@sjawhar/claude-envoy 0.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/.mcp.json +11 -0
  3. package/.omp-plugin/plugin.json +6 -0
  4. package/AGENTS.md +343 -0
  5. package/README.md +639 -0
  6. package/bin/dispatch +6 -0
  7. package/bin/envoy-channel.ts +9 -0
  8. package/bin/envoy-send.ts +33 -0
  9. package/dist/THIRD_PARTY_NOTICES +816 -0
  10. package/dist/dispatch.js +27707 -0
  11. package/dist/envoy-channel.js +57695 -0
  12. package/dist/session-hook.js +26328 -0
  13. package/dist/session-title-hook.js +116 -0
  14. package/hooks/hooks.json +60 -0
  15. package/hooks/session-hook.ts +512 -0
  16. package/hooks/session-title-hook.ts +46 -0
  17. package/legion/.claude-plugin/plugin.json +4 -0
  18. package/legion/agents/deep-worker.md +68 -0
  19. package/legion/agents/oracle.md +35 -0
  20. package/legion/agents/plan-gap-analyst.md +65 -0
  21. package/legion/agents/plan-reviewer.md +104 -0
  22. package/legion/agents/thermonuclear-code-quality.md +30 -0
  23. package/legion/agents/thermonuclear-deep-review.md +30 -0
  24. package/legion/hooks/hooks.json +89 -0
  25. package/legion/skills/ce-simplify-code/LICENSE +21 -0
  26. package/legion/skills/ce-simplify-code/SKILL.md +66 -0
  27. package/legion/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
  28. package/legion/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
  29. package/legion/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
  30. package/legion/skills/leading-a-work-stream/SKILL.md +96 -0
  31. package/legion/skills/leading-a-work-stream/references/daily-plan.md +8 -0
  32. package/legion/skills/leading-a-work-stream/references/decision-rights.md +6 -0
  33. package/legion/skills/leading-a-work-stream/references/direction-and-coaching.md +13 -0
  34. package/legion/skills/leading-a-work-stream/references/dispatch-upkeep.md +8 -0
  35. package/legion/skills/leading-a-work-stream/references/sources.md +9 -0
  36. package/legion/skills/leading-a-work-stream/references/speed-tick.md +44 -0
  37. package/legion/skills/leading-a-work-stream/references/user-quotes.md +7 -0
  38. package/legion/skills/legion-architect/SKILL.md +86 -0
  39. package/legion/skills/legion-architect/references/acceptance.md +208 -0
  40. package/legion/skills/legion-architect/references/decomposition.md +129 -0
  41. package/legion/skills/legion-architect/references/design-gate.md +206 -0
  42. package/legion/skills/legion-architect/references/merge-notices.md +91 -0
  43. package/legion/skills/legion-architect/references/wake-routing.md +41 -0
  44. package/legion/skills/legion-controller/SKILL.md +151 -0
  45. package/legion/skills/legion-controller/references/admission.md +22 -0
  46. package/legion/skills/legion-controller/references/backlog-stewardship.md +101 -0
  47. package/legion/skills/legion-controller/references/daily-report.md +75 -0
  48. package/legion/skills/legion-controller/references/escalation.md +82 -0
  49. package/legion/skills/legion-controller/references/finding-asks.md +181 -0
  50. package/legion/skills/legion-controller/references/parked-decisions.md +198 -0
  51. package/legion/skills/legion-controller/references/session.md +100 -0
  52. package/legion/skills/legion-controller/references/slack-mentions.md +154 -0
  53. package/legion/skills/legion-controller/references/triage.md +96 -0
  54. package/legion/skills/legion-controller/references/wakes.md +132 -0
  55. package/legion/skills/legion-oracle/SKILL.md +48 -0
  56. package/legion/skills/legion-retro/SKILL.md +112 -0
  57. package/legion/skills/legion-retro/references/learnings.md +60 -0
  58. package/legion/skills/legion-retro/references/pr-body-content.md +56 -0
  59. package/legion/skills/legion-worker/SKILL.md +69 -0
  60. package/legion/skills/legion-worker/references/cleanup-deletion.md +22 -0
  61. package/legion/skills/legion-worker/references/conflicts-and-rewrites.md +164 -0
  62. package/legion/skills/legion-worker/references/default-branch-check.md +53 -0
  63. package/legion/skills/legion-worker/references/findings.md +199 -0
  64. package/legion/skills/legion-worker/references/merge-gate.md +353 -0
  65. package/legion/skills/legion-worker/references/pr-body.md +218 -0
  66. package/legion/skills/legion-worker/references/review-threads.md +77 -0
  67. package/legion/skills/legion-worker/references/systematic-rename.md +19 -0
  68. package/legion/skills/legion-worker/references/tools.md +708 -0
  69. package/legion/skills/thermonuclear-code-quality/LICENSE +21 -0
  70. package/legion/skills/thermonuclear-code-quality/SKILL.md +60 -0
  71. package/legion/skills/thermonuclear-deep-review/LICENSE +21 -0
  72. package/legion/skills/thermonuclear-deep-review/SKILL.md +80 -0
  73. package/package.json +49 -1
  74. package/scripts/build-inputs.txt +84 -0
  75. package/scripts/build.ts +415 -0
  76. package/scripts/check-packed-skills.sh +50 -0
  77. package/scripts/pack-skills.sh +146 -0
  78. package/scripts/smoke-channel.sh +178 -0
  79. package/scripts/smoke-clear-rebind.sh +164 -0
  80. package/scripts/smoke-session-title.sh +245 -0
  81. package/scripts/verify-build.sh +88 -0
  82. package/skills/agent-secrets/SKILL.md +180 -0
  83. package/skills/asd-ste100/LICENSE +21 -0
  84. package/skills/asd-ste100/SKILL.md +140 -0
  85. package/skills/asd-ste100/examples/before-after.md +92 -0
  86. package/skills/asd-ste100/references/writing-rules.md +59 -0
  87. package/skills/dispatch/SKILL.md +89 -0
  88. package/skills/dispatch-first/SKILL.md +46 -0
  89. package/skills/dispatch-writing-specs/SKILL.md +80 -0
  90. package/skills/envoy/SKILL.md +82 -0
  91. package/skills/envoy/references/check-settlement.md +62 -0
  92. package/skills/envoy/references/deliveries.md +67 -0
  93. package/skills/envoy/references/legion.md +14 -0
  94. package/skills/envoy/references/roles.md +12 -0
  95. package/skills/envoy/references/sources.md +41 -0
  96. package/skills/outside-message/SKILL.md +111 -0
  97. package/skills/plain-words/SKILL.md +88 -0
  98. package/src/channel-forwarder.ts +262 -0
  99. package/src/claude-channel.ts +705 -0
  100. package/src/claude-session.ts +38 -0
  101. package/src/envoy-channel-server.ts +2115 -0
  102. package/src/envoy-client.ts +26 -0
  103. package/src/envoy-off.ts +62 -0
  104. package/src/legion-claim.ts +211 -0
  105. package/src/legion-inbox.ts +110 -0
  106. package/src/legion-phase-hook.ts +554 -0
  107. package/src/legion-tool.ts +69 -0
  108. package/src/replay-state.ts +97 -0
  109. package/src/send-arguments.ts +13 -0
  110. package/src/session-identity.ts +165 -0
  111. package/src/session-state.ts +142 -0
  112. package/src/session-title.ts +251 -0
  113. package/src/subscription-topics.ts +16 -0
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env bash
2
+ # smoke-channel.sh — real Claude Code channel delivery smoke.
3
+ # See README.md for prerequisites, configuration, and why this is not CI.
4
+ set -euo pipefail
5
+
6
+ readonly envoy_url="${ENVOY_URL:-http://127.0.0.1:9020}"
7
+ readonly nats_url="${ENVOY_NATS_URL:?set ENVOY_NATS_URL to the live Envoy NATS listener}"
8
+ readonly claude_bin="${CLAUDE_BIN:-claude}"
9
+ readonly channel_entry="${CLAUDE_CHANNEL_ENTRY:?set CLAUDE_CHANNEL_ENTRY, e.g. plugin:claude-envoy@<marketplace>}"
10
+ # `--channels` only works for an entry managed settings allowlist (`allowedChannelPlugins`);
11
+ # here that is the `legion-plugins` marketplace. Everything else (a dev marketplace, a
12
+ # bare `server:` entry) needs the development bypass. Always overridable.
13
+ if [[ -n "${CLAUDE_CHANNEL_FLAG:-}" ]]; then
14
+ readonly channel_flag="$CLAUDE_CHANNEL_FLAG"
15
+ elif [[ "$channel_entry" == *"@legion-plugins" ]]; then
16
+ readonly channel_flag="--channels"
17
+ else
18
+ readonly channel_flag="--dangerously-load-development-channels"
19
+ fi
20
+ readonly plugin_dir="${CLAUDE_PLUGIN_DIR:-}"
21
+ readonly mcp_config="${CLAUDE_MCP_CONFIG:-}"
22
+ readonly startup_timeout=60
23
+ readonly session_timeout=45
24
+ readonly delivery_timeout=90
25
+ readonly teardown_timeout=15
26
+
27
+ fail() {
28
+ printf 'FAIL: %s\n' "$*" >&2
29
+ [[ -n "${tmux_session:-}" ]] && tmux capture-pane -pt "$tmux_session" >&2 || true
30
+ if [[ -n "${claude_output:-}" && -f "$claude_output" ]]; then
31
+ printf -- '--- claude pane output (%s) ---\n' "$claude_output" >&2
32
+ cat "$claude_output" >&2 || true
33
+ fi
34
+ exit 1
35
+ }
36
+
37
+ command -v "$claude_bin" >/dev/null 2>&1 || fail "$claude_bin is not on PATH"
38
+ command -v tmux >/dev/null 2>&1 || fail "tmux is required"
39
+ command -v curl >/dev/null 2>&1 || fail "curl is required"
40
+ command -v jq >/dev/null 2>&1 || fail "jq is required"
41
+
42
+ readonly nonce="$(od -An -N4 -tx1 /dev/urandom | tr -d ' \n')"
43
+ readonly target_session="claude-channel-smoke-${nonce}"
44
+ readonly message="channel-smoke-payload-${nonce}"
45
+ workdir="$(mktemp -d "/tmp/${target_session}.XXXXXX")"
46
+ readonly workdir
47
+ # The smoke runs its own tmux server on a socket in its scratch directory, so its cleanup can end
48
+ # only the session it started: `tmux kill-session` on the shared default server could end anyone's.
49
+ readonly tmux_socket="${workdir}/tmux.sock"
50
+ tmux() { command tmux -S "$tmux_socket" "$@"; }
51
+ readonly received_file="${workdir}/received.txt"
52
+ readonly claude_output="${workdir}/claude-output.txt"
53
+ readonly delivery_instruction="Envoy channel smoke: use Bash to write exactly ${message}, with no trailing newline, to ${received_file}; then reply CHANNEL_SMOKE_DONE."
54
+ tmux_session=""
55
+
56
+ cleanup() {
57
+ [[ -n "$tmux_session" ]] && tmux kill-session -t "$tmux_session" 2>/dev/null || true
58
+ rm -rf "$workdir"
59
+ }
60
+ trap cleanup EXIT INT TERM
61
+
62
+ # Claude Code shows up to three dialogs before its prompt, in an order that
63
+ # depends on what this machine has already consented to: folder trust, the
64
+ # development-channel warning, and first-use MCP server consent. Poll the pane
65
+ # and answer whichever is showing until the prompt appears.
66
+ answer_startup_dialogs() {
67
+ local deadline=$((SECONDS + startup_timeout)) pane
68
+ while (( SECONDS < deadline )); do
69
+ pane="$(tmux capture-pane -pt "$tmux_session" 2>/dev/null || true)"
70
+ if [[ "$pane" == *"Yes, I trust this folder"* ]]; then
71
+ tmux send-keys -t "$tmux_session" Down Enter
72
+ elif [[ "$pane" == *"I am using this for local development"* ]]; then
73
+ tmux send-keys -t "$tmux_session" Enter
74
+ elif [[ "$pane" == *"New MCP server found"* ]]; then
75
+ tmux send-keys -t "$tmux_session" Enter
76
+ elif [[ "$pane" == *"shift+tab to cycle"* || "$pane" == *"? for shortcuts"* ]]; then
77
+ # The permission-mode status line is drawn only once the prompt is live.
78
+ return 0
79
+ fi
80
+ sleep 1
81
+ done
82
+ return 1
83
+ }
84
+
85
+ wait_for_session() {
86
+ local deadline=$((SECONDS + session_timeout)) response
87
+ while (( SECONDS < deadline )); do
88
+ response="$(curl --connect-timeout 2 --max-time 5 -fsS "${envoy_url}/v1/sessions?dir=${workdir}" 2>/dev/null || true)"
89
+ if jq -e --arg id "$target_session" '.[] | select(.session_id == $id)' <<<"$response" >/dev/null 2>&1; then
90
+ session_row="$(jq -c --arg id "$target_session" '.[] | select(.session_id == $id) | {session_id, dir, capabilities}' <<<"$response")"
91
+ return 0
92
+ fi
93
+ sleep 1
94
+ done
95
+ return 1
96
+ }
97
+
98
+ wait_for_delivery() {
99
+ local deadline=$((SECONDS + delivery_timeout))
100
+ while (( SECONDS < deadline )); do
101
+ if [[ -f "$received_file" ]] && [[ "$(cat "$received_file")" == "$message" ]]; then
102
+ return 0
103
+ fi
104
+ sleep 1
105
+ done
106
+ return 1
107
+ }
108
+
109
+ # wait_for_teardown returns 0 once a read of the session list leaves the session out, 1 when the
110
+ # last list it read still held the session at the deadline, and 2 when it never read the list. A
111
+ # read that fails (no answer, a non-200 such as the listener's refilling 503, a body that is not a
112
+ # list) says nothing about the session, so it is retried, never taken for its absence;
113
+ # teardown_read_error keeps the last such failure for the message.
114
+ teardown_read_error=""
115
+ wait_for_teardown() {
116
+ local deadline=$((SECONDS + teardown_timeout)) response listed=0 rc
117
+ local curl_error="${workdir}/teardown-curl.err"
118
+ while (( SECONDS < deadline )); do
119
+ if response="$(curl --connect-timeout 2 --max-time 5 -fsS "${envoy_url}/v1/sessions?dir=${workdir}" 2>"$curl_error")"; then
120
+ rc=0
121
+ jq -e --arg id "$target_session" 'any(.[]; .session_id == $id)' <<<"$response" >/dev/null 2>&1 || rc=$?
122
+ case "$rc" in
123
+ 0) listed=1 ;;
124
+ 1) return 0 ;;
125
+ *) teardown_read_error="GET /v1/sessions answered a body that is not a session list: ${response:0:200}" ;;
126
+ esac
127
+ else
128
+ teardown_read_error="$(tr '\n' ' ' <"$curl_error")"
129
+ fi
130
+ sleep 1
131
+ done
132
+ (( listed )) && return 1
133
+ return 2
134
+ }
135
+
136
+ # The channel server gets a known direct Envoy route through the QA override.
137
+ # The smoke starts an interactive Claude Code session: channel notifications are
138
+ # meant to wake an already-open session, while -p has no interactive consent or
139
+ # plan-approval surfaces.
140
+ tmux_session="${target_session}"
141
+ session_row=""
142
+ claude_command=("$claude_bin")
143
+ [[ -z "$plugin_dir" ]] || claude_command+=(--plugin-dir "$plugin_dir")
144
+ [[ -z "$mcp_config" ]] || claude_command+=(--mcp-config "$mcp_config")
145
+ tmux new-session -d -s "$tmux_session" -x 220 -y 50 -c "$workdir" \
146
+ env ENVOY_NATS_URL="$nats_url" ENVOY_URL="$envoy_url" ENVOY_SESSION_ID="$target_session" \
147
+ CLAUDE_PROJECT_DIR="$workdir" CLAUDE_PLUGIN_DATA="${workdir}/plugin-data" \
148
+ "${claude_command[@]}" "$channel_flag" "$channel_entry"
149
+ tmux set-option -t "$tmux_session" remain-on-exit on
150
+ # Everything Claude prints, dialogs included, lands in a file the failure path dumps.
151
+ tmux pipe-pane -t "$tmux_session" -o "cat >> '${claude_output}'"
152
+ answer_startup_dialogs || fail "Claude did not reach its prompt within ${startup_timeout}s"
153
+
154
+ printf 'starting interactive Claude channel smoke %s with %s %s; watch it with: tmux -S %s attach -t %s\n' \
155
+ "$target_session" "$channel_flag" "$channel_entry" "$tmux_socket" "$tmux_session"
156
+ wait_for_session || fail "channel session did not register with Envoy within ${session_timeout}s"
157
+ printf 'registered Envoy channel session %s\n' "$session_row"
158
+
159
+ response_file="$(mktemp)"
160
+ trap 'rm -f "$response_file"; cleanup' EXIT INT TERM
161
+ status="$(curl --connect-timeout 2 --max-time 5 -sS -o "$response_file" -w '%{http_code}' \
162
+ -X POST -H 'Content-Type: application/json' "${envoy_url}/v1/messages/send" \
163
+ --data "$(jq -nc --arg target "$target_session" --arg body "$delivery_instruction" '{source:"human", target_session:$target, message:$body, idempotency_key:("channel-smoke-" + $target)}')" 2>/dev/null || true)"
164
+ [[ "$status" == "200" ]] || fail "Envoy direct send returned ${status}: $(cat "$response_file")"
165
+ printf 'sent direct Envoy event; waiting up to %ss for Claude to consume the channel notification\n' "$delivery_timeout"
166
+ wait_for_delivery || fail "Claude did not write the exact channel payload within ${delivery_timeout}s"
167
+
168
+ printf 'delivery observed; terminating Claude session and checking deregistration\n'
169
+ tmux kill-session -t "$tmux_session" || fail "tmux kill-session failed"
170
+ tmux_session=""
171
+ teardown_rc=0
172
+ wait_for_teardown || teardown_rc=$?
173
+ case "$teardown_rc" in
174
+ 0) ;;
175
+ 1) fail "Envoy session remained registered after ${teardown_timeout}s" ;;
176
+ *) fail "could not read Envoy's session list within ${teardown_timeout}s of the teardown, so deregistration is unproven: ${teardown_read_error:-no read finished}" ;;
177
+ esac
178
+ printf 'PASS: Envoy event arrived through Claude Code channel notifications and the session deregistered — %s\n' "$session_row"
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env bash
2
+ # smoke-clear-rebind.sh — real Claude Code `/clear` handoff smoke: after `/clear`, the
3
+ # registry entry of the new session id must list its own direct subject and never the old one.
4
+ # See README.md "Manual smoke" for prerequisites; like smoke-channel.sh, never a CI step.
5
+ set -euo pipefail
6
+
7
+ readonly envoy_url="${ENVOY_URL:-http://127.0.0.1:9020}"
8
+ readonly nats_url="${ENVOY_NATS_URL:?set ENVOY_NATS_URL to the live Envoy NATS listener}"
9
+ readonly claude_bin="${CLAUDE_BIN:-claude}"
10
+ readonly heartbeat_ms="${ENVOY_HEARTBEAT_MS:-5000}"
11
+ readonly startup_timeout=60
12
+ readonly registration_timeout=45
13
+ readonly handoff_timeout=60
14
+
15
+ command -v "$claude_bin" >/dev/null 2>&1 || { echo "FAIL: $claude_bin is not on PATH" >&2; exit 1; }
16
+ for tool in tmux curl jq; do
17
+ command -v "$tool" >/dev/null 2>&1 || { echo "FAIL: $tool is required" >&2; exit 1; }
18
+ done
19
+
20
+ workdir="$(mktemp -d /tmp/claude-clear-rebind.XXXXXX)"
21
+ readonly workdir
22
+ # The smoke runs its own tmux server on a socket in its scratch directory, so its cleanup can end
23
+ # only the session it started: `tmux kill-session` on the shared default server could end anyone's.
24
+ readonly tmux_socket="${workdir}/tmux.sock"
25
+ tmux() { command tmux -S "$tmux_socket" "$@"; }
26
+ readonly claude_output="${workdir}/claude-output.txt"
27
+ tmux_session="claude-clear-rebind-${workdir##*.}"
28
+ session_ids=()
29
+ claude_pid=""
30
+ server_pid=""
31
+
32
+ fail() {
33
+ printf 'FAIL: %s\n' "$*" >&2
34
+ [[ -n "$tmux_session" ]] && tmux capture-pane -pt "$tmux_session" >&2 || true
35
+ if [[ -f "$claude_output" ]]; then
36
+ printf -- '--- claude pane output (%s) ---\n' "$claude_output" >&2
37
+ cat "$claude_output" >&2 || true
38
+ fi
39
+ exit 1
40
+ }
41
+
42
+ cleanup() {
43
+ [[ -n "$tmux_session" ]] && tmux kill-session -t "$tmux_session" 2>/dev/null || true
44
+ # The channel server deregisters as it exits, and can outlive Claude; a registration
45
+ # still in flight then must land before the entries are removed below.
46
+ local deadline=$((SECONDS + 10)) pid
47
+ for pid in "$claude_pid" "$server_pid"; do
48
+ while [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do
49
+ sleep 0.2
50
+ done
51
+ done
52
+ # A deregistered session keeps its interest entry until the listener reaps it; an
53
+ # unsubscribe with no topics removes the entry now.
54
+ for id in "${session_ids[@]}"; do
55
+ curl --connect-timeout 2 --max-time 5 -fsS -o /dev/null -X POST -H 'Content-Type: application/json' \
56
+ "${envoy_url}/v1/interests/unsubscribe" --data "{\"session_id\":\"${id}\",\"topics\":[]}" || true
57
+ done
58
+ # Claude Code keeps a transcript directory per project path, named after it.
59
+ rm -rf "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/${workdir//[^A-Za-z0-9]/-}" "$workdir"
60
+ }
61
+ trap cleanup EXIT INT TERM
62
+
63
+ # The plugin under test: CLAUDE_PLUGIN_DIR when set, else a copy built from this tree into the
64
+ # scratch directory (`scripts/build.ts --out`), since a pull request's tree carries no fresh
65
+ # `dist/` or `legion/`.
66
+ if [[ -n "${CLAUDE_PLUGIN_DIR:-}" ]]; then
67
+ plugin_dir="$(cd "$CLAUDE_PLUGIN_DIR" && pwd)"
68
+ else
69
+ plugin_dir="${workdir}/claude-envoy"
70
+ bun "$(dirname "${BASH_SOURCE[0]}")/build.ts" --out "$plugin_dir" >/dev/null ||
71
+ fail "the plugin did not build into ${plugin_dir}"
72
+ fi
73
+ readonly plugin_dir
74
+
75
+ # The registry rows this Claude session owns: it registers with its project directory.
76
+ entries() {
77
+ local rows
78
+ rows="$(curl --connect-timeout 2 --max-time 5 -fsS "${envoy_url}/v1/sessions")" \
79
+ || fail "the Envoy listener at ${envoy_url} did not answer /v1/sessions"
80
+ jq -c --arg dir "$workdir" '[.[] | select(.dir == $dir) | {session_id, topics}]' <<<"$rows"
81
+ }
82
+
83
+ # Polls the entries until the jq filter prints something within `timeout` seconds; prints it.
84
+ await_entries() {
85
+ local deadline=$((SECONDS + $1)) out
86
+ shift
87
+ while (( SECONDS < deadline )); do
88
+ out="$(entries | jq -cr "$@")"
89
+ [[ -n "$out" ]] && { printf '%s\n' "$out"; return 0; }
90
+ sleep 1
91
+ done
92
+ return 1
93
+ }
94
+
95
+ answer_startup_dialogs() {
96
+ local deadline=$((SECONDS + startup_timeout)) pane
97
+ while (( SECONDS < deadline )); do
98
+ pane="$(tmux capture-pane -pt "$tmux_session" 2>/dev/null || true)"
99
+ if [[ "$pane" == *"Yes, I trust this folder"* ]]; then
100
+ tmux send-keys -t "$tmux_session" Down Enter
101
+ elif [[ "$pane" == *"New MCP server found"* ]]; then
102
+ tmux send-keys -t "$tmux_session" Enter
103
+ elif [[ "$pane" == *"shift+tab to cycle"* || "$pane" == *"? for shortcuts"* ]]; then
104
+ return 0
105
+ fi
106
+ sleep 1
107
+ done
108
+ return 1
109
+ }
110
+
111
+ # User settings stay out (`--setting-sources project,local`) so an installed copy of the
112
+ # plugin does not start a second channel server in this session; `--plugin-dir` loads the
113
+ # build under test with its SessionStart hook, which writes the handoff file `/clear` needs.
114
+ printf 'starting Claude /clear rebind smoke %s; watch it with: tmux -S %s attach -t %s\n' \
115
+ "$tmux_session" "$tmux_socket" "$tmux_session"
116
+ tmux new-session -d -s "$tmux_session" -x 220 -y 50 -c "$workdir" \
117
+ env ENVOY_NATS_URL="$nats_url" ENVOY_URL="$envoy_url" ENVOY_HEARTBEAT_MS="$heartbeat_ms" \
118
+ CLAUDE_PROJECT_DIR="$workdir" \
119
+ "$claude_bin" --setting-sources project,local --plugin-dir "$plugin_dir"
120
+ tmux set-option -t "$tmux_session" remain-on-exit on
121
+ claude_pid="$(tmux display-message -p -t "$tmux_session" '#{pane_pid}')"
122
+ tmux pipe-pane -t "$tmux_session" -o "cat >> '${claude_output}'"
123
+ answer_startup_dialogs || fail "Claude did not reach its prompt within ${startup_timeout}s"
124
+
125
+ before="$(await_entries "$registration_timeout" 'if length == 1 then .[0].session_id else empty end')" \
126
+ || fail "no single registry entry for ${workdir} within ${registration_timeout}s: $(entries)"
127
+ session_ids+=("$before")
128
+ server_pid="$(pgrep -P "$claude_pid" -f 'dist/envoy-channel.js' | head -1)" || true
129
+ [[ -n "$server_pid" ]] || fail "no channel server process (dist/envoy-channel.js) under Claude pid ${claude_pid}"
130
+ printf 'registered %s\n' "$before"
131
+
132
+ tmux send-keys -t "$tmux_session" "/clear" Enter
133
+ cleared_at=$SECONDS
134
+ # shellcheck disable=SC2016 # $old is a jq variable
135
+ after_id="$(await_entries "$handoff_timeout" --arg old "$before" \
136
+ 'if length == 1 and .[0].session_id != $old then .[0].session_id else empty end')" \
137
+ || fail "the entry did not move off ${before} within ${handoff_timeout}s: $(entries)"
138
+ session_ids+=("$after_id")
139
+ moved=$((SECONDS - cleared_at))
140
+ printf 'the entry moved %ss after /clear (heartbeat every %sms)\n' "$moved" "$heartbeat_ms"
141
+
142
+ # Checked at once and again two heartbeats later: a later registration must not bring it back.
143
+ check_entry() {
144
+ local entry
145
+ entry="$(entries | jq -c --arg id "$after_id" '.[] | select(.session_id == $id)')"
146
+ printf '%s: %s\n' "$1" "$entry"
147
+ jq -e --arg own "notifications.agent.${after_id}" '(.topics // []) | index($own)' <<<"$entry" >/dev/null \
148
+ || fail "the new entry does not list its own direct subject notifications.agent.${after_id}"
149
+ if jq -e --arg old "notifications.agent.${before}" '(.topics // []) | index($old)' <<<"$entry" >/dev/null; then
150
+ fail "the new entry still lists the pre-/clear direct subject notifications.agent.${before}"
151
+ fi
152
+ }
153
+ check_entry "after /clear"
154
+ # The server polls the handoff file; waiting for the heartbeat instead shows up here once the
155
+ # heartbeat is long (ENVOY_HEARTBEAT_MS=60000 makes this a real check of the poll).
156
+ move_limit=$((heartbeat_ms / 1000 - 1))
157
+ (( move_limit > 10 )) && move_limit=10
158
+ (( move_limit < 2 )) && move_limit=2
159
+ (( moved <= move_limit )) \
160
+ || fail "the entry moved ${moved}s after /clear; the handoff poll should move it within ${move_limit}s"
161
+ sleep $((2 * heartbeat_ms / 1000 + 1))
162
+ check_entry "two heartbeats later"
163
+ printf 'PASS: /clear moved the entry %s -> %s without the old direct subject (plugin %s)\n' \
164
+ "$before" "$after_id" "$plugin_dir"
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env bash
2
+ # smoke-session-title.sh — the live proof that the name a person gave a Claude Code session is
3
+ # what this plugin registers with a real Envoy listener, that a rename replaces it, and that a
4
+ # session nobody named keeps the folder-and-short-id fallback (dispatch://INFRA-92).
5
+ #
6
+ # It runs the bundles, not the TypeScript: a real nats-server, a real Envoy listener built from
7
+ # this tree, and a copy of the plugin built from this tree into the scratch directory
8
+ # (`scripts/build.ts --out`), whose `dist/session-hook.js` runs for each payload and
9
+ # `dist/envoy-channel.js` as the session, read back through the listener's own
10
+ # `GET /v1/sessions`. Its negative control is the same first case against the base revision's
11
+ # committed `dist/envoy-channel.js`, which registers the folder name and not the chosen one.
12
+ #
13
+ # WHAT IS SIMULATED. This script does not start Claude Code (a pod can run it from bunx, as
14
+ # scripts/e2e/envoy-session-delivery.sh does), so the two
15
+ # hook payloads are JSON this script writes, shaped as Claude Code's hooks reference documents
16
+ # them (https://code.claude.com/docs/en/hooks: a SessionStart hook receives `session_title`, "the
17
+ # session's custom title, present when one is set, for example with `--name`, `/rename` …", and
18
+ # "UserPromptSubmit hooks also receive `session_title` …"). The run proves that the real hook
19
+ # bundle records what Claude Code would hand it and that the real channel server registers it
20
+ # with a real listener. It does not prove that Claude Code sends the field; that rests on Claude
21
+ # Code's own documentation.
22
+ #
23
+ # Like smoke-channel.sh and smoke-clear-rebind.sh, this is a manual smoke and never a CI step.
24
+ #
25
+ # bash packages/claude-envoy/scripts/smoke-session-title.sh
26
+ #
27
+ # BASE_REV names the revision the negative control's bundle comes from: by default the parent of
28
+ # 0306bcbe16df (#2154), the last revision without the fallback this smoke checks, since main has
29
+ # carried it from then on.
30
+ set -euo pipefail
31
+
32
+ readonly base_rev="${BASE_REV:-0306bcbe16df-}"
33
+ readonly heartbeat_ms="${ENVOY_HEARTBEAT_MS:-1000}"
34
+ readonly chosen_name="eeny meeny"
35
+ readonly renamed="miny moe"
36
+ readonly register_timeout=30
37
+
38
+ package_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
39
+ readonly package_dir
40
+ repo_root="$(cd "${package_dir}/../.." && pwd)"
41
+ readonly repo_root
42
+
43
+ fail() {
44
+ printf 'FAIL: %s\n' "$*" >&2
45
+ [[ -s "${workdir:-}/server.log" ]] && { printf -- '--- channel server log ---\n'; cat "${workdir}/server.log"; } >&2
46
+ exit 1
47
+ }
48
+
49
+ # nats-server is a `go install` away and lands in GOPATH's bin, which is often off PATH.
50
+ if ! command -v nats-server >/dev/null 2>&1; then
51
+ PATH="$(go env GOPATH 2>/dev/null)/bin:${PATH}"
52
+ export PATH
53
+ fi
54
+ for tool in bun curl jq go nats-server; do
55
+ command -v "$tool" >/dev/null 2>&1 ||
56
+ fail "$tool is required (nats-server: go install github.com/nats-io/nats-server/v2@v2.11.4)"
57
+ done
58
+
59
+ workdir="$(mktemp -d /tmp/claude-session-title.XXXXXX)"
60
+ readonly workdir
61
+ nats_pid=""
62
+ listener_pid=""
63
+ server_pid=""
64
+ fifo_pid=""
65
+
66
+ cleanup() {
67
+ [[ -n "$server_pid" ]] && kill "$server_pid" 2>/dev/null
68
+ [[ -n "$fifo_pid" ]] && kill "$fifo_pid" 2>/dev/null
69
+ [[ -n "$listener_pid" ]] && kill "$listener_pid" 2>/dev/null
70
+ [[ -n "$nats_pid" ]] && kill "$nats_pid" 2>/dev/null
71
+ wait 2>/dev/null || true
72
+ rm -rf "$workdir"
73
+ return 0
74
+ }
75
+ trap cleanup EXIT INT TERM
76
+
77
+ # The plugin under test, built from this tree: a pull request's tree carries no fresh `dist/`.
78
+ readonly plugin_dir="${workdir}/claude-envoy"
79
+ bun "${package_dir}/scripts/build.ts" --out "$plugin_dir" >"${workdir}/build.log" 2>&1 ||
80
+ fail "the plugin did not build: $(cat "${workdir}/build.log")"
81
+
82
+ # Both servers bind port 0, so the kernel chooses each port and nothing can take it before the
83
+ # server serves on it, and each reports the port it got: nats-server (`-p -1`) on its `Listening for
84
+ # client connections on` line, once it logs `Server is ready`, and the listener (PORT=0) as the
85
+ # `addr` of its `envoy-listener listening` JSON line.
86
+ nats-server -js -a 127.0.0.1 -p -1 -sd "${workdir}/nats" >"${workdir}/nats.log" 2>&1 &
87
+ nats_pid=$!
88
+ for _ in $(seq 60); do
89
+ grep -q 'Server is ready' "${workdir}/nats.log" && break
90
+ kill -0 "$nats_pid" 2>/dev/null || fail "nats-server exited at startup: $(cat "${workdir}/nats.log")"
91
+ sleep 0.5
92
+ done
93
+ grep -q 'Server is ready' "${workdir}/nats.log" || fail "nats-server was not ready within 30s: $(cat "${workdir}/nats.log")"
94
+ nats_port="$(awk -F: '/Listening for client connections on/ {print $NF; exit}' "${workdir}/nats.log")"
95
+ readonly nats_port
96
+ [[ -n "$nats_port" ]] || fail "nats-server logged no client port: $(cat "${workdir}/nats.log")"
97
+
98
+ (cd "${repo_root}/packages/envoy" && go build -o "${workdir}/envoy-listener" ./cmd/listener) ||
99
+ fail "the Envoy listener did not build"
100
+ env PORT=0 ENVOY_LISTEN_HOST=127.0.0.1 ENVOY_MACHINE_ID=session-title-smoke \
101
+ NATS_URLS="nats://127.0.0.1:${nats_port}" \
102
+ "${workdir}/envoy-listener" >"${workdir}/listener.log" 2>&1 &
103
+ listener_pid=$!
104
+ listener_addr=""
105
+ for _ in $(seq 60); do
106
+ listener_addr="$(jq -Rr 'fromjson? | select(.msg == "envoy-listener listening") | .addr' "${workdir}/listener.log")"
107
+ [[ -n "$listener_addr" ]] && break
108
+ kill -0 "$listener_pid" 2>/dev/null || fail "the listener exited at startup: $(cat "${workdir}/listener.log")"
109
+ sleep 0.5
110
+ done
111
+ [[ -n "$listener_addr" ]] || fail "the listener reported no address within 30s: $(cat "${workdir}/listener.log")"
112
+ readonly envoy_url="http://${listener_addr}"
113
+ printf 'nats-server on %s and an Envoy listener built from this tree on %s\n' "$nats_port" "$listener_addr"
114
+ for _ in $(seq 60); do
115
+ [[ "$(curl -fsS --max-time 2 "${envoy_url}/healthz" 2>/dev/null)" == *healthy* ]] && break
116
+ sleep 0.5
117
+ done
118
+ [[ "$(curl -fsS --max-time 2 "${envoy_url}/healthz" 2>/dev/null)" == *healthy* ]] ||
119
+ fail "the listener was not healthy within 30s: $(cat "${workdir}/listener.log")"
120
+
121
+ # The session's own scratch world: the envoy.json Claude Code's rule picks
122
+ # (`$CLAUDE_CONFIG_DIR/envoy.json`), the plugin state directory the hook records titles in, the
123
+ # `dispatch` command's state directory the registration stamps, and a project directory whose
124
+ # name is the fallback title's first half.
125
+ readonly config_dir="${workdir}/claude-config"
126
+ readonly plugin_data="${workdir}/plugin-data"
127
+ readonly dispatch_state="${workdir}/dispatch-state"
128
+ readonly project_dir="${workdir}/probe-project"
129
+ mkdir -p "$config_dir" "$plugin_data" "$dispatch_state" "$project_dir"
130
+ # Dispatch is enabled against port 1, where nothing serves: the session stamps its title for the
131
+ # `dispatch` command without this smoke needing a Dispatch server.
132
+ jq -n --arg envoy "$envoy_url" --arg nats "nats://127.0.0.1:${nats_port}" \
133
+ --arg dispatch "http://127.0.0.1:1" \
134
+ '{dispatch: {enabled: true, serverUrl: $dispatch, token: "session-title-smoke"},
135
+ envoy: {enabled: true, url: $envoy, natsUrls: [$nats]}}' >"${config_dir}/envoy.json"
136
+
137
+ # The channel server runs as Claude Code runs it: no ENVOY_SESSION_ID, because that QA override
138
+ # also turns the `dispatch` command's title stamp off, and this smoke asserts the stamp.
139
+ run_channel_server() {
140
+ local bundle="$1" sid="$2"
141
+ mkfifo "${workdir}/stdin"
142
+ sleep 600 >"${workdir}/stdin" &
143
+ fifo_pid=$!
144
+ env -i PATH="$PATH" HOME="$workdir" \
145
+ CLAUDE_CONFIG_DIR="$config_dir" CLAUDE_PLUGIN_DATA="$plugin_data" \
146
+ CLAUDE_PROJECT_DIR="$project_dir" CLAUDE_CODE_SESSION_ID="$sid" \
147
+ DISPATCH_STATE_DIR="$dispatch_state" ENVOY_HEARTBEAT_MS="$heartbeat_ms" \
148
+ bun "$bundle" <"${workdir}/stdin" >"${workdir}/server.log" 2>&1 &
149
+ server_pid=$!
150
+ }
151
+
152
+ stop_channel_server() {
153
+ [[ -n "$server_pid" ]] && kill "$server_pid" 2>/dev/null
154
+ [[ -n "$fifo_pid" ]] && kill "$fifo_pid" 2>/dev/null
155
+ # A killed job's status is its signal, which `set -e` would otherwise take for a failure.
156
+ wait "$server_pid" "$fifo_pid" 2>/dev/null || true
157
+ server_pid=""
158
+ fifo_pid=""
159
+ rm -f "${workdir}/stdin"
160
+ return 0
161
+ }
162
+
163
+ # The title the listener holds for the session `$1`, or empty when it holds no such session.
164
+ registered_title() {
165
+ curl -fsS --max-time 5 "${envoy_url}/v1/sessions" 2>/dev/null |
166
+ jq -r --arg id "$1" '[.. | objects | select(.session_id? == $id)][0].title // empty'
167
+ }
168
+
169
+ # Waits up to `register_timeout` for the listener's title for the session `$1` to equal `$2`.
170
+ await_title() {
171
+ local sid="$1" expected="$2" seen=""
172
+ for _ in $(seq $((register_timeout * 2))); do
173
+ seen="$(registered_title "$sid")"
174
+ [[ "$seen" == "$expected" ]] && return 0
175
+ sleep 0.5
176
+ done
177
+ printf 'the listener holds %q for %s, not %q\n' "$seen" "$sid" "$expected" >&2
178
+ return 1
179
+ }
180
+
181
+ # One hook run, with the environment Claude Code gives a hook and `payload` on its stdin. The
182
+ # title hook is a bundle of its own and takes no mode argument: Claude Code blocks the user's
183
+ # prompt on it, so it carries none of the SessionStart bundle's weight.
184
+ run_hook() {
185
+ local mode="$1" payload="$2"
186
+ local -a command=("${plugin_dir}/dist/session-hook.js" "$mode")
187
+ if [[ "$mode" == session-title ]]; then
188
+ command=("${plugin_dir}/dist/session-title-hook.js")
189
+ fi
190
+ printf '%s' "$payload" | env -i PATH="$PATH" HOME="$workdir" \
191
+ CLAUDE_CONFIG_DIR="$config_dir" CLAUDE_PLUGIN_DATA="$plugin_data" \
192
+ CLAUDE_PROJECT_DIR="$project_dir" \
193
+ bun "${command[@]}" >"${workdir}/hook-${mode}.out" 2>&1 ||
194
+ fail "the ${mode} hook exited non-zero: $(cat "${workdir}/hook-${mode}.out")"
195
+ }
196
+
197
+ readonly session_id="smoke-title-0001"
198
+ readonly short_id="${session_id:0:8}"
199
+ readonly fallback="probe-project ${short_id}"
200
+
201
+ printf '\ncase 1: a SessionStart payload carrying the name a person gave the session\n'
202
+ run_hook open-asks "$(jq -nc --arg id "$session_id" --arg dir "$project_dir" --arg title "$chosen_name" \
203
+ '{session_id: $id, cwd: $dir, hook_event_name: "SessionStart", source: "startup", session_title: $title}')"
204
+ run_channel_server "${plugin_dir}/dist/envoy-channel.js" "$session_id"
205
+ await_title "$session_id" "$chosen_name" || fail "the chosen name did not reach the listener"
206
+ printf 'the listener holds %q for %s\n' "$chosen_name" "$session_id"
207
+
208
+ printf '\ncase 2: the session is renamed, and the next registration carries the new name\n'
209
+ run_hook session-title "$(jq -nc --arg id "$session_id" --arg title "$renamed" \
210
+ '{session_id: $id, hook_event_name: "UserPromptSubmit", session_title: $title}')"
211
+ await_title "$session_id" "$renamed" || fail "the rename did not reach the listener"
212
+ printf 'the listener holds %q for %s\n' "$renamed" "$session_id"
213
+
214
+ printf '\ncase 3: the name is taken away, and the folder-and-short-id fallback returns\n'
215
+ run_hook session-title "$(jq -nc --arg id "$session_id" \
216
+ '{session_id: $id, hook_event_name: "UserPromptSubmit"}')"
217
+ await_title "$session_id" "$fallback" || fail "the fallback did not return"
218
+ printf 'the listener holds %q for %s\n' "$fallback" "$session_id"
219
+
220
+ printf '\nthe dispatch command carries the same name on what this session writes\n'
221
+ run_hook session-title "$(jq -nc --arg id "$session_id" --arg title "$renamed" \
222
+ '{session_id: $id, hook_event_name: "UserPromptSubmit", session_title: $title}')"
223
+ await_title "$session_id" "$renamed" || fail "the rename did not reach the listener a second time"
224
+ readonly stamp="${dispatch_state}/sessions/${session_id}/title"
225
+ [[ -f "$stamp" ]] || fail "no title was stamped for the dispatch command at ${stamp}"
226
+ [[ "$(cat "$stamp")" == "$renamed" ]] ||
227
+ fail "the dispatch command's title is $(cat "$stamp"), not ${renamed}"
228
+ printf '%s holds %q\n' "$stamp" "$renamed"
229
+ stop_channel_server
230
+
231
+ printf '\nnegative control: the same recorded name against %s dist/envoy-channel.js\n' "$base_rev"
232
+ readonly control_id="smoke-title-0002"
233
+ readonly control_fallback="probe-project ${control_id:0:8}"
234
+ run_hook open-asks "$(jq -nc --arg id "$control_id" --arg dir "$project_dir" --arg title "$chosen_name" \
235
+ '{session_id: $id, cwd: $dir, hook_event_name: "SessionStart", source: "startup", session_title: $title}')"
236
+ jj -R "$repo_root" file show -r "$base_rev" packages/claude-envoy/dist/envoy-channel.js \
237
+ >"${workdir}/base-envoy-channel.js" ||
238
+ fail "could not read packages/claude-envoy/dist/envoy-channel.js at ${base_rev}"
239
+ run_channel_server "${workdir}/base-envoy-channel.js" "$control_id"
240
+ await_title "$control_id" "$control_fallback" ||
241
+ fail "the base bundle did not register the fallback it is supposed to"
242
+ printf 'the base bundle registered %q and not %q\n' "$control_fallback" "$chosen_name"
243
+ stop_channel_server
244
+
245
+ printf '\nPASS: the chosen name registers, a rename replaces it, an unnamed session keeps the fallback, the dispatch command carries the same name, and the base bundle does none of it\n'