@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.
- package/.claude-plugin/plugin.json +6 -0
- package/.mcp.json +11 -0
- package/.omp-plugin/plugin.json +6 -0
- package/AGENTS.md +343 -0
- package/README.md +639 -0
- package/bin/dispatch +6 -0
- package/bin/envoy-channel.ts +9 -0
- package/bin/envoy-send.ts +33 -0
- package/dist/THIRD_PARTY_NOTICES +816 -0
- package/dist/dispatch.js +27707 -0
- package/dist/envoy-channel.js +57695 -0
- package/dist/session-hook.js +26328 -0
- package/dist/session-title-hook.js +116 -0
- package/hooks/hooks.json +60 -0
- package/hooks/session-hook.ts +512 -0
- package/hooks/session-title-hook.ts +46 -0
- package/legion/.claude-plugin/plugin.json +4 -0
- package/legion/agents/deep-worker.md +68 -0
- package/legion/agents/oracle.md +35 -0
- package/legion/agents/plan-gap-analyst.md +65 -0
- package/legion/agents/plan-reviewer.md +104 -0
- package/legion/agents/thermonuclear-code-quality.md +30 -0
- package/legion/agents/thermonuclear-deep-review.md +30 -0
- package/legion/hooks/hooks.json +89 -0
- package/legion/skills/ce-simplify-code/LICENSE +21 -0
- package/legion/skills/ce-simplify-code/SKILL.md +66 -0
- package/legion/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
- package/legion/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
- package/legion/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
- package/legion/skills/leading-a-work-stream/SKILL.md +96 -0
- package/legion/skills/leading-a-work-stream/references/daily-plan.md +8 -0
- package/legion/skills/leading-a-work-stream/references/decision-rights.md +6 -0
- package/legion/skills/leading-a-work-stream/references/direction-and-coaching.md +13 -0
- package/legion/skills/leading-a-work-stream/references/dispatch-upkeep.md +8 -0
- package/legion/skills/leading-a-work-stream/references/sources.md +9 -0
- package/legion/skills/leading-a-work-stream/references/speed-tick.md +44 -0
- package/legion/skills/leading-a-work-stream/references/user-quotes.md +7 -0
- package/legion/skills/legion-architect/SKILL.md +86 -0
- package/legion/skills/legion-architect/references/acceptance.md +208 -0
- package/legion/skills/legion-architect/references/decomposition.md +129 -0
- package/legion/skills/legion-architect/references/design-gate.md +206 -0
- package/legion/skills/legion-architect/references/merge-notices.md +91 -0
- package/legion/skills/legion-architect/references/wake-routing.md +41 -0
- package/legion/skills/legion-controller/SKILL.md +151 -0
- package/legion/skills/legion-controller/references/admission.md +22 -0
- package/legion/skills/legion-controller/references/backlog-stewardship.md +101 -0
- package/legion/skills/legion-controller/references/daily-report.md +75 -0
- package/legion/skills/legion-controller/references/escalation.md +82 -0
- package/legion/skills/legion-controller/references/finding-asks.md +181 -0
- package/legion/skills/legion-controller/references/parked-decisions.md +198 -0
- package/legion/skills/legion-controller/references/session.md +100 -0
- package/legion/skills/legion-controller/references/slack-mentions.md +154 -0
- package/legion/skills/legion-controller/references/triage.md +96 -0
- package/legion/skills/legion-controller/references/wakes.md +132 -0
- package/legion/skills/legion-oracle/SKILL.md +48 -0
- package/legion/skills/legion-retro/SKILL.md +112 -0
- package/legion/skills/legion-retro/references/learnings.md +60 -0
- package/legion/skills/legion-retro/references/pr-body-content.md +56 -0
- package/legion/skills/legion-worker/SKILL.md +69 -0
- package/legion/skills/legion-worker/references/cleanup-deletion.md +22 -0
- package/legion/skills/legion-worker/references/conflicts-and-rewrites.md +164 -0
- package/legion/skills/legion-worker/references/default-branch-check.md +53 -0
- package/legion/skills/legion-worker/references/findings.md +199 -0
- package/legion/skills/legion-worker/references/merge-gate.md +353 -0
- package/legion/skills/legion-worker/references/pr-body.md +218 -0
- package/legion/skills/legion-worker/references/review-threads.md +77 -0
- package/legion/skills/legion-worker/references/systematic-rename.md +19 -0
- package/legion/skills/legion-worker/references/tools.md +708 -0
- package/legion/skills/thermonuclear-code-quality/LICENSE +21 -0
- package/legion/skills/thermonuclear-code-quality/SKILL.md +60 -0
- package/legion/skills/thermonuclear-deep-review/LICENSE +21 -0
- package/legion/skills/thermonuclear-deep-review/SKILL.md +80 -0
- package/package.json +49 -1
- package/scripts/build-inputs.txt +84 -0
- package/scripts/build.ts +415 -0
- package/scripts/check-packed-skills.sh +50 -0
- package/scripts/pack-skills.sh +146 -0
- package/scripts/smoke-channel.sh +178 -0
- package/scripts/smoke-clear-rebind.sh +164 -0
- package/scripts/smoke-session-title.sh +245 -0
- package/scripts/verify-build.sh +88 -0
- package/skills/agent-secrets/SKILL.md +180 -0
- package/skills/asd-ste100/LICENSE +21 -0
- package/skills/asd-ste100/SKILL.md +140 -0
- package/skills/asd-ste100/examples/before-after.md +92 -0
- package/skills/asd-ste100/references/writing-rules.md +59 -0
- package/skills/dispatch/SKILL.md +89 -0
- package/skills/dispatch-first/SKILL.md +46 -0
- package/skills/dispatch-writing-specs/SKILL.md +80 -0
- package/skills/envoy/SKILL.md +82 -0
- package/skills/envoy/references/check-settlement.md +62 -0
- package/skills/envoy/references/deliveries.md +67 -0
- package/skills/envoy/references/legion.md +14 -0
- package/skills/envoy/references/roles.md +12 -0
- package/skills/envoy/references/sources.md +41 -0
- package/skills/outside-message/SKILL.md +111 -0
- package/skills/plain-words/SKILL.md +88 -0
- package/src/channel-forwarder.ts +262 -0
- package/src/claude-channel.ts +705 -0
- package/src/claude-session.ts +38 -0
- package/src/envoy-channel-server.ts +2115 -0
- package/src/envoy-client.ts +26 -0
- package/src/envoy-off.ts +62 -0
- package/src/legion-claim.ts +211 -0
- package/src/legion-inbox.ts +110 -0
- package/src/legion-phase-hook.ts +554 -0
- package/src/legion-tool.ts +69 -0
- package/src/replay-state.ts +97 -0
- package/src/send-arguments.ts +13 -0
- package/src/session-identity.ts +165 -0
- package/src/session-state.ts +142 -0
- package/src/session-title.ts +251 -0
- 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'
|