detached 6bb33e5

fix(rebrand): preserve external contracts

Keep required Codex identity metadata, negotiate extension protocol v2, make tagged release retries idempotent, and align the first-release installer examples.
commit
6bb33e50d0d25ec9e9a5b1871b9d928dd9b8057c
author
brice · · 2026-08-05 16:09 UTC
parent
e4a06c5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
22 files · +248 -91
A .github/scripts/next-release-tag.sh +60 -0
@@ -0,0 +1,60 @@
1+#!/usr/bin/env bash
2+# Print the release tag for HEAD, or nothing when its unreleased commits do not
3+# require a release. A tag already pointing to HEAD is emitted again so a
4+# failed GoReleaser job can be retried without minting a second tag.
5+set -euo pipefail
6+
7+latest=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1)
8+head=$(git rev-parse HEAD)
9+if [ -n "$latest" ] && [ "$(git rev-parse "$latest^{}")" = "$head" ]; then
10+ printf '%s\n' "$latest"
11+ exit 0
12+fi
13+
14+if [ -n "$latest" ]; then
15+ range="${latest}..HEAD"
16+else
17+ range="HEAD"
18+fi
19+
20+minor=false
21+patch=false
22+while IFS= read -r commit; do
23+ subject=$(git log -1 --format=%s "$commit")
24+ body=$(git log -1 --format=%b "$commit")
25+
26+ # A breaking change is a v0.x minor release.
27+ if grep -Eq '^[[:alpha:]]+(\([^)]+\))?!:' <<<"$subject" || grep -Eiq '^BREAKING[ -]CHANGE:' <<<"$body"; then
28+ minor=true
29+ continue
30+ fi
31+
32+ type=${subject%%(*}
33+ type=${type%%:*}
34+ type=${type%%!*}
35+ type=$(tr '[:upper:]' '[:lower:]' <<<"$type")
36+ case "$type" in
37+ feat) minor=true ;;
38+ fix|perf|refactor|build|chore|ci) patch=true ;;
39+ docs|test|style) ;;
40+ *) ;;
41+ esac
42+done < <(git rev-list "$range")
43+
44+if [ "$minor" != true ] && [ "$patch" != true ]; then
45+ exit 0
46+fi
47+
48+# The renamed project starts a new public release line at v0.1.0.
49+if [ -z "$latest" ]; then
50+ printf '%s\n' 'v0.1.0'
51+ exit 0
52+fi
53+
54+version=${latest#v}
55+IFS=. read -r major minor_version patch_version <<<"$version"
56+if [ "$minor" = true ]; then
57+ printf 'v%s.%s.0\n' "$major" "$((minor_version + 1))"
58+else
59+ printf 'v%s.%s.%s\n' "$major" "$minor_version" "$((patch_version + 1))"
60+fi
@@ -0,0 +1,60 @@
1+#!/usr/bin/env bash
2+# Print the release tag for HEAD, or nothing when its unreleased commits do not
3+# require a release. A tag already pointing to HEAD is emitted again so a
4+# failed GoReleaser job can be retried without minting a second tag.
5+set -euo pipefail
6+
7+latest=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1)
8+head=$(git rev-parse HEAD)
9+if [ -n "$latest" ] && [ "$(git rev-parse "$latest^{}")" = "$head" ]; then
10+ printf '%s\n' "$latest"
11+ exit 0
12+fi
13+
14+if [ -n "$latest" ]; then
15+ range="${latest}..HEAD"
16+else
17+ range="HEAD"
18+fi
19+
20+minor=false
21+patch=false
22+while IFS= read -r commit; do
23+ subject=$(git log -1 --format=%s "$commit")
24+ body=$(git log -1 --format=%b "$commit")
25+
26+ # A breaking change is a v0.x minor release.
27+ if grep -Eq '^[[:alpha:]]+(\([^)]+\))?!:' <<<"$subject" || grep -Eiq '^BREAKING[ -]CHANGE:' <<<"$body"; then
28+ minor=true
29+ continue
30+ fi
31+
32+ type=${subject%%(*}
33+ type=${type%%:*}
34+ type=${type%%!*}
35+ type=$(tr '[:upper:]' '[:lower:]' <<<"$type")
36+ case "$type" in
37+ feat) minor=true ;;
38+ fix|perf|refactor|build|chore|ci) patch=true ;;
39+ docs|test|style) ;;
40+ *) ;;
41+ esac
42+done < <(git rev-list "$range")
43+
44+if [ "$minor" != true ] && [ "$patch" != true ]; then
45+ exit 0
46+fi
47+
48+# The renamed project starts a new public release line at v0.1.0.
49+if [ -z "$latest" ]; then
50+ printf '%s\n' 'v0.1.0'
51+ exit 0
52+fi
53+
54+version=${latest#v}
55+IFS=. read -r major minor_version patch_version <<<"$version"
56+if [ "$minor" = true ]; then
57+ printf 'v%s.%s.0\n' "$major" "$((minor_version + 1))"
58+else
59+ printf 'v%s.%s.%s\n' "$major" "$minor_version" "$((patch_version + 1))"
60+fi
A .github/scripts/next-release-tag_test.sh +39 -0
@@ -0,0 +1,39 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
5+script="$repo_root/.github/scripts/next-release-tag.sh"
6+tmp=$(mktemp -d)
7+trap 'rm -rf "$tmp"' EXIT
8+
9+cd "$tmp"
10+git init --quiet
11+git config user.name 'Release Test'
12+git config user.email 'release-test@example.invalid'
13+
14+git commit --allow-empty --quiet -m 'feat: first release'
15+expect() {
16+ local want=$1
17+ local got
18+ got=$($script)
19+ if [ "$got" != "$want" ]; then
20+ printf 'next-release-tag = %q, want %q\n' "$got" "$want" >&2
21+ exit 1
22+ fi
23+}
24+
25+expect 'v0.1.0'
26+git tag -a v0.1.0 -m 'release v0.1.0'
27+
28+# A rerun after tag creation must release the existing tag, not skip it.
29+expect 'v0.1.0'
30+
31+git commit --allow-empty --quiet -m 'docs: explain releases'
32+expect ''
33+
34+git commit --allow-empty --quiet -m 'fix: repair installer'
35+expect 'v0.1.1'
36+git tag -a v0.1.1 -m 'release v0.1.1'
37+
38+git commit --allow-empty --quiet -m 'refactor!: replace extension handshake'
39+expect 'v0.2.0'
@@ -0,0 +1,39 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
5+script="$repo_root/.github/scripts/next-release-tag.sh"
6+tmp=$(mktemp -d)
7+trap 'rm -rf "$tmp"' EXIT
8+
9+cd "$tmp"
10+git init --quiet
11+git config user.name 'Release Test'
12+git config user.email 'release-test@example.invalid'
13+
14+git commit --allow-empty --quiet -m 'feat: first release'
15+expect() {
16+ local want=$1
17+ local got
18+ got=$($script)
19+ if [ "$got" != "$want" ]; then
20+ printf 'next-release-tag = %q, want %q\n' "$got" "$want" >&2
21+ exit 1
22+ fi
23+}
24+
25+expect 'v0.1.0'
26+git tag -a v0.1.0 -m 'release v0.1.0'
27+
28+# A rerun after tag creation must release the existing tag, not skip it.
29+expect 'v0.1.0'
30+
31+git commit --allow-empty --quiet -m 'docs: explain releases'
32+expect ''
33+
34+git commit --allow-empty --quiet -m 'fix: repair installer'
35+expect 'v0.1.1'
36+git tag -a v0.1.1 -m 'release v0.1.1'
37+
38+git commit --allow-empty --quiet -m 'refactor!: replace extension handshake'
39+expect 'v0.2.0'
M .github/workflows/ci.yml +3 -0
@@ -53,6 +53,9 @@ jobs:
5353 steps:
5454 - uses: actions/checkout@v4
5555
56+ - name: release-version policy
57+ run: bash .github/scripts/next-release-tag_test.sh
58+
5659 - name: set up go
5760 uses: actions/setup-go@v7
5861 with:
@@ -53,6 +53,9 @@ jobs:
53 steps:53 steps:
54 - uses: actions/checkout@v454 - uses: actions/checkout@v4
55 55
56+ - name: release-version policy
57+ run: bash .github/scripts/next-release-tag_test.sh
58+
56 - name: set up go59 - name: set up go
57 uses: actions/setup-go@v760 uses: actions/setup-go@v7
58 with:61 with:
M .github/workflows/release.yml +5 -50
@@ -38,58 +38,13 @@ jobs:
3838 shell: bash
3939 run: |
4040 set -euo pipefail
41-
42- latest=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1)
43- if [ -n "$latest" ]; then
44- range="${latest}..HEAD"
45- else
46- range="HEAD"
47- fi
48-
49- minor=false
50- patch=false
51- while IFS= read -r commit; do
52- subject=$(git log -1 --format=%s "$commit")
53- body=$(git log -1 --format=%b "$commit")
54-
55- # A breaking change is a v0.x minor release.
56- if grep -Eq '^[[:alpha:]]+(\([^)]+\))?!:' <<<"$subject" || grep -Eiq '^BREAKING[ -]CHANGE:' <<<"$body"; then
57- minor=true
58- continue
59- fi
60-
61- type=${subject%%(*}
62- type=${type%%:*}
63- type=${type%%!*}
64- type=$(tr '[:upper:]' '[:lower:]' <<<"$type")
65- case "$type" in
66- feat) minor=true ;;
67- fix|perf|refactor|build|chore|ci) patch=true ;;
68- docs|test|style) ;;
69- *) ;;
70- esac
71- done < <(git rev-list "$range")
72-
73- if [ "$minor" != true ] && [ "$patch" != true ]; then
74- echo "No releasable conventional commits since ${latest:-the repository start}."
41+ tag=$(bash .github/scripts/next-release-tag.sh)
42+ if [ -z "$tag" ]; then
43+ echo "No releasable conventional commits for this run."
7544 exit 0
7645 fi
77-
78- # The renamed project starts a new public release line at v0.1.0.
79- if [ -z "$latest" ]; then
80- next="v0.1.0"
81- else
82- version=${latest#v}
83- IFS=. read -r major minor_version patch_version <<<"$version"
84- if [ "$minor" = true ]; then
85- next="v${major}.$((minor_version + 1)).0"
86- else
87- next="v${major}.${minor_version}.$((patch_version + 1))"
88- fi
89- fi
90-
91- echo "Creating $next from $range"
92- echo "tag=$next" >> "$GITHUB_OUTPUT"
46+ echo "Releasing $tag from $(git rev-parse --short HEAD)"
47+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
9348
9449 - name: Create and push the tag
9550 if: steps.version.outputs.tag != ''
@@ -38,58 +38,13 @@ jobs:
38 shell: bash38 shell: bash
39 run: |39 run: |
40 set -euo pipefail40 set -euo pipefail
41-41+ tag=$(bash .github/scripts/next-release-tag.sh)
42- latest=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1)42+ if [ -z "$tag" ]; then
43- if [ -n "$latest" ]; then43+ echo "No releasable conventional commits for this run."
44- range="${latest}..HEAD"
45- else
46- range="HEAD"
47- fi
48-
49- minor=false
50- patch=false
51- while IFS= read -r commit; do
52- subject=$(git log -1 --format=%s "$commit")
53- body=$(git log -1 --format=%b "$commit")
54-
55- # A breaking change is a v0.x minor release.
56- if grep -Eq '^[[:alpha:]]+(\([^)]+\))?!:' <<<"$subject" || grep -Eiq '^BREAKING[ -]CHANGE:' <<<"$body"; then
57- minor=true
58- continue
59- fi
60-
61- type=${subject%%(*}
62- type=${type%%:*}
63- type=${type%%!*}
64- type=$(tr '[:upper:]' '[:lower:]' <<<"$type")
65- case "$type" in
66- feat) minor=true ;;
67- fix|perf|refactor|build|chore|ci) patch=true ;;
68- docs|test|style) ;;
69- *) ;;
70- esac
71- done < <(git rev-list "$range")
72-
73- if [ "$minor" != true ] && [ "$patch" != true ]; then
74- echo "No releasable conventional commits since ${latest:-the repository start}."
75 exit 044 exit 0
76 fi45 fi
77-46+ echo "Releasing $tag from $(git rev-parse --short HEAD)"
78- # The renamed project starts a new public release line at v0.1.0.47+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
79- if [ -z "$latest" ]; then
80- next="v0.1.0"
81- else
82- version=${latest#v}
83- IFS=. read -r major minor_version patch_version <<<"$version"
84- if [ "$minor" = true ]; then
85- next="v${major}.$((minor_version + 1)).0"
86- else
87- next="v${major}.${minor_version}.$((patch_version + 1))"
88- fi
89- fi
90-
91- echo "Creating $next from $range"
92- echo "tag=$next" >> "$GITHUB_OUTPUT"
93 48
94 - name: Create and push the tag49 - name: Create and push the tag
95 if: steps.version.outputs.tag != ''50 if: steps.version.outputs.tag != ''
M README.md +1 -1
@@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash
4343 Detects your OS and architecture, downloads the latest release from GitHub, verifies the SHA-256 against the release's `checksums.txt`, extracts the binary, and drops it in `/usr/local/bin`, `~/.local/bin`, or `~/bin`, whichever is writable first. Pass a version or prefix to pin:
4444
4545 ```bash
46-curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.0.1 ~/bin
46+curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.1.0 ~/bin
4747 ```
4848
4949 ### One-liner (Windows, PowerShell)
@@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash
43 Detects your OS and architecture, downloads the latest release from GitHub, verifies the SHA-256 against the release's `checksums.txt`, extracts the binary, and drops it in `/usr/local/bin`, `~/.local/bin`, or `~/bin`, whichever is writable first. Pass a version or prefix to pin:43 Detects your OS and architecture, downloads the latest release from GitHub, verifies the SHA-256 against the release's `checksums.txt`, extracts the binary, and drops it in `/usr/local/bin`, `~/.local/bin`, or `~/bin`, whichever is writable first. Pass a version or prefix to pin:
44 44
45 ```bash45 ```bash
46-curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.0.1 ~/bin46+curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.1.0 ~/bin
47 ```47 ```
48 48
49 ### One-liner (Windows, PowerShell)49 ### One-liner (Windows, PowerShell)
M docs/extensions.md +12 -8
@@ -35,11 +35,13 @@ def emit(obj):
3535 sys.stdout.write(json.dumps(obj) + "\n")
3636 sys.stdout.flush()
3737
38-emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]})
38+emit({"type":"hello","protocol_version":2,"name":"hello-py","version":"1.0.0","capabilities":["commands"]})
3939
4040 for line in sys.stdin:
4141 msg = json.loads(line)
4242 if msg["type"] == "hello_ack":
43+ if msg.get("protocol_version") != 2:
44+ raise SystemExit("unsupported extension protocol version")
4345 emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})
4446 emit({"type":"ready"})
4547 elif msg["type"] == "command_invoked":
@@ -156,9 +158,10 @@ manifest tells zut how to launch it:
156158 redirects to `$ZUT_HOME/logs/ext-<name>.log` (one file per
157159 extension, append-mode).
158160 3. **Hello handshake**: the extension's first stdout frame must be
159- `hello`; zut replies with `hello_ack` containing the protocol
160- version, the active provider/model/cwd, and the extension's own
161- data directory so it can persist files beside its manifest.
161+ `hello` with `protocol_version: 2`; zut rejects a mismatched major
162+ and replies with `hello_ack` containing the same protocol version,
163+ the active provider/model/cwd, and the extension's own data directory
164+ so it can persist files beside its manifest.
162165 4. **Registration**: after receiving `hello_ack`, the extension sends
163166 `register_command`, `register_tool`, and subscription frames, then
164167 sends `ready`. First-come-first-served: a name already taken by a
@@ -193,7 +196,7 @@ or any other stdout frame before `hello`.
193196 #### `hello` (required, first frame)
194197
195198 ```json
196-{"type":"hello","name":"weather","version":"1.0.0",
199+{"type":"hello","protocol_version":2,"name":"weather","version":"1.0.0",
197200 "capabilities":["commands","tools","alerts","panels"]}
198201 ```
199202
@@ -498,7 +501,7 @@ Sent in response to `shutdown`. Extension should exit promptly after.
498501 #### `hello_ack`
499502
500503 ```json
501-{"type":"hello_ack","protocol_version":1,
504+{"type":"hello_ack","protocol_version":2,
502505 "zut_version":"0.0.7","provider":"anthropic",
503506 "model":"claude-opus-4-7","cwd":"/path/to/zut",
504507 "extension_dir":"/path/to/zut/.zut/extensions/todos",
@@ -508,8 +511,9 @@ Sent in response to `shutdown`. Extension should exit promptly after.
508511 "fork_point":4}}
509512 ```
510513
511-Sent immediately after `hello`. Wait for this frame before sending
512-registrations if they depend on host metadata. The extension can use
514+Sent immediately after a matching `hello`. The protocol version is a
515+major and must match the version sent by the extension. Wait for this frame
516+before sending registrations if they depend on host metadata. The extension can use
513517 these fields to decide which commands to register (e.g. only register
514518 a Python tool on macOS, only register a model-specific shortcut for
515519 opus, etc.). `cwd` is the user's project directory; the extension
@@ -35,11 +35,13 @@ def emit(obj):
35 sys.stdout.write(json.dumps(obj) + "\n")35 sys.stdout.write(json.dumps(obj) + "\n")
36 sys.stdout.flush()36 sys.stdout.flush()
37 37
38-emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]})38+emit({"type":"hello","protocol_version":2,"name":"hello-py","version":"1.0.0","capabilities":["commands"]})
39 39
40 for line in sys.stdin:40 for line in sys.stdin:
41 msg = json.loads(line)41 msg = json.loads(line)
42 if msg["type"] == "hello_ack":42 if msg["type"] == "hello_ack":
43+ if msg.get("protocol_version") != 2:
44+ raise SystemExit("unsupported extension protocol version")
43 emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})45 emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})
44 emit({"type":"ready"})46 emit({"type":"ready"})
45 elif msg["type"] == "command_invoked":47 elif msg["type"] == "command_invoked":
@@ -156,9 +158,10 @@ manifest tells zut how to launch it:
156 redirects to `$ZUT_HOME/logs/ext-<name>.log` (one file per158 redirects to `$ZUT_HOME/logs/ext-<name>.log` (one file per
157 extension, append-mode).159 extension, append-mode).
158 3. **Hello handshake**: the extension's first stdout frame must be160 3. **Hello handshake**: the extension's first stdout frame must be
159- `hello`; zut replies with `hello_ack` containing the protocol161+ `hello` with `protocol_version: 2`; zut rejects a mismatched major
160- version, the active provider/model/cwd, and the extension's own162+ and replies with `hello_ack` containing the same protocol version,
161- data directory so it can persist files beside its manifest.163+ the active provider/model/cwd, and the extension's own data directory
164+ so it can persist files beside its manifest.
162 4. **Registration**: after receiving `hello_ack`, the extension sends165 4. **Registration**: after receiving `hello_ack`, the extension sends
163 `register_command`, `register_tool`, and subscription frames, then166 `register_command`, `register_tool`, and subscription frames, then
164 sends `ready`. First-come-first-served: a name already taken by a167 sends `ready`. First-come-first-served: a name already taken by a
@@ -193,7 +196,7 @@ or any other stdout frame before `hello`.
193 #### `hello` (required, first frame)196 #### `hello` (required, first frame)
194 197
195 ```json198 ```json
196-{"type":"hello","name":"weather","version":"1.0.0",199+{"type":"hello","protocol_version":2,"name":"weather","version":"1.0.0",
197 "capabilities":["commands","tools","alerts","panels"]}200 "capabilities":["commands","tools","alerts","panels"]}
198 ```201 ```
199 202
@@ -498,7 +501,7 @@ Sent in response to `shutdown`. Extension should exit promptly after.
498 #### `hello_ack`501 #### `hello_ack`
499 502
500 ```json503 ```json
501-{"type":"hello_ack","protocol_version":1,504+{"type":"hello_ack","protocol_version":2,
502 "zut_version":"0.0.7","provider":"anthropic",505 "zut_version":"0.0.7","provider":"anthropic",
503 "model":"claude-opus-4-7","cwd":"/path/to/zut",506 "model":"claude-opus-4-7","cwd":"/path/to/zut",
504 "extension_dir":"/path/to/zut/.zut/extensions/todos",507 "extension_dir":"/path/to/zut/.zut/extensions/todos",
@@ -508,8 +511,9 @@ Sent in response to `shutdown`. Extension should exit promptly after.
508 "fork_point":4}}511 "fork_point":4}}
509 ```512 ```
510 513
511-Sent immediately after `hello`. Wait for this frame before sending514+Sent immediately after a matching `hello`. The protocol version is a
512-registrations if they depend on host metadata. The extension can use515+major and must match the version sent by the extension. Wait for this frame
516+before sending registrations if they depend on host metadata. The extension can use
513 these fields to decide which commands to register (e.g. only register517 these fields to decide which commands to register (e.g. only register
514 a Python tool on macOS, only register a model-specific shortcut for518 a Python tool on macOS, only register a model-specific shortcut for
515 opus, etc.). `cwd` is the user's project directory; the extension519 opus, etc.). `cwd` is the user's project directory; the extension
M examples/extensions/clock/index.js +7 -0
@@ -52,6 +52,7 @@ function log(msg) {
5252 // 1. Hello first.
5353 send({
5454 type: "hello",
55+ protocol_version: 2,
5556 name: NAME,
5657 version: VERSION,
5758 capabilities: ["commands"],
@@ -84,6 +85,12 @@ rl.on("line", (line) => {
8485
8586 switch (frame.type) {
8687 case "hello_ack":
88+ if (frame.protocol_version !== 2) {
89+ log(`unsupported protocol version: ${frame.protocol_version}`);
90+ process.exitCode = 1;
91+ rl.close();
92+ break;
93+ }
8794 log(
8895 `connected to zut ${frame.zut_version} (${frame.provider}/${frame.model})`,
8996 );
@@ -52,6 +52,7 @@ function log(msg) {
52 // 1. Hello first.52 // 1. Hello first.
53 send({53 send({
54 type: "hello",54 type: "hello",
55+ protocol_version: 2,
55 name: NAME,56 name: NAME,
56 version: VERSION,57 version: VERSION,
57 capabilities: ["commands"],58 capabilities: ["commands"],
@@ -84,6 +85,12 @@ rl.on("line", (line) => {
84 85
85 switch (frame.type) {86 switch (frame.type) {
86 case "hello_ack":87 case "hello_ack":
88+ if (frame.protocol_version !== 2) {
89+ log(`unsupported protocol version: ${frame.protocol_version}`);
90+ process.exitCode = 1;
91+ rl.close();
92+ break;
93+ }
87 log(94 log(
88 `connected to zut ${frame.zut_version} (${frame.provider}/${frame.model})`,95 `connected to zut ${frame.zut_version} (${frame.provider}/${frame.model})`,
89 );96 );
M examples/extensions/scratchpad/index.ts +7 -0
@@ -177,6 +177,7 @@ function renderNotes(): string {
177177
178178 send({
179179 type: "hello",
180+ protocol_version: 2,
180181 name: NAME,
181182 version: VERSION,
182183 capabilities: ["commands", "tools"],
@@ -251,6 +252,12 @@ rl.on("close", () => {
251252 });
252253
253254 function handleHelloAck(ack: HelloAck): void {
255+ if (ack.protocol_version !== 2) {
256+ log(`unsupported protocol version: ${ack.protocol_version}`);
257+ process.exitCode = 1;
258+ rl.close();
259+ return;
260+ }
254261 log(
255262 `connected to zut ${ack.zut_version} ` +
256263 `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`,
@@ -177,6 +177,7 @@ function renderNotes(): string {
177 177
178 send({178 send({
179 type: "hello",179 type: "hello",
180+ protocol_version: 2,
180 name: NAME,181 name: NAME,
181 version: VERSION,182 version: VERSION,
182 capabilities: ["commands", "tools"],183 capabilities: ["commands", "tools"],
@@ -251,6 +252,12 @@ rl.on("close", () => {
251 });252 });
252 253
253 function handleHelloAck(ack: HelloAck): void {254 function handleHelloAck(ack: HelloAck): void {
255+ if (ack.protocol_version !== 2) {
256+ log(`unsupported protocol version: ${ack.protocol_version}`);
257+ process.exitCode = 1;
258+ rl.close();
259+ return;
260+ }
254 log(261 log(
255 `connected to zut ${ack.zut_version} ` +262 `connected to zut ${ack.zut_version} ` +
256 `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`,263 `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`,
M install.ps1 +1 -1
@@ -4,7 +4,7 @@
44 # iwr -useb https://raw.githubusercontent.com/bnema/zut/main/install.ps1 | iex
55 #
66 # Or with arguments:
7-# $env:ZUT_VERSION = "v0.0.1"
7+# $env:ZUT_VERSION = "v0.1.0"
88 # $env:ZUT_PREFIX = "$HOME\bin"
99 # iwr -useb https://raw.githubusercontent.com/bnema/zut/main/install.ps1 | iex
1010 #
@@ -4,7 +4,7 @@
4 # iwr -useb https://raw.githubusercontent.com/bnema/zut/main/install.ps1 | iex4 # iwr -useb https://raw.githubusercontent.com/bnema/zut/main/install.ps1 | iex
5 #5 #
6 # Or with arguments:6 # Or with arguments:
7-# $env:ZUT_VERSION = "v0.0.1"7+# $env:ZUT_VERSION = "v0.1.0"
8 # $env:ZUT_PREFIX = "$HOME\bin"8 # $env:ZUT_PREFIX = "$HOME\bin"
9 # iwr -useb https://raw.githubusercontent.com/bnema/zut/main/install.ps1 | iex9 # iwr -useb https://raw.githubusercontent.com/bnema/zut/main/install.ps1 | iex
10 #10 #
M install.sh +2 -2
@@ -4,10 +4,10 @@
44 #
55 # Usage:
66 # curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash
7-# curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.0.1 ~/bin
7+# curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.1.0 ~/bin
88 #
99 # Positional arguments:
10-# $1 version — release tag (e.g. v0.0.1). Defaults to "latest".
10+# $1 version — release tag (e.g. v0.1.0). Defaults to "latest".
1111 # $2 prefix — install directory. Defaults to the first writable
1212 # directory in: /usr/local/bin, $HOME/.local/bin,
1313 # $HOME/bin. Created if missing. Add it to your PATH
@@ -4,10 +4,10 @@
4 #4 #
5 # Usage:5 # Usage:
6 # curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash6 # curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash
7-# curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.0.1 ~/bin7+# curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.1.0 ~/bin
8 #8 #
9 # Positional arguments:9 # Positional arguments:
10-# $1 version — release tag (e.g. v0.0.1). Defaults to "latest".10+# $1 version — release tag (e.g. v0.1.0). Defaults to "latest".
11 # $2 prefix — install directory. Defaults to the first writable11 # $2 prefix — install directory. Defaults to the first writable
12 # directory in: /usr/local/bin, $HOME/.local/bin,12 # directory in: /usr/local/bin, $HOME/.local/bin,
13 # $HOME/bin. Created if missing. Add it to your PATH13 # $HOME/bin. Created if missing. Add it to your PATH
M packages/agent/ext/ext.go +8 -4
@@ -559,10 +559,11 @@ func (e *Extension) Run() error {
559559 // synchronously so extensions can use HostInfo while registering
560560 // commands/tools before the ready frame.
561561 if err := e.send(extproto.HelloFromExt{
562- Type: "hello",
563- Name: e.name,
564- Version: e.version,
565- Capabilities: e.caps,
562+ Type: "hello",
563+ ProtocolVersion: extproto.ProtocolVersion,
564+ Name: e.name,
565+ Version: e.version,
566+ Capabilities: e.caps,
566567 }); err != nil {
567568 return err
568569 }
@@ -576,6 +577,9 @@ func (e *Extension) Run() error {
576577 if ack.Type != "hello_ack" {
577578 return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type)
578579 }
580+ if ack.ProtocolVersion != extproto.ProtocolVersion {
581+ return fmt.Errorf("unsupported host protocol version %d (want %d)", ack.ProtocolVersion, extproto.ProtocolVersion)
582+ }
579583 e.host = HostInfo{
580584 ProtocolVersion: ack.ProtocolVersion,
581585 ZutVersion: ack.ZutVersion,
@@ -559,10 +559,11 @@ func (e *Extension) Run() error {
559 // synchronously so extensions can use HostInfo while registering559 // synchronously so extensions can use HostInfo while registering
560 // commands/tools before the ready frame.560 // commands/tools before the ready frame.
561 if err := e.send(extproto.HelloFromExt{561 if err := e.send(extproto.HelloFromExt{
562- Type: "hello",562+ Type: "hello",
563- Name: e.name,563+ ProtocolVersion: extproto.ProtocolVersion,
564- Version: e.version,564+ Name: e.name,
565- Capabilities: e.caps,565+ Version: e.version,
566+ Capabilities: e.caps,
566 }); err != nil {567 }); err != nil {
567 return err568 return err
568 }569 }
@@ -576,6 +577,9 @@ func (e *Extension) Run() error {
576 if ack.Type != "hello_ack" {577 if ack.Type != "hello_ack" {
577 return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type)578 return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type)
578 }579 }
580+ if ack.ProtocolVersion != extproto.ProtocolVersion {
581+ return fmt.Errorf("unsupported host protocol version %d (want %d)", ack.ProtocolVersion, extproto.ProtocolVersion)
582+ }
579 e.host = HostInfo{583 e.host = HostInfo{
580 ProtocolVersion: ack.ProtocolVersion,584 ProtocolVersion: ack.ProtocolVersion,
581 ZutVersion: ack.ZutVersion,585 ZutVersion: ack.ZutVersion,
M packages/agent/ext/ext_test.go +28 -0
@@ -140,6 +140,13 @@ func (h *extHarness) handshake(t *testing.T) {
140140 if f.hdr.Type != "hello" {
141141 t.Fatalf("expected hello, got %q", f.hdr.Type)
142142 }
143+ var hello extproto.HelloFromExt
144+ if err := json.Unmarshal(f.raw, &hello); err != nil {
145+ t.Fatalf("unmarshal hello: %v", err)
146+ }
147+ if hello.ProtocolVersion != extproto.ProtocolVersion {
148+ t.Fatalf("hello protocol version = %d, want %d", hello.ProtocolVersion, extproto.ProtocolVersion)
149+ }
143150 h.sendToExt(t, extproto.HelloAckFromHost{
144151 Type: "hello_ack",
145152 ProtocolVersion: extproto.ProtocolVersion,
@@ -157,6 +164,27 @@ func (h *extHarness) handshake(t *testing.T) {
157164
158165 // ---------- tests ----------
159166
167+func TestRunRejectsUnsupportedHostProtocolVersion(t *testing.T) {
168+ h := newHarness("versioned-ext")
169+ runDone := make(chan error, 1)
170+ go func() { runDone <- h.ext.Run() }()
171+ t.Cleanup(func() { _ = h.hostW.Close() })
172+
173+ if frame := h.next(t); frame.hdr.Type != "hello" {
174+ t.Fatalf("expected hello, got %q", frame.hdr.Type)
175+ }
176+ h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", ProtocolVersion: extproto.ProtocolVersion - 1})
177+
178+ select {
179+ case err := <-runDone:
180+ if err == nil || !strings.Contains(err.Error(), "unsupported host protocol version") {
181+ t.Fatalf("Run error = %v", err)
182+ }
183+ case <-time.After(2 * time.Second):
184+ t.Fatal("Run did not reject an unsupported host protocol version")
185+ }
186+}
187+
160188 // TestOnHelloCanRegisterUsingHostInfo checks that extensions can use
161189 // host metadata before announcing their initial registrations.
162190 func TestOnHelloCanRegisterUsingHostInfo(t *testing.T) {
@@ -140,6 +140,13 @@ func (h *extHarness) handshake(t *testing.T) {
140 if f.hdr.Type != "hello" {140 if f.hdr.Type != "hello" {
141 t.Fatalf("expected hello, got %q", f.hdr.Type)141 t.Fatalf("expected hello, got %q", f.hdr.Type)
142 }142 }
143+ var hello extproto.HelloFromExt
144+ if err := json.Unmarshal(f.raw, &hello); err != nil {
145+ t.Fatalf("unmarshal hello: %v", err)
146+ }
147+ if hello.ProtocolVersion != extproto.ProtocolVersion {
148+ t.Fatalf("hello protocol version = %d, want %d", hello.ProtocolVersion, extproto.ProtocolVersion)
149+ }
143 h.sendToExt(t, extproto.HelloAckFromHost{150 h.sendToExt(t, extproto.HelloAckFromHost{
144 Type: "hello_ack",151 Type: "hello_ack",
145 ProtocolVersion: extproto.ProtocolVersion,152 ProtocolVersion: extproto.ProtocolVersion,
@@ -157,6 +164,27 @@ func (h *extHarness) handshake(t *testing.T) {
157 164
158 // ---------- tests ----------165 // ---------- tests ----------
159 166
167+func TestRunRejectsUnsupportedHostProtocolVersion(t *testing.T) {
168+ h := newHarness("versioned-ext")
169+ runDone := make(chan error, 1)
170+ go func() { runDone <- h.ext.Run() }()
171+ t.Cleanup(func() { _ = h.hostW.Close() })
172+
173+ if frame := h.next(t); frame.hdr.Type != "hello" {
174+ t.Fatalf("expected hello, got %q", frame.hdr.Type)
175+ }
176+ h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", ProtocolVersion: extproto.ProtocolVersion - 1})
177+
178+ select {
179+ case err := <-runDone:
180+ if err == nil || !strings.Contains(err.Error(), "unsupported host protocol version") {
181+ t.Fatalf("Run error = %v", err)
182+ }
183+ case <-time.After(2 * time.Second):
184+ t.Fatal("Run did not reject an unsupported host protocol version")
185+ }
186+}
187+
160 // TestOnHelloCanRegisterUsingHostInfo checks that extensions can use188 // TestOnHelloCanRegisterUsingHostInfo checks that extensions can use
161 // host metadata before announcing their initial registrations.189 // host metadata before announcing their initial registrations.
162 func TestOnHelloCanRegisterUsingHostInfo(t *testing.T) {190 func TestOnHelloCanRegisterUsingHostInfo(t *testing.T) {
M packages/agent/extensions/intercept_test.go +2 -2
@@ -39,7 +39,7 @@ func TestInterceptAllThreeEvents(t *testing.T) {
3939 extDir := t.TempDir()
4040 script := `#!/bin/bash
4141 emit() { printf '%s\n' "$1"; }
42-emit '{"type":"hello","name":"itest","version":"0.1.0","capabilities":["events"]}'
42+emit '{"type":"hello","protocol_version":2,"name":"itest","version":"0.1.0","capabilities":["events"]}'
4343 emit '{"type":"subscribe","events":[],"intercept":["tool_call","turn_start","assistant_message"]}'
4444 emit '{"type":"ready"}'
4545 while IFS= read -r line; do
@@ -159,7 +159,7 @@ func TestReloadRespawnsExtensions(t *testing.T) {
159159 extDir := t.TempDir()
160160 script := `#!/bin/bash
161161 emit() { printf '%s\n' "$1"; }
162-emit '{"type":"hello","name":"rtest","version":"0.1.0","capabilities":["commands"]}'
162+emit '{"type":"hello","protocol_version":2,"name":"rtest","version":"0.1.0","capabilities":["commands"]}'
163163 emit '{"type":"register_command","name":"rtest","description":"test"}'
164164 emit '{"type":"ready"}'
165165 while IFS= read -r line; do
@@ -39,7 +39,7 @@ func TestInterceptAllThreeEvents(t *testing.T) {
39 extDir := t.TempDir()39 extDir := t.TempDir()
40 script := `#!/bin/bash40 script := `#!/bin/bash
41 emit() { printf '%s\n' "$1"; }41 emit() { printf '%s\n' "$1"; }
42-emit '{"type":"hello","name":"itest","version":"0.1.0","capabilities":["events"]}'42+emit '{"type":"hello","protocol_version":2,"name":"itest","version":"0.1.0","capabilities":["events"]}'
43 emit '{"type":"subscribe","events":[],"intercept":["tool_call","turn_start","assistant_message"]}'43 emit '{"type":"subscribe","events":[],"intercept":["tool_call","turn_start","assistant_message"]}'
44 emit '{"type":"ready"}'44 emit '{"type":"ready"}'
45 while IFS= read -r line; do45 while IFS= read -r line; do
@@ -159,7 +159,7 @@ func TestReloadRespawnsExtensions(t *testing.T) {
159 extDir := t.TempDir()159 extDir := t.TempDir()
160 script := `#!/bin/bash160 script := `#!/bin/bash
161 emit() { printf '%s\n' "$1"; }161 emit() { printf '%s\n' "$1"; }
162-emit '{"type":"hello","name":"rtest","version":"0.1.0","capabilities":["commands"]}'162+emit '{"type":"hello","protocol_version":2,"name":"rtest","version":"0.1.0","capabilities":["commands"]}'
163 emit '{"type":"register_command","name":"rtest","description":"test"}'163 emit '{"type":"register_command","name":"rtest","description":"test"}'
164 emit '{"type":"ready"}'164 emit '{"type":"ready"}'
165 while IFS= read -r line; do165 while IFS= read -r line; do
M packages/agent/extensions/manager.go +3 -0
@@ -707,6 +707,9 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
707707 if hello.Type != "hello" || hello.Name == "" {
708708 return fmt.Errorf("first frame must be hello (got %q; stderr log: %s)", hello.Type, logPath)
709709 }
710+ if hello.ProtocolVersion != extproto.ProtocolVersion {
711+ return fmt.Errorf("unsupported extension protocol version %d (want %d; stderr log: %s)", hello.ProtocolVersion, extproto.ProtocolVersion, logPath)
712+ }
710713 // Trust the manifest's name; ignore mismatch from the hello.
711714 ext.helloAck = true
712715
@@ -707,6 +707,9 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
707 if hello.Type != "hello" || hello.Name == "" {707 if hello.Type != "hello" || hello.Name == "" {
708 return fmt.Errorf("first frame must be hello (got %q; stderr log: %s)", hello.Type, logPath)708 return fmt.Errorf("first frame must be hello (got %q; stderr log: %s)", hello.Type, logPath)
709 }709 }
710+ if hello.ProtocolVersion != extproto.ProtocolVersion {
711+ return fmt.Errorf("unsupported extension protocol version %d (want %d; stderr log: %s)", hello.ProtocolVersion, extproto.ProtocolVersion, logPath)
712+ }
710 // Trust the manifest's name; ignore mismatch from the hello.713 // Trust the manifest's name; ignore mismatch from the hello.
711 ext.helloAck = true714 ext.helloAck = true
712 715
M packages/agent/extensions/manager_test.go +35 -6
@@ -123,7 +123,7 @@ func writeMockExtension(t *testing.T, root string) {
123123 // stdin closes; tail's -F keeps the pipe alive long enough for
124124 // the manager to send command_invoked.
125125 script := `#!/bin/sh
126-printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}'
126+printf '%s\n' '{"type":"hello","protocol_version":2,"name":"mock","version":"0.0.1","capabilities":["commands"]}'
127127 printf '%s\n' '{"type":"register_command","name":"Ping","description":"ping/pong"}'
128128 while IFS= read -r line; do
129129 case "$line" in
@@ -260,6 +260,35 @@ func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {
260260 }
261261 }
262262
263+func TestSpawnRejectsUnsupportedExtensionProtocolVersion(t *testing.T) {
264+ if runtime.GOOS == "windows" {
265+ t.Skip("mock extension uses /bin/sh; skip on windows")
266+ }
267+
268+ tmp := t.TempDir()
269+ extDir := filepath.Join(tmp, "broken")
270+ if err := os.MkdirAll(extDir, 0o755); err != nil {
271+ t.Fatal(err)
272+ }
273+ script := `#!/bin/sh
274+printf '%s\n' '{"type":"hello","protocol_version":1,"name":"broken"}'
275+while IFS= read -r line; do :; done
276+`
277+ if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
278+ t.Fatal(err)
279+ }
280+
281+ ext := &Extension{Manifest: Manifest{Name: "broken", Exec: "./run.sh"}, Dir: extDir}
282+ mgr := New(tmp, "", "0.0.0-test", "", "", nil)
283+ err := mgr.spawn(context.Background(), ext)
284+ if err == nil || !strings.Contains(err.Error(), "unsupported extension protocol version 1") {
285+ t.Fatalf("spawn error = %v", err)
286+ }
287+ if ext.cmd == nil || ext.cmd.ProcessState == nil {
288+ t.Fatal("failed protocol handshake process was not reaped")
289+ }
290+}
291+
263292 func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
264293 tmp := t.TempDir()
265294 extDir := filepath.Join(tmp, "extensions", "theme-only")
@@ -423,7 +452,7 @@ func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) {
423452 }
424453
425454 writeDiagExtension("a-first", `#!/bin/sh
426-printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["commands","tools"]}'
455+printf '%s\n' '{"type":"hello","protocol_version":2,"name":"a-first","version":"0.1","capabilities":["commands","tools"]}'
427456 printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}'
428457 printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'
429458 printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'
@@ -435,7 +464,7 @@ while IFS= read -r line; do
435464 done
436465 `)
437466 writeDiagExtension("b-second", `#!/bin/sh
438-printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["commands","tools"]}'
467+printf '%s\n' '{"type":"hello","protocol_version":2,"name":"b-second","version":"0.1","capabilities":["commands","tools"]}'
439468 printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}'
440469 printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'
441470 printf '%s\n' '{"type":"ready"}'
@@ -512,7 +541,7 @@ func TestSpontaneousSubmit(t *testing.T) {
512541 }
513542
514543 script := `#!/bin/sh
515-printf '%s\n' '{"type":"hello","name":"submit-mock","version":"0.1","capabilities":["submit"]}'
544+printf '%s\n' '{"type":"hello","protocol_version":2,"name":"submit-mock","version":"0.1","capabilities":["submit"]}'
516545 printf '%s\n' '{"type":"ready"}'
517546 printf '%s\n' '{"type":"submit","text":" explain this repository briefly "}'
518547 printf '%s\n' '{"type":"submit","text":" "}'
@@ -575,7 +604,7 @@ func TestSpontaneousAlert(t *testing.T) {
575604 }
576605
577606 script := `#!/bin/sh
578-printf '%s\n' '{"type":"hello","name":"alert-mock","version":"0.1","capabilities":["alerts"]}'
607+printf '%s\n' '{"type":"hello","protocol_version":2,"name":"alert-mock","version":"0.1","capabilities":["alerts"]}'
579608 printf '%s\n' '{"type":"ready"}'
580609 printf '%s\n' '{"type":"alert","kind":"bell","reason":"question_ready"}'
581610 while IFS= read -r line; do
@@ -643,7 +672,7 @@ func TestSpontaneousOpenPanel(t *testing.T) {
643672 // Extension emits hello + ready, then immediately fires a
644673 // spontaneous open_panel, then waits for shutdown.
645674 script := `#!/bin/sh
646-printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}'
675+printf '%s\n' '{"type":"hello","protocol_version":2,"name":"panel-mock","version":"0.1","capabilities":["panels"]}'
647676 printf '%s\n' '{"type":"ready"}'
648677 printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}'
649678 while IFS= read -r line; do
@@ -123,7 +123,7 @@ func writeMockExtension(t *testing.T, root string) {
123 // stdin closes; tail's -F keeps the pipe alive long enough for123 // stdin closes; tail's -F keeps the pipe alive long enough for
124 // the manager to send command_invoked.124 // the manager to send command_invoked.
125 script := `#!/bin/sh125 script := `#!/bin/sh
126-printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}'126+printf '%s\n' '{"type":"hello","protocol_version":2,"name":"mock","version":"0.0.1","capabilities":["commands"]}'
127 printf '%s\n' '{"type":"register_command","name":"Ping","description":"ping/pong"}'127 printf '%s\n' '{"type":"register_command","name":"Ping","description":"ping/pong"}'
128 while IFS= read -r line; do128 while IFS= read -r line; do
129 case "$line" in129 case "$line" in
@@ -260,6 +260,35 @@ func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {
260 }260 }
261 }261 }
262 262
263+func TestSpawnRejectsUnsupportedExtensionProtocolVersion(t *testing.T) {
264+ if runtime.GOOS == "windows" {
265+ t.Skip("mock extension uses /bin/sh; skip on windows")
266+ }
267+
268+ tmp := t.TempDir()
269+ extDir := filepath.Join(tmp, "broken")
270+ if err := os.MkdirAll(extDir, 0o755); err != nil {
271+ t.Fatal(err)
272+ }
273+ script := `#!/bin/sh
274+printf '%s\n' '{"type":"hello","protocol_version":1,"name":"broken"}'
275+while IFS= read -r line; do :; done
276+`
277+ if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
278+ t.Fatal(err)
279+ }
280+
281+ ext := &Extension{Manifest: Manifest{Name: "broken", Exec: "./run.sh"}, Dir: extDir}
282+ mgr := New(tmp, "", "0.0.0-test", "", "", nil)
283+ err := mgr.spawn(context.Background(), ext)
284+ if err == nil || !strings.Contains(err.Error(), "unsupported extension protocol version 1") {
285+ t.Fatalf("spawn error = %v", err)
286+ }
287+ if ext.cmd == nil || ext.cmd.ProcessState == nil {
288+ t.Fatal("failed protocol handshake process was not reaped")
289+ }
290+}
291+
263 func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {292 func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
264 tmp := t.TempDir()293 tmp := t.TempDir()
265 extDir := filepath.Join(tmp, "extensions", "theme-only")294 extDir := filepath.Join(tmp, "extensions", "theme-only")
@@ -423,7 +452,7 @@ func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) {
423 }452 }
424 453
425 writeDiagExtension("a-first", `#!/bin/sh454 writeDiagExtension("a-first", `#!/bin/sh
426-printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["commands","tools"]}'455+printf '%s\n' '{"type":"hello","protocol_version":2,"name":"a-first","version":"0.1","capabilities":["commands","tools"]}'
427 printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}'456 printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}'
428 printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'457 printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'
429 printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'458 printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'
@@ -435,7 +464,7 @@ while IFS= read -r line; do
435 done464 done
436 `)465 `)
437 writeDiagExtension("b-second", `#!/bin/sh466 writeDiagExtension("b-second", `#!/bin/sh
438-printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["commands","tools"]}'467+printf '%s\n' '{"type":"hello","protocol_version":2,"name":"b-second","version":"0.1","capabilities":["commands","tools"]}'
439 printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}'468 printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}'
440 printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'469 printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'
441 printf '%s\n' '{"type":"ready"}'470 printf '%s\n' '{"type":"ready"}'
@@ -512,7 +541,7 @@ func TestSpontaneousSubmit(t *testing.T) {
512 }541 }
513 542
514 script := `#!/bin/sh543 script := `#!/bin/sh
515-printf '%s\n' '{"type":"hello","name":"submit-mock","version":"0.1","capabilities":["submit"]}'544+printf '%s\n' '{"type":"hello","protocol_version":2,"name":"submit-mock","version":"0.1","capabilities":["submit"]}'
516 printf '%s\n' '{"type":"ready"}'545 printf '%s\n' '{"type":"ready"}'
517 printf '%s\n' '{"type":"submit","text":" explain this repository briefly "}'546 printf '%s\n' '{"type":"submit","text":" explain this repository briefly "}'
518 printf '%s\n' '{"type":"submit","text":" "}'547 printf '%s\n' '{"type":"submit","text":" "}'
@@ -575,7 +604,7 @@ func TestSpontaneousAlert(t *testing.T) {
575 }604 }
576 605
577 script := `#!/bin/sh606 script := `#!/bin/sh
578-printf '%s\n' '{"type":"hello","name":"alert-mock","version":"0.1","capabilities":["alerts"]}'607+printf '%s\n' '{"type":"hello","protocol_version":2,"name":"alert-mock","version":"0.1","capabilities":["alerts"]}'
579 printf '%s\n' '{"type":"ready"}'608 printf '%s\n' '{"type":"ready"}'
580 printf '%s\n' '{"type":"alert","kind":"bell","reason":"question_ready"}'609 printf '%s\n' '{"type":"alert","kind":"bell","reason":"question_ready"}'
581 while IFS= read -r line; do610 while IFS= read -r line; do
@@ -643,7 +672,7 @@ func TestSpontaneousOpenPanel(t *testing.T) {
643 // Extension emits hello + ready, then immediately fires a672 // Extension emits hello + ready, then immediately fires a
644 // spontaneous open_panel, then waits for shutdown.673 // spontaneous open_panel, then waits for shutdown.
645 script := `#!/bin/sh674 script := `#!/bin/sh
646-printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}'675+printf '%s\n' '{"type":"hello","protocol_version":2,"name":"panel-mock","version":"0.1","capabilities":["panels"]}'
647 printf '%s\n' '{"type":"ready"}'676 printf '%s\n' '{"type":"ready"}'
648 printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}'677 printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}'
649 while IFS= read -r line; do678 while IFS= read -r line; do
M packages/agent/extensions/tool_test.go +1 -1
@@ -23,7 +23,7 @@ func writeMockToolExtension(t *testing.T, root string) {
2323 // Shell-script extension: hello, register one tool, ready, then
2424 // loop on stdin echoing back tool_call args as tool_result text.
2525 script := `#!/bin/sh
26-printf '%s\n' '{"type":"hello","name":"tool-mock","version":"0.1","capabilities":["tools"]}'
26+printf '%s\n' '{"type":"hello","protocol_version":2,"name":"tool-mock","version":"0.1","capabilities":["tools"]}'
2727 printf '%s\n' '{"type":"register_tool","name":"echo","description":"echo back the args","schema":{"type":"object","properties":{"msg":{"type":"string"}}}}'
2828 printf '%s\n' '{"type":"ready"}'
2929 while IFS= read -r line; do
@@ -23,7 +23,7 @@ func writeMockToolExtension(t *testing.T, root string) {
23 // Shell-script extension: hello, register one tool, ready, then23 // Shell-script extension: hello, register one tool, ready, then
24 // loop on stdin echoing back tool_call args as tool_result text.24 // loop on stdin echoing back tool_call args as tool_result text.
25 script := `#!/bin/sh25 script := `#!/bin/sh
26-printf '%s\n' '{"type":"hello","name":"tool-mock","version":"0.1","capabilities":["tools"]}'26+printf '%s\n' '{"type":"hello","protocol_version":2,"name":"tool-mock","version":"0.1","capabilities":["tools"]}'
27 printf '%s\n' '{"type":"register_tool","name":"echo","description":"echo back the args","schema":{"type":"object","properties":{"msg":{"type":"string"}}}}'27 printf '%s\n' '{"type":"register_tool","name":"echo","description":"echo back the args","schema":{"type":"object","properties":{"msg":{"type":"string"}}}}'
28 printf '%s\n' '{"type":"ready"}'28 printf '%s\n' '{"type":"ready"}'
29 while IFS= read -r line; do29 while IFS= read -r line; do
M packages/agent/extproto/extproto.go +8 -5
@@ -22,7 +22,9 @@ import (
2222 "strings"
2323 )
2424
25-const ProtocolVersion = 1
25+// ProtocolVersion is the extension protocol major. Version 2 introduces the
26+// zut_version wire key and requires both sides to reject mismatched majors.
27+const ProtocolVersion = 2
2628
2729 type Frame struct {
2830 Type string `json:"type"`
@@ -30,10 +32,11 @@ type Frame struct {
3032 }
3133
3234 type HelloFromExt struct {
33- Type string `json:"type"`
34- Name string `json:"name"`
35- Version string `json:"version"`
36- Capabilities []string `json:"capabilities,omitempty"`
35+ Type string `json:"type"`
36+ ProtocolVersion int `json:"protocol_version"`
37+ Name string `json:"name"`
38+ Version string `json:"version"`
39+ Capabilities []string `json:"capabilities,omitempty"`
3740 }
3841
3942 type RegisterCommandFromExt struct {
@@ -22,7 +22,9 @@ import (
22 "strings"22 "strings"
23 )23 )
24 24
25-const ProtocolVersion = 125+// ProtocolVersion is the extension protocol major. Version 2 introduces the
26+// zut_version wire key and requires both sides to reject mismatched majors.
27+const ProtocolVersion = 2
26 28
27 type Frame struct {29 type Frame struct {
28 Type string `json:"type"`30 Type string `json:"type"`
@@ -30,10 +32,11 @@ type Frame struct {
30 }32 }
31 33
32 type HelloFromExt struct {34 type HelloFromExt struct {
33- Type string `json:"type"`35+ Type string `json:"type"`
34- Name string `json:"name"`36+ ProtocolVersion int `json:"protocol_version"`
35- Version string `json:"version"`37+ Name string `json:"name"`
36- Capabilities []string `json:"capabilities,omitempty"`38+ Version string `json:"version"`
39+ Capabilities []string `json:"capabilities,omitempty"`
37 }40 }
38 41
39 type RegisterCommandFromExt struct {42 type RegisterCommandFromExt struct {
M packages/agent/skills/builtin/write-zut-extension/SKILL.md +10 -6
@@ -92,17 +92,18 @@ their responses.
9292 The very first frame the extension sends is `hello`:
9393
9494 ```json
95-{"type":"hello","name":"weather","version":"1.0.0",
95+{"type":"hello","protocol_version":2,"name":"weather","version":"1.0.0",
9696 "capabilities":["commands","tools"]}
9797 ```
9898
9999 Capabilities are advisory; current values are `commands`, `tools`,
100-`events`, `alerts`, and `panels`. Send all that apply.
100+`events`, `alerts`, and `panels`. Send all that apply. `protocol_version`
101+is the major wire version; it is currently `2` and must match the host.
101102
102103 zut replies with `hello_ack`:
103104
104105 ```json
105-{"type":"hello_ack","protocol_version":1,"zut_version":"0.0.x",
106+{"type":"hello_ack","protocol_version":2,"zut_version":"0.0.x",
106107 "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"}
107108 ```
108109
@@ -275,13 +276,14 @@ import { stderr, stdin, stdout } from "node:process";
275276 function send(o: object) { stdout.write(JSON.stringify(o) + "\n"); }
276277 function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); }
277278
278-send({ type: "hello", name: "scratchpad", version: "1.0.0",
279+send({ type: "hello", protocol_version: 2, name: "scratchpad", version: "1.0.0",
279280 capabilities: ["commands", "tools"] });
280281
281282 const rl = createInterface({ input: stdin, crlfDelay: Infinity });
282283 rl.on("line", (line) => {
283284 const f = JSON.parse(line);
284285 if (f.type === "hello_ack") {
286+ if (f.protocol_version !== 2) throw new Error("unsupported extension protocol version");
285287 // f.cwd is the user's project directory.
286288 send({ type: "register_command", name: "note", description: "append a note" });
287289 send({ type: "register_tool", name: "read_notes",
@@ -317,11 +319,13 @@ import json, sys
317319
318320 def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()
319321
320-emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]})
322+emit({"type": "hello", "protocol_version": 2, "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]})
321323
322324 for line in sys.stdin:
323325 msg = json.loads(line)
324326 if msg["type"] == "hello_ack":
327+ if msg.get("protocol_version") != 2:
328+ raise SystemExit("unsupported extension protocol version")
325329 # msg["cwd"] is the user's project directory.
326330 emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})
327331 emit({"type": "ready"})
@@ -367,7 +371,7 @@ to see exactly what's happening on the wire:
367371
368372 ```bash
369373 {
370- printf '%s\n' '{"type":"hello_ack","protocol_version":1,"zut_version":"x","provider":"a","model":"o","cwd":"/tmp"}'
374+ printf '%s\n' '{"type":"hello_ack","protocol_version":2,"zut_version":"x","provider":"a","model":"o","cwd":"/tmp"}'
371375 sleep 0.2
372376 printf '%s\n' '{"type":"command_invoked","id":"1","name":"weather","args":"Berlin"}'
373377 sleep 0.5
@@ -92,17 +92,18 @@ their responses.
92 The very first frame the extension sends is `hello`:92 The very first frame the extension sends is `hello`:
93 93
94 ```json94 ```json
95-{"type":"hello","name":"weather","version":"1.0.0",95+{"type":"hello","protocol_version":2,"name":"weather","version":"1.0.0",
96 "capabilities":["commands","tools"]}96 "capabilities":["commands","tools"]}
97 ```97 ```
98 98
99 Capabilities are advisory; current values are `commands`, `tools`,99 Capabilities are advisory; current values are `commands`, `tools`,
100-`events`, `alerts`, and `panels`. Send all that apply.100+`events`, `alerts`, and `panels`. Send all that apply. `protocol_version`
101+is the major wire version; it is currently `2` and must match the host.
101 102
102 zut replies with `hello_ack`:103 zut replies with `hello_ack`:
103 104
104 ```json105 ```json
105-{"type":"hello_ack","protocol_version":1,"zut_version":"0.0.x",106+{"type":"hello_ack","protocol_version":2,"zut_version":"0.0.x",
106 "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"}107 "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"}
107 ```108 ```
108 109
@@ -275,13 +276,14 @@ import { stderr, stdin, stdout } from "node:process";
275 function send(o: object) { stdout.write(JSON.stringify(o) + "\n"); }276 function send(o: object) { stdout.write(JSON.stringify(o) + "\n"); }
276 function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); }277 function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); }
277 278
278-send({ type: "hello", name: "scratchpad", version: "1.0.0",279+send({ type: "hello", protocol_version: 2, name: "scratchpad", version: "1.0.0",
279 capabilities: ["commands", "tools"] });280 capabilities: ["commands", "tools"] });
280 281
281 const rl = createInterface({ input: stdin, crlfDelay: Infinity });282 const rl = createInterface({ input: stdin, crlfDelay: Infinity });
282 rl.on("line", (line) => {283 rl.on("line", (line) => {
283 const f = JSON.parse(line);284 const f = JSON.parse(line);
284 if (f.type === "hello_ack") {285 if (f.type === "hello_ack") {
286+ if (f.protocol_version !== 2) throw new Error("unsupported extension protocol version");
285 // f.cwd is the user's project directory.287 // f.cwd is the user's project directory.
286 send({ type: "register_command", name: "note", description: "append a note" });288 send({ type: "register_command", name: "note", description: "append a note" });
287 send({ type: "register_tool", name: "read_notes",289 send({ type: "register_tool", name: "read_notes",
@@ -317,11 +319,13 @@ import json, sys
317 319
318 def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()320 def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()
319 321
320-emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]})322+emit({"type": "hello", "protocol_version": 2, "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]})
321 323
322 for line in sys.stdin:324 for line in sys.stdin:
323 msg = json.loads(line)325 msg = json.loads(line)
324 if msg["type"] == "hello_ack":326 if msg["type"] == "hello_ack":
327+ if msg.get("protocol_version") != 2:
328+ raise SystemExit("unsupported extension protocol version")
325 # msg["cwd"] is the user's project directory.329 # msg["cwd"] is the user's project directory.
326 emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})330 emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})
327 emit({"type": "ready"})331 emit({"type": "ready"})
@@ -367,7 +371,7 @@ to see exactly what's happening on the wire:
367 371
368 ```bash372 ```bash
369 {373 {
370- printf '%s\n' '{"type":"hello_ack","protocol_version":1,"zut_version":"x","provider":"a","model":"o","cwd":"/tmp"}'374+ printf '%s\n' '{"type":"hello_ack","protocol_version":2,"zut_version":"x","provider":"a","model":"o","cwd":"/tmp"}'
371 sleep 0.2375 sleep 0.2
372 printf '%s\n' '{"type":"command_invoked","id":"1","name":"weather","args":"Berlin"}'376 printf '%s\n' '{"type":"command_invoked","id":"1","name":"weather","args":"Berlin"}'
373 sleep 0.5377 sleep 0.5
M packages/provider/auth/auth_test.go +1 -1
@@ -118,7 +118,7 @@ func TestOpenAIAuthorizeURL(t *testing.T) {
118118 if state == p.Verifier {
119119 t.Fatal("openai state should be random, not verifier")
120120 }
121- if q.Get("originator") != "zut" {
121+ if q.Get("originator") != "zot" {
122122 t.Fatalf("originator=%q", q.Get("originator"))
123123 }
124124 }
@@ -118,7 +118,7 @@ func TestOpenAIAuthorizeURL(t *testing.T) {
118 if state == p.Verifier {118 if state == p.Verifier {
119 t.Fatal("openai state should be random, not verifier")119 t.Fatal("openai state should be random, not verifier")
120 }120 }
121- if q.Get("originator") != "zut" {121+ if q.Get("originator") != "zot" {
122 t.Fatalf("originator=%q", q.Get("originator"))122 t.Fatalf("originator=%q", q.Get("originator"))
123 }123 }
124 }124 }
M packages/provider/auth/oauth.go +3 -1
@@ -108,7 +108,9 @@ var (
108108 ExtraAuthArgs: map[string]string{
109109 "id_token_add_organizations": "true",
110110 "codex_cli_simplified_flow": "true",
111- "originator": "zut",
111+ // OpenAI's registered Codex OAuth flow requires this legacy
112+ // originator value; it is not a zut product identifier.
113+ "originator": "zot",
112114 },
113115 }
114116
@@ -108,7 +108,9 @@ var (
108 ExtraAuthArgs: map[string]string{108 ExtraAuthArgs: map[string]string{
109 "id_token_add_organizations": "true",109 "id_token_add_organizations": "true",
110 "codex_cli_simplified_flow": "true",110 "codex_cli_simplified_flow": "true",
111- "originator": "zut",111+ // OpenAI's registered Codex OAuth flow requires this legacy
112+ // originator value; it is not a zut product identifier.
113+ "originator": "zot",
112 },114 },
113 }115 }
114 116
M packages/provider/openai_codex.go +5 -3
@@ -21,7 +21,7 @@ import (
2121 // Wire protocol notes:
2222 // - Endpoint: POST https://chatgpt.com/backend-api/codex/responses
2323 // - Headers: Authorization: Bearer <access_token>, chatgpt-account-id: <id>,
24-// OpenAI-Beta: responses=experimental, originator: zut
24+// OpenAI-Beta: responses=experimental, originator: zot (required legacy value)
2525 // - Body: OpenAI Responses API shape (not chat/completions).
2626 // input: [{role, content: [{type: "input_text" | "input_image" | ... }]}]
2727 // instructions: <system prompt>
@@ -410,8 +410,10 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
410410 httpReq.Header.Set("session-id", codexCLISessionID)
411411 httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
412412 } else {
413- httpReq.Header.Set("originator", "zut")
414- httpReq.Header.Set("user-agent", fmt.Sprintf("zut (%s %s)", runtime.GOOS, runtime.GOARCH))
413+ // ChatGPT's backend recognizes these established values. They are
414+ // external provider metadata, not zut product identifiers.
415+ httpReq.Header.Set("originator", "zot")
416+ httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
415417 }
416418 return httpReq, nil
417419 }
@@ -21,7 +21,7 @@ import (
21 // Wire protocol notes:21 // Wire protocol notes:
22 // - Endpoint: POST https://chatgpt.com/backend-api/codex/responses22 // - Endpoint: POST https://chatgpt.com/backend-api/codex/responses
23 // - Headers: Authorization: Bearer <access_token>, chatgpt-account-id: <id>,23 // - Headers: Authorization: Bearer <access_token>, chatgpt-account-id: <id>,
24-// OpenAI-Beta: responses=experimental, originator: zut24+// OpenAI-Beta: responses=experimental, originator: zot (required legacy value)
25 // - Body: OpenAI Responses API shape (not chat/completions).25 // - Body: OpenAI Responses API shape (not chat/completions).
26 // input: [{role, content: [{type: "input_text" | "input_image" | ... }]}]26 // input: [{role, content: [{type: "input_text" | "input_image" | ... }]}]
27 // instructions: <system prompt>27 // instructions: <system prompt>
@@ -410,8 +410,10 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
410 httpReq.Header.Set("session-id", codexCLISessionID)410 httpReq.Header.Set("session-id", codexCLISessionID)
411 httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")411 httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
412 } else {412 } else {
413- httpReq.Header.Set("originator", "zut")413+ // ChatGPT's backend recognizes these established values. They are
414- httpReq.Header.Set("user-agent", fmt.Sprintf("zut (%s %s)", runtime.GOOS, runtime.GOARCH))414+ // external provider metadata, not zut product identifiers.
415+ httpReq.Header.Set("originator", "zot")
416+ httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
415 }417 }
416 return httpReq, nil418 return httpReq, nil
417 }419 }
M packages/provider/openai_codex_test.go +7 -0
@@ -6,6 +6,7 @@ import (
66 "encoding/json"
77 "io"
88 "net/http"
9+ "runtime"
910 "strings"
1011 "testing"
1112 )
@@ -175,6 +176,12 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
175176 if gotBody.PromptCacheKey != "" {
176177 t.Fatalf("prompt_cache_key = %q", gotBody.PromptCacheKey)
177178 }
179+ if gotReq.Header.Get("originator") != "zot" {
180+ t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
181+ }
182+ if gotReq.Header.Get("user-agent") != "zot ("+runtime.GOOS+" "+runtime.GOARCH+")" {
183+ t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
184+ }
178185 }
179186
180187 func TestCodexNestedStreamError(t *testing.T) {
@@ -6,6 +6,7 @@ import (
6 "encoding/json"6 "encoding/json"
7 "io"7 "io"
8 "net/http"8 "net/http"
9+ "runtime"
9 "strings"10 "strings"
10 "testing"11 "testing"
11 )12 )
@@ -175,6 +176,12 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
175 if gotBody.PromptCacheKey != "" {176 if gotBody.PromptCacheKey != "" {
176 t.Fatalf("prompt_cache_key = %q", gotBody.PromptCacheKey)177 t.Fatalf("prompt_cache_key = %q", gotBody.PromptCacheKey)
177 }178 }
179+ if gotReq.Header.Get("originator") != "zot" {
180+ t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
181+ }
182+ if gotReq.Header.Get("user-agent") != "zot ("+runtime.GOOS+" "+runtime.GOARCH+")" {
183+ t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
184+ }
178 }185 }
179 186
180 func TestCodexNestedStreamError(t *testing.T) {187 func TestCodexNestedStreamError(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close