@rubytech/create-maxy-code 0.1.210 → 0.1.214

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 (24) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  3. package/payload/platform/plugins/admin/hooks/__tests__/hook-emit.test.sh +75 -0
  4. package/payload/platform/plugins/admin/hooks/__tests__/post-tool-use-agent.test.sh +77 -0
  5. package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-admin-tool-gate.test.sh +64 -38
  6. package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-base64-guard.test.sh +4 -4
  7. package/payload/platform/plugins/admin/hooks/lib/hook-emit.sh +84 -0
  8. package/payload/platform/plugins/admin/hooks/post-tool-use-agent.sh +84 -0
  9. package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +110 -68
  10. package/payload/platform/plugins/admin/skills/investigate/SKILL.md +15 -0
  11. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +23 -8
  12. package/payload/platform/plugins/admin/skills/task/SKILL.md +31 -0
  13. package/payload/platform/plugins/docs/references/platform.md +22 -7
  14. package/payload/platform/plugins/prompt-optimiser/skills/prompt-optimiser/SKILL.md +1 -0
  15. package/payload/platform/scripts/setup-account.sh +6 -0
  16. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  17. package/payload/platform/services/claude-session-manager/dist/http-server.js +11 -27
  18. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  19. package/payload/platform/services/claude-session-manager/dist/index.js +44 -4
  20. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  21. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +59 -9
  22. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +135 -22
  24. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
@@ -8,72 +8,93 @@ set -uo pipefail
8
8
 
9
9
  AGENT_TYPE="${1:-public}"
10
10
 
11
+ # Source the propagation emitter (Task 560). The library is fail-open;
12
+ # any error path inside it logs to server.log and returns 0, so sourcing
13
+ # never blocks the hook's primary allow/block contract.
14
+ HOOKS_LIB_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)/lib"
15
+ # shellcheck disable=SC1091
16
+ [ -f "$HOOKS_LIB_DIR/hook-emit.sh" ] && source "$HOOKS_LIB_DIR/hook-emit.sh"
17
+
11
18
  # Read stdin — fail closed if unavailable
12
19
  if [ -t 0 ]; then
20
+ # No INPUT yet; propagate with agentId=unknown so the regression is visible.
21
+ if declare -F hook_emit_decision >/dev/null 2>&1; then
22
+ hook_emit_decision "unknown" "admin-tool-gate" "?" "block" \
23
+ "stdin-missing" "Blocked: Cannot determine tool call (no stdin). Failing closed." 2 0
24
+ fi
13
25
  echo "Blocked: Cannot determine tool call (no stdin). Failing closed." >&2
14
26
  exit 2
15
27
  fi
16
28
  INPUT=$(cat)
17
29
  TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4)
18
30
 
31
+ # Session id for propagation. Empty / parse-failure routes to "unknown" —
32
+ # the propagator emits the record under agentId=unknown rather than dropping.
33
+ SESSION_ID=$(printf '%s' "$INPUT" | python3 -c '
34
+ import sys, json
35
+ try:
36
+ print(json.load(sys.stdin).get("session_id", "") or "unknown")
37
+ except Exception:
38
+ print("unknown")
39
+ ' 2>/dev/null || echo "unknown")
40
+
19
41
  # ---------------------------------------------------------------------------
20
42
  # Admin agent — code directory protection + approval gating
21
43
  # ---------------------------------------------------------------------------
22
44
  if [ "$AGENT_TYPE" = "admin" ]; then
23
45
 
24
- # ── Orchestration-only authoring + research block (Task 529) ─────────────
46
+ # ── Orchestration-only authoring + research block (Task 529, 559) ────────
25
47
  # The admin seat is orchestration + clarification + delivery only.
26
48
  # Write/Edit/MultiEdit/Bash/WebFetch/WebSearch are specialist-owned —
27
49
  # content-producer for authoring, research-assistant for research, the
28
50
  # shell tools for whichever specialist owns the workflow that needs them.
29
51
  # In-window Task subagents must keep these tools (Task 222 regression
30
- # class) — the discriminator is the transcript_path: Claude Code stores
31
- # subagent turns in separate `*/subagents/agent-*.jsonl` files.
52
+ # class).
53
+ #
54
+ # Discriminator (Task 559): Claude Code passes `parent_tool_use_id` in
55
+ # the hook input JSON when the call originates from a subagent (the id
56
+ # of the parent's Task tool_use). Admin-direct calls have it absent or
57
+ # empty. Earlier versions used the `transcript_path` glob, but Claude
58
+ # Code 2.1.x passes the parent session's transcript_path to subagent
59
+ # hooks, so the glob never matched and every specialist subagent Bash
60
+ # was blocked with the admin orchestration-only wording.
32
61
  #
33
- # Admin direct → exit 2 with dispatch hint.
34
- # Subagent → exit 0 (passthrough).
35
- # Missing/malformed transcript_path → fail-closed block (matches Task 222).
62
+ # Admin direct (no parent_tool_use_id) → exit 2 with dispatch hint.
63
+ # Subagent (parent_tool_use_id non-empty) → exit 0 (passthrough).
36
64
  case "$TOOL_NAME" in
37
65
  Write|Edit|MultiEdit|Bash|WebFetch|WebSearch)
38
- TRANSCRIPT_PATH=$(echo "$INPUT" | python3 -c '
66
+ PARENT_TOOL_USE_ID=$(echo "$INPUT" | python3 -c '
39
67
  import sys, json
40
68
  try:
41
- print(json.load(sys.stdin).get("transcript_path", "") or "")
69
+ v = json.load(sys.stdin).get("parent_tool_use_id", "")
70
+ print(v if isinstance(v, str) else "")
42
71
  except Exception:
43
72
  print("")
44
73
  ' 2>/dev/null)
45
- case "$TRANSCRIPT_PATH" in
46
- */subagents/agent-*.jsonl)
47
- # In-window subagent — passthrough. Tool name resolves on the
48
- # subagent's own frontmatter `tools:` allowlist downstream.
49
- echo "[admin-tool-gate] role=subagent tool=${TOOL_NAME} decision=allow source=hook-passthrough" >&2
50
- ;;
51
- "")
52
- # Missing transcript_path on a denied native tool — fail closed
53
- # rather than guess (Task 222 fail-closed contract).
54
- echo "[admin-tool-gate] role=unknown tool=${TOOL_NAME} decision=block reason=missing-transcript-path" >&2
55
- echo "Blocked: ${TOOL_NAME} is unavailable from the admin seat and the caller cannot be classified (no transcript_path). Failing closed." >&2
56
- exit 2
57
- ;;
58
- *)
59
- # Admin direct invocation — refuse with dispatch hint.
60
- case "$TOOL_NAME" in
61
- Write|Edit|MultiEdit)
62
- OWNER="content-producer"
63
- ;;
64
- Bash)
65
- OWNER="the specialist that owns the workflow needing shell access"
66
- ;;
67
- WebFetch|WebSearch)
68
- OWNER="research-assistant"
69
- ;;
70
- esac
71
- echo "[admin-tool-gate] role=admin tool=${TOOL_NAME} decision=block reason=orchestration-only owner=${OWNER}" >&2
72
- echo "Blocked: \`${TOOL_NAME}\` is not callable from the admin seat. The admin seat is orchestration + clarification + delivery only — authoring, shell work, and web research are owned by specialists." >&2
73
- echo "Dispatch the work to ${OWNER} via the Agent tool." >&2
74
- exit 2
75
- ;;
76
- esac
74
+ SPECIALIST="${MAXY_SPECIALIST:-}"
75
+ if [ -n "$PARENT_TOOL_USE_ID" ]; then
76
+ # In-window subagent — passthrough. Tool name resolves on the
77
+ # subagent's own frontmatter `tools:` allowlist downstream.
78
+ echo "[admin-tool-gate] role=subagent parent_tool_use_id=${PARENT_TOOL_USE_ID} specialist=${SPECIALIST:--} tool=${TOOL_NAME} decision=allow source=hook-passthrough" >&2
79
+ else
80
+ # Admin direct invocation — refuse with dispatch hint.
81
+ case "$TOOL_NAME" in
82
+ Write|Edit|MultiEdit)
83
+ OWNER="content-producer"
84
+ ;;
85
+ Bash)
86
+ OWNER="the specialist that owns the workflow needing shell access"
87
+ ;;
88
+ WebFetch|WebSearch)
89
+ OWNER="research-assistant"
90
+ ;;
91
+ esac
92
+ echo "[admin-tool-gate] role=admin parent_tool_use_id=- specialist=${SPECIALIST:--} tool=${TOOL_NAME} decision=block reason=orchestration-only owner=${OWNER}" >&2
93
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
94
+ "orchestration-only" \
95
+ "Blocked: \`${TOOL_NAME}\` is not callable from the admin seat. The admin seat is orchestration + clarification + delivery only — authoring, shell work, and web research are owned by specialists.
96
+ Dispatch the work to ${OWNER} via the Agent tool."
97
+ fi
77
98
  ;;
78
99
  esac
79
100
 
@@ -84,13 +105,15 @@ except Exception:
84
105
  case "$FILE_PATH" in
85
106
  # Platform UI source (platform/ui/) — source lives here, not on device
86
107
  */platform/ui/app/*|*/platform/ui/lib/*|*/platform/ui/*.ts|*/platform/ui/*.tsx|*/platform/ui/*.mjs)
87
- echo "Blocked: Admin agent cannot modify platform UI source at $FILE_PATH" >&2
88
- exit 2
108
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
109
+ "code-dir-protection-ui" \
110
+ "Blocked: Admin agent cannot modify platform UI source at $FILE_PATH"
89
111
  ;;
90
112
  # Compiled MCP servers, hooks, and scripts — what actually ships to device
91
113
  */platform/plugins/*/mcp/dist/*|*/platform/plugins/*/hooks/*|*/platform/scripts/*)
92
- echo "Blocked: Admin agent cannot modify platform code at $FILE_PATH" >&2
93
- exit 2
114
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
115
+ "code-dir-protection-platform" \
116
+ "Blocked: Admin agent cannot modify platform code at $FILE_PATH"
94
117
  ;;
95
118
  # Entitlement library — verifier source, compiled output, vendored
96
119
  # pubkey, hash file, and any signed entitlement.json. Tampering here is the
@@ -99,9 +122,10 @@ except Exception:
99
122
  # Patterns intentionally cover both absolute (*/...) and relative
100
123
  # (no leading slash) paths so an agent can't bypass via cwd-relative writes.
101
124
  */platform/lib/entitlement/*|platform/lib/entitlement/*|*/entitlement.json|entitlement.json)
102
- echo "Blocked: Admin agent cannot modify entitlement files at $FILE_PATH. Effective tier and purchasedPlugins derive from a Rubytech-signed payload." >&2
103
125
  echo "[entitlement] tool-deny: tool=${TOOL_NAME} path=${FILE_PATH} field=entitlement-file" >&2
104
- exit 2
126
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
127
+ "entitlement-file" \
128
+ "Blocked: Admin agent cannot modify entitlement files at $FILE_PATH. Effective tier and purchasedPlugins derive from a Rubytech-signed payload."
105
129
  ;;
106
130
  # account.json — agent must use the account-update
107
131
  # MCP tool (or plugin-toggle-enabled for enabledPlugins changes), which
@@ -112,14 +136,16 @@ except Exception:
112
136
  # incident showed a tier upgrade via raw Edit on account.json reach
113
137
  # the disk, exactly the failure mode this hook exists to prevent.
114
138
  */data/accounts/*/account.json|*/config/accounts/*/account.json|data/accounts/*/account.json|config/accounts/*/account.json|*/account.json|*account.json|account.json)
115
- echo "Blocked: Admin agent cannot edit account.json directly. Use the account-update or plugin-toggle-enabled MCP tools — they whitelist editable fields server-side and exclude tier and purchasedPlugins by design." >&2
116
139
  echo "[entitlement] tool-deny: tool=${TOOL_NAME} path=${FILE_PATH} field=account-json" >&2
117
- exit 2
140
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
141
+ "account-json" \
142
+ "Blocked: Admin agent cannot edit account.json directly. Use the account-update or plugin-toggle-enabled MCP tools — they whitelist editable fields server-side and exclude tier and purchasedPlugins by design."
118
143
  ;;
119
144
  # Pending action queue — only the hook and MCP tools may write here
120
145
  */pending-actions/*)
121
- echo "Blocked: Admin agent cannot modify pending action files directly" >&2
122
- exit 2
146
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
147
+ "pending-actions" \
148
+ "Blocked: Admin agent cannot modify pending action files directly"
123
149
  ;;
124
150
  esac
125
151
  ;;
@@ -127,24 +153,28 @@ except Exception:
127
153
  # Block shell commands referencing protected code directories or pending actions
128
154
  case "$INPUT" in
129
155
  *"platform/ui/app/"*|*"platform/ui/lib/"*|*"platform/plugins/"*"hooks/"*|*"platform/scripts/"*)
130
- echo "Blocked: Admin agent cannot run shell commands against code directories" >&2
131
- exit 2
156
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "Bash" \
157
+ "code-dir-protection-bash" \
158
+ "Blocked: Admin agent cannot run shell commands against code directories"
132
159
  ;;
133
160
  # Entitlement files via shell — same surface, blocked symmetrically
134
161
  *"platform/lib/entitlement/"*|*"entitlement.json"*)
135
- echo "Blocked: Admin agent cannot reference entitlement files via shell." >&2
136
162
  echo "[entitlement] tool-deny: tool=Bash path=entitlement-file field=entitlement-file" >&2
137
- exit 2
163
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "Bash" \
164
+ "entitlement-shell" \
165
+ "Blocked: Admin agent cannot reference entitlement files via shell."
138
166
  ;;
139
167
  # account.json via shell — same rationale as Edit/Write block
140
168
  *"data/accounts/"*"account.json"*|*"config/accounts/"*"account.json"*)
141
- echo "Blocked: Admin agent cannot edit account.json via shell. Use the account-update MCP tool." >&2
142
169
  echo "[entitlement] tool-deny: tool=Bash path=account-json field=account-json" >&2
143
- exit 2
170
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "Bash" \
171
+ "account-json-shell" \
172
+ "Blocked: Admin agent cannot edit account.json via shell. Use the account-update MCP tool."
144
173
  ;;
145
174
  *"pending-actions/"*)
146
- echo "Blocked: Admin agent cannot modify pending action files via shell" >&2
147
- exit 2
175
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "Bash" \
176
+ "pending-actions-shell" \
177
+ "Blocked: Admin agent cannot modify pending action files via shell"
148
178
  ;;
149
179
  esac
150
180
  ;;
@@ -229,17 +259,19 @@ except Exception:
229
259
  REJECT:base64-write-content:*)
230
260
  BYTES="${GUARD_VERDICT##*:}"
231
261
  echo "[pre-tool-use] guard=base64-write-content bytes=${BYTES} action=reject" >&2
232
- echo "Blocked: ${TOOL_NAME} content carries an inline base64 payload (>4 KB encoded). Inline binary in Write.content overloads the model context — the same path produced a main_stream_stalled at ~33 KB on 2026-05-09." >&2
233
- echo "Save the bytes to \$ACCOUNT_DIR/tmp/<sha1>.<ext> via Bash (e.g. 'base64 -d > out.png'), then reference the file from the document: <img src=\"./<file>\"> or Read-by-path. Do not carry binary bytes through the assistant turn." >&2
234
- exit 2
262
+ hook_block_with_emit "$SESSION_ID" "base64-guard" "${TOOL_NAME}" \
263
+ "base64-write-content" \
264
+ "Blocked: ${TOOL_NAME} content carries an inline base64 payload (>4 KB encoded). Inline binary in Write.content overloads the model context — the same path produced a main_stream_stalled at ~33 KB on 2026-05-09.
265
+ Save the bytes to \$ACCOUNT_DIR/tmp/<sha1>.<ext> via Bash (e.g. 'base64 -d > out.png'), then reference the file from the document: <img src=\"./<file>\"> or Read-by-path. Do not carry binary bytes through the assistant turn."
235
266
  ;;
236
267
  REJECT:base64-encoder:*|REJECT:xxd-plain-hex:*)
237
268
  REASON="${GUARD_VERDICT#REJECT:}"; REASON="${REASON%:*}"
238
269
  BYTES="${GUARD_VERDICT##*:}"
239
270
  echo "[pre-tool-use] guard=base64-tool-result bytes=${BYTES} tool=Bash reason=${REASON} action=reject" >&2
240
- echo "Blocked: Bash command would emit binary as inline base64/hex to stdout, which lands in the assistant turn and overloads the model context (the 2026-05-09 Rubytech-invoice path hit 66% context after a single ~33 KB tool_result)." >&2
241
- echo "Instead: save the bytes directly to \$ACCOUNT_DIR/tmp/<sha1>.<ext> and operate on the file via path — Read for inspection, <img src=\"./<file>\"> for HTML embedding, the \`file-presentation\` skill for delivery. Decoding base64 (e.g. 'base64 -d in.b64 > out.bin') is allowed." >&2
242
- exit 2
271
+ hook_block_with_emit "$SESSION_ID" "base64-guard" "Bash" \
272
+ "$REASON" \
273
+ "Blocked: Bash command would emit binary as inline base64/hex to stdout, which lands in the assistant turn and overloads the model context (the 2026-05-09 Rubytech-invoice path hit 66% context after a single ~33 KB tool_result).
274
+ Instead: save the bytes directly to \$ACCOUNT_DIR/tmp/<sha1>.<ext> and operate on the file via path — Read for inspection, <img src=\"./<file>\"> for HTML embedding, the \`file-presentation\` skill for delivery. Decoding base64 (e.g. 'base64 -d in.b64 > out.bin') is allowed."
243
275
  ;;
244
276
  *)
245
277
  : # ALLOW — fall through to approval gating below
@@ -313,8 +345,9 @@ except Exception:
313
345
  # Write atomically: temp file + mv
314
346
  TEMP=$(mktemp "${PENDING_DIR}/.tmp.XXXXXX" 2>/dev/null)
315
347
  if [ -z "$TEMP" ]; then
316
- echo "Blocked: Action requires approval but failed to create queue file (disk error). Failing closed." >&2
317
- exit 2
348
+ hook_block_with_emit "$SESSION_ID" "approval-gate" "${TOOL_NAME}" \
349
+ "approval-queue-mktemp" \
350
+ "Blocked: Action requires approval but failed to create queue file (disk error). Failing closed."
318
351
  fi
319
352
 
320
353
  # The pending action file contains the full hook JSON (tool_name + tool_input)
@@ -332,10 +365,19 @@ ENDJSON
332
365
 
333
366
  if ! mv "$TEMP" "${PENDING_DIR}/${ACTION_ID}.json" 2>/dev/null; then
334
367
  rm -f "$TEMP" 2>/dev/null
335
- echo "Blocked: Action requires approval but failed to queue (filesystem error). Failing closed." >&2
336
- exit 2
368
+ hook_block_with_emit "$SESSION_ID" "approval-gate" "${TOOL_NAME}" \
369
+ "approval-queue-mv" \
370
+ "Blocked: Action requires approval but failed to queue (filesystem error). Failing closed."
337
371
  fi
338
372
 
373
+ # Record the queued-for-approval block in the propagation buffer; the
374
+ # readable summary still goes to stdout (CC attaches it as the agent's
375
+ # tool_result). hook_emit_decision is the no-exit variant — the
376
+ # subsequent echoes + exit 2 below remain the operator-facing surface.
377
+ hook_emit_decision "$SESSION_ID" "approval-gate" "${TOOL_NAME}" \
378
+ "block" "approval-queued" \
379
+ "Action ${ACTION_ID} queued for review" 2 0
380
+
339
381
  # Extract a readable summary of the action for the agent to present
340
382
  # tool_input is the second JSON value in the hook payload
341
383
  echo "Action requires approval before execution."
@@ -135,6 +135,21 @@ If no specific artifact was provided, proceed directly to Phase 1.
135
135
  task for the observability fix. Resume after instrumentation is
136
136
  deployed and the issue is reproduced with proper evidence.
137
137
 
138
+ **Test for the no-event blind spot explicitly.** Transition-only
139
+ logging (a line per action) is structurally blind to any failure that
140
+ emits *no event and does not reproduce on demand* — a leak, an
141
+ orphan, a stuck or idle resource, a silently-failed cleanup, slow
142
+ drift. There is no action to hang a log on, and re-running the code
143
+ will not surface it. For **every failure mode you can name**, ask the
144
+ forcing question: *"what signal reveals it, without reproducing the
145
+ failure and without waiting for unrelated future activity?"* If the
146
+ answer is "none until something else happens," the missing
147
+ instrumentation is a **standing check — an independent periodic audit
148
+ that reconciles expected state against actual** — not another action
149
+ log, and that gap is itself the finding. Verify post-conditions,
150
+ never log intentions: "called the cleanup" is not evidence the
151
+ resource was freed.
152
+
138
153
  Output: **"Root cause hypothesis: ..."** — a specific, testable claim
139
154
  supported by cited evidence, plus the narrowest module the fix would
140
155
  touch (informational; investigation does not edit source).
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any claim about what Maxy is, how the platform is built, how plugins/skills/specialists work, how install/deploy runs, or any other product-architecture fact. The body of this skill is the only permissible source for such claims. Cite the `Source:` URL inline.
4
- content-hash: sha256:7a63e89172ec61a001e45436e346b7e89e5bae8b72436c74ea6279321e114a55
4
+ content-hash: sha256:f26ae06dff1c5b82b674893120da0454280946709391d6f9f79051f48e6f40e0
5
5
  ---
6
6
 
7
7
  # Platform architecture (reference)
@@ -209,9 +209,9 @@ Each session row also carries a small muted timestamp crumb under the name showi
209
209
  **Two spawn methods coexist (Task 557).** The manager runs two separate on-device spawn surfaces:
210
210
 
211
211
  - **`claude rc` daemon** — spawned at platform boot by `rc-daemon.ts`, **`systemdSpawn`** path. Owns the long-lived composer session that backs claude.ai/code Remote Control. The script(1) TTY wrap (Task 556) inside the scope keeps the daemon resident even though the manager holds no fd. The activation gate below covers this path.
212
- - **`claude --remote-control` on-device sidebar spawn** — spawned per-click by `/rc-spawn` in `http-server.ts`, **`spawnPty` (node-pty) + `systemd-run --scope`** path. Node-pty inside the scope owns the real `xterm-256color` TTY (120×40); the manager process holds the master fd at spawn time and releases it at `op=session-ready` once claude's pid file lands. The `trap "" HUP` wrap inside the scope keeps claude resident after the release, so the manager can detach without killing the session. Task 552's `systemdSpawn`-with-`stdio:'ignore'` swap broke this surface for five occasions because `--scope` units inherit the launcher's fds, so claude got `/dev/null` on all three streams and exited at the no-input branch in ~50 ms; Task 557 restored node-pty here and left the daemon path unchanged.
212
+ - **`claude --remote-control` on-device sidebar spawn** — spawned per-click by `/rc-spawn` in `http-server.ts`, **`spawnPty` (node-pty) + `systemd-run --scope`** path. Node-pty inside the scope owns the real `xterm-256color` TTY (120×40); the manager process holds the master fd **for the session's entire lifetime**. The pty master IS the live session — claude operates on the slave, and closing the master hangs up the slave (Task 557 evidence: claude exited ~0.8 s after `op=fd-release trigger=session-ready`). Task 558 deletes that release point. The only valid master-release points are now (1) the explicit operator teardown `/stop` `stopSession` → `op=archive-release` and (2) the natural-exit path inside `pty.onExit handlePtyNaturalExit`. Task 552's `systemdSpawn`-with-`stdio:'ignore'` swap broke this surface for five occasions because `--scope` units inherit the launcher's fds, so claude got `/dev/null` on all three streams and exited at the no-input branch in ~50 ms; Task 557 restored node-pty here and left the daemon path unchanged.
213
213
 
214
- **`/rc-spawn` lifecycle observability (Task 557).** Every on-device sidebar resume emits a stream of `[rc-spawn]` lines tagged with the same `unitToken=rc-resume-<uuid>` so one spawn's full lifeline can be reconstructed by `grep` alone. The lines, in order:
214
+ **`/rc-spawn` lifecycle observability (Tasks 557 + 558).** Every on-device sidebar resume emits a stream of `[rc-spawn]` lines tagged with the same `unitToken=rc-resume-<uuid>` so one spawn's full lifeline can be reconstructed by `grep` alone. The lines, in order:
215
215
 
216
216
  | Step | Line shape |
217
217
  |------|-----------|
@@ -220,13 +220,28 @@ Each session row also carries a small muted timestamp crumb under the name showi
220
220
  | 3 | `[rc-spawn] op=pty-spawned unitToken=<t> pid=<pid> openFds=<n>` (fd baseline) |
221
221
  | 4 | `[rc-spawn] op=child-output unitToken=<t> pid=<pid> head=<json>` (first ≤1 KB or 500 ms idle — claude's own words: `Remote Control connecting…` on success, `No deferred tool marker` / `Unknown assignment` on failure) |
222
222
  | 5 | `[rc-spawn] op=alive unitToken=<t> pid=<pid> ranMs=<n>` and `op=early-exit` mirror — `early-exit` fires when `pty.onExit` lands before the pid file (the no-TTY death signature) |
223
- | 6 | `[rc-spawn] op=session-ready unitToken=<t> pid=<pid> pidFile=<path> bridgeId=<…>` (pid file written by claude the candidate safe fd-release point) |
223
+ | 6 | `[rc-spawn] op=session-ready unitToken=<t> pid=<pid> pidFile=<path> bridgeId=<…>` **terminal for the spawn path.** No `op=fd-release trigger=session-ready` follows. The tracker remains in `livePtys` for the session's lifetime. |
224
224
  | 7 | `[pty-tracker] op=spawn sessionId=<8> pid=<pid> size=<n>` (also fires for spawnClaudeSession; same line shape on the rc-spawn path) |
225
- | 8 | `[rc-spawn] op=fd-release unitToken=<t> pid=<pid> trigger=session-ready master-fd=closed openFds=<n>` then `[rc-spawn] op=survives-fd-release unitToken=<t> pid=<pid> alive=<bool>` (kill -0 the empirical evidence for whether `trap "" HUP` holds) |
226
- | 9 | `[fd-census] trigger=rc-spawn openFds=<n> livePtys=<n>` (the leak time-series — `openFds` rising while `livePtys` stays flat across successive spawns is the leak signature; `openFds` returning to baseline after each release is no leak) |
227
- | 10 | `[rc-spawn] op=exit unitToken=<t> pid=<pid> ranMs=<n>` paired with `[pty-tracker] op=exit` from `handlePtyNaturalExit` (fires only if the child exits before fd-release — once the master fd is closed `pty.onExit` no longer fires, so `op=detach` from `op=fd-release` is the terminal manager-side line for surviving children) |
225
+ | 8 | `[rc-spawn] op=exit unitToken=<t> pid=<pid> ranMs=<n>` paired with `[pty-tracker] op=exit` from `handlePtyNaturalExit` fires when claude exits on its own (operator typed `/quit`, SIGINT in the PTY, crash). |
228
226
 
229
- `openFdCount()` reads `/proc/self/fd` directly on Linux and returns `-1` on darwin (the dev-Mac path). The fd-leak audit on the laptop is one line: `~/maxy-code/platform/scripts/logs-read.sh --tail server 200 | grep -E '\[rc-spawn\]|\[fd-census\]'`, then filter by `unitToken` to isolate a single spawn or by `trigger=rc-spawn` to read the cross-spawn fd time-series.
227
+ **Operator-archive release (Task 558).** When the operator clicks End in the UI, `/stop` `stopSession` `archiveReleaseTracker` emits a single verified release line:
228
+
229
+ `[rc-spawn] op=archive-release sessionId=<8> pid=<pid> master-fd=<closed|close-failed err=…> fdBefore=<n> fdAfter=<n> fdDelta=<n> removedFds=<list|none> trackerRemoved=<bool> verified=<bool>`
230
+
231
+ `verified=true` requires `master-fd=closed` AND `fdDelta>=1` AND `trackerRemoved=true`. `master-fd=close-failed` is logged at error level (`[rc-spawn-error]` prefix) — never swallowed; the next post-archive sweep is the catch-net.
232
+
233
+ **Post-archive fd sweep (Task 558).** Independent of spawn/archive request traffic, the manager runs a 60 s sweep that walks both directions of the master-fd invariant:
234
+
235
+ - `[fd-audit] op=orphan-master sessionId=<8> pid=<n> archivedAt=<ms> heldSinceArchiveMs=<n> fd=<n|unknown>` — fires per tracker whose row is archived (the leak).
236
+ - `[fd-audit] op=orphan-master-escalate sessionId=<8> fd=<n|unknown> heldSinceArchiveMs=<n>` — fires when `heldSinceArchiveMs ≥ 300 000` ms (5 min); strongest leak signal.
237
+ - `[fd-audit] op=post-archive-sweep archivedSessions=<n> orphanMasters=<n> openFds=<n> livePtys=<n>` — once per sweep.
238
+ - `[fd-audit] op=master-reconcile liveTrackers=<n> liveSessions=<n> archivedWithMaster=<n> orphanLiveSessionsNoMaster=<n>` — once per sweep. `archivedWithMaster>0` = fd leak; `orphanLiveSessionsNoMaster>0` = inverse defect (a live session whose master is gone — it cannot operate). Both are alarms.
239
+
240
+ The sweep is the catch-net for `master-fd=close-failed` and any future regression that orphans a tracker after archive. The steady-state `archivedWithMaster=0 orphanLiveSessionsNoMaster=0` is itself the signal the sweep ran.
241
+
242
+ **Manager-shutdown master-audit (Task 558).** On SIGTERM/SIGINT the manager emits `[manager-shutdown] op=master-audit held=<n> liveSessionsClosed=<n>` after walking `livePtys`. `held` is the count of trackers at shutdown entry; `liveSessionsClosed` is the subset whose master was destroyed by this shutdown. This is the data the out-of-scope "does manager restart kill on-device live sessions?" question is decided by — a logged number, not speculation.
243
+
244
+ `openFdCount()` reads `/proc/self/fd` directly on Linux and returns `-1` on darwin (the dev-Mac path). The fd-leak audit on the laptop: `~/maxy-code/platform/scripts/logs-read.sh --tail server 400 | grep -E '\[fd-audit\]|op=archive-release'`. Full per-spawn lifeline: `grep -E '\[rc-spawn\]|\[pty-tracker\]'` filtered by `unitToken`.
230
245
 
231
246
  **Non-PTY scope spawn — activation gate (Tasks 552 + 554 + 555 + 556).** `claude rc` daemon spawns (and historically `/rc-spawn`, until Task 557 restored node-pty for that path) go through `systemdSpawn` (a `child_process.spawn` around `systemd-run --user --scope ...`, no PTY). The scope's inner command is `script -q -f -c "<claude cmd>" "<captureFilePath>"` (Task 556): `script(1)` allocates a real pty master and slave so claude runs on a TTY (without one, `claude --remote-control --resume` and `claude rc --spawn` early-exit because `--scope` units inherit the launcher's `stdio: 'ignore'` fds), and writes its typescript to a per-spawn capture file at `~/.<brand>/logs/spawn-<unitToken>.log`. The capture file holds the child's stdout/stderr and survives scope reaping, so a fast-exiting child's last words are recoverable by reading the file. (The prior Task 555 attempt — `StandardOutput=journal` + `SyslogIdentifier=` scope properties — was a no-op because those are `Exec*=` *service-unit* fields that systemd silently ignores on `--scope` units.) The activation gate polls `systemctl show -p ActiveState -p ControlGroup -p Result -p SubState` every 50 ms until either the scope reaches `ActiveState=active` with a pid in `cgroup.procs` (success — a handle is returned) or it reaches a terminal state. Four terminal outcomes are distinguished:
232
247
 
@@ -162,6 +162,37 @@ How the change will be monitored once deployed. This section is
162
162
  mandatory — code that cannot be observed in production is incomplete
163
163
  code.
164
164
 
165
+ **Derive it first, adversarially — do not write this section last.**
166
+ Observability designed after the fix inherits the fix's blind spots;
167
+ designed first, it forces the fix to expose what would otherwise stay
168
+ hidden. Before writing prose, name **every way the change can fail**,
169
+ and treat the failures that emit *no event and do not reproduce on
170
+ demand* as first-class — they are the ones routinely missed. For
171
+ **each** failure mode, answer one forcing question and write the answer
172
+ in:
173
+
174
+ > **What signal reveals this failure — and does it fire (a) without
175
+ > reproducing the failure and (b) without waiting for unrelated future
176
+ > activity?**
177
+
178
+ The two clauses are the part repeatedly gotten wrong:
179
+
180
+ - **Presence ≠ adequacy.** A section that merely has "what to log /
181
+ signals / diagnostic path" passes a presence check while still being
182
+ blind. The test is not "is there a section" but "is every failure
183
+ mode answered by the forcing question."
184
+ - **Event-driven logging is structurally blind to no-event failures.**
185
+ A failure that triggers no action produces no log line, and re-running
186
+ the code will not surface it. Any such failure REQUIRES a **standing
187
+ check — an independent periodic audit that reconciles expected state
188
+ against actual** — not a log hung on some action. A task that names a
189
+ no-event failure mode but specifies no standing check to detect it is
190
+ inadequate by construction.
191
+ - **Verify outcomes, don't log intentions.** "Called the cleanup" is
192
+ not "the resource was freed." Log the *verified* post-condition (a
193
+ measured delta, the resource absent from the live set), and never
194
+ swallow the failure branch.
195
+
165
196
  The observability section addresses:
166
197
 
167
198
  - **What to log** — the specific events, state transitions, or error
@@ -101,9 +101,9 @@ Each session row also carries a small muted timestamp crumb under the name showi
101
101
  **Two spawn methods coexist (Task 557).** The manager runs two separate on-device spawn surfaces:
102
102
 
103
103
  - **`claude rc` daemon** — spawned at platform boot by `rc-daemon.ts`, **`systemdSpawn`** path. Owns the long-lived composer session that backs claude.ai/code Remote Control. The script(1) TTY wrap (Task 556) inside the scope keeps the daemon resident even though the manager holds no fd. The activation gate below covers this path.
104
- - **`claude --remote-control` on-device sidebar spawn** — spawned per-click by `/rc-spawn` in `http-server.ts`, **`spawnPty` (node-pty) + `systemd-run --scope`** path. Node-pty inside the scope owns the real `xterm-256color` TTY (120×40); the manager process holds the master fd at spawn time and releases it at `op=session-ready` once claude's pid file lands. The `trap "" HUP` wrap inside the scope keeps claude resident after the release, so the manager can detach without killing the session. Task 552's `systemdSpawn`-with-`stdio:'ignore'` swap broke this surface for five occasions because `--scope` units inherit the launcher's fds, so claude got `/dev/null` on all three streams and exited at the no-input branch in ~50 ms; Task 557 restored node-pty here and left the daemon path unchanged.
104
+ - **`claude --remote-control` on-device sidebar spawn** — spawned per-click by `/rc-spawn` in `http-server.ts`, **`spawnPty` (node-pty) + `systemd-run --scope`** path. Node-pty inside the scope owns the real `xterm-256color` TTY (120×40); the manager process holds the master fd **for the session's entire lifetime**. The pty master IS the live session — claude operates on the slave, and closing the master hangs up the slave (Task 557 evidence: claude exited ~0.8 s after `op=fd-release trigger=session-ready`). Task 558 deletes that release point. The only valid master-release points are now (1) the explicit operator teardown `/stop` `stopSession` → `op=archive-release` and (2) the natural-exit path inside `pty.onExit handlePtyNaturalExit`. Task 552's `systemdSpawn`-with-`stdio:'ignore'` swap broke this surface for five occasions because `--scope` units inherit the launcher's fds, so claude got `/dev/null` on all three streams and exited at the no-input branch in ~50 ms; Task 557 restored node-pty here and left the daemon path unchanged.
105
105
 
106
- **`/rc-spawn` lifecycle observability (Task 557).** Every on-device sidebar resume emits a stream of `[rc-spawn]` lines tagged with the same `unitToken=rc-resume-<uuid>` so one spawn's full lifeline can be reconstructed by `grep` alone. The lines, in order:
106
+ **`/rc-spawn` lifecycle observability (Tasks 557 + 558).** Every on-device sidebar resume emits a stream of `[rc-spawn]` lines tagged with the same `unitToken=rc-resume-<uuid>` so one spawn's full lifeline can be reconstructed by `grep` alone. The lines, in order:
107
107
 
108
108
  | Step | Line shape |
109
109
  |------|-----------|
@@ -112,13 +112,28 @@ Each session row also carries a small muted timestamp crumb under the name showi
112
112
  | 3 | `[rc-spawn] op=pty-spawned unitToken=<t> pid=<pid> openFds=<n>` (fd baseline) |
113
113
  | 4 | `[rc-spawn] op=child-output unitToken=<t> pid=<pid> head=<json>` (first ≤1 KB or 500 ms idle — claude's own words: `Remote Control connecting…` on success, `No deferred tool marker` / `Unknown assignment` on failure) |
114
114
  | 5 | `[rc-spawn] op=alive unitToken=<t> pid=<pid> ranMs=<n>` and `op=early-exit` mirror — `early-exit` fires when `pty.onExit` lands before the pid file (the no-TTY death signature) |
115
- | 6 | `[rc-spawn] op=session-ready unitToken=<t> pid=<pid> pidFile=<path> bridgeId=<…>` (pid file written by claude the candidate safe fd-release point) |
115
+ | 6 | `[rc-spawn] op=session-ready unitToken=<t> pid=<pid> pidFile=<path> bridgeId=<…>` **terminal for the spawn path.** No `op=fd-release trigger=session-ready` follows. The tracker remains in `livePtys` for the session's lifetime. |
116
116
  | 7 | `[pty-tracker] op=spawn sessionId=<8> pid=<pid> size=<n>` (also fires for spawnClaudeSession; same line shape on the rc-spawn path) |
117
- | 8 | `[rc-spawn] op=fd-release unitToken=<t> pid=<pid> trigger=session-ready master-fd=closed openFds=<n>` then `[rc-spawn] op=survives-fd-release unitToken=<t> pid=<pid> alive=<bool>` (kill -0 the empirical evidence for whether `trap "" HUP` holds) |
118
- | 9 | `[fd-census] trigger=rc-spawn openFds=<n> livePtys=<n>` (the leak time-series — `openFds` rising while `livePtys` stays flat across successive spawns is the leak signature; `openFds` returning to baseline after each release is no leak) |
119
- | 10 | `[rc-spawn] op=exit unitToken=<t> pid=<pid> ranMs=<n>` paired with `[pty-tracker] op=exit` from `handlePtyNaturalExit` (fires only if the child exits before fd-release — once the master fd is closed `pty.onExit` no longer fires, so `op=detach` from `op=fd-release` is the terminal manager-side line for surviving children) |
117
+ | 8 | `[rc-spawn] op=exit unitToken=<t> pid=<pid> ranMs=<n>` paired with `[pty-tracker] op=exit` from `handlePtyNaturalExit` fires when claude exits on its own (operator typed `/quit`, SIGINT in the PTY, crash). |
120
118
 
121
- `openFdCount()` reads `/proc/self/fd` directly on Linux and returns `-1` on darwin (the dev-Mac path). The fd-leak audit on the laptop is one line: `~/maxy-code/platform/scripts/logs-read.sh --tail server 200 | grep -E '\[rc-spawn\]|\[fd-census\]'`, then filter by `unitToken` to isolate a single spawn or by `trigger=rc-spawn` to read the cross-spawn fd time-series.
119
+ **Operator-archive release (Task 558).** When the operator clicks End in the UI, `/stop` `stopSession` `archiveReleaseTracker` emits a single verified release line:
120
+
121
+ `[rc-spawn] op=archive-release sessionId=<8> pid=<pid> master-fd=<closed|close-failed err=…> fdBefore=<n> fdAfter=<n> fdDelta=<n> removedFds=<list|none> trackerRemoved=<bool> verified=<bool>`
122
+
123
+ `verified=true` requires `master-fd=closed` AND `fdDelta>=1` AND `trackerRemoved=true`. `master-fd=close-failed` is logged at error level (`[rc-spawn-error]` prefix) — never swallowed; the next post-archive sweep is the catch-net.
124
+
125
+ **Post-archive fd sweep (Task 558).** Independent of spawn/archive request traffic, the manager runs a 60 s sweep that walks both directions of the master-fd invariant:
126
+
127
+ - `[fd-audit] op=orphan-master sessionId=<8> pid=<n> archivedAt=<ms> heldSinceArchiveMs=<n> fd=<n|unknown>` — fires per tracker whose row is archived (the leak).
128
+ - `[fd-audit] op=orphan-master-escalate sessionId=<8> fd=<n|unknown> heldSinceArchiveMs=<n>` — fires when `heldSinceArchiveMs ≥ 300 000` ms (5 min); strongest leak signal.
129
+ - `[fd-audit] op=post-archive-sweep archivedSessions=<n> orphanMasters=<n> openFds=<n> livePtys=<n>` — once per sweep.
130
+ - `[fd-audit] op=master-reconcile liveTrackers=<n> liveSessions=<n> archivedWithMaster=<n> orphanLiveSessionsNoMaster=<n>` — once per sweep. `archivedWithMaster>0` = fd leak; `orphanLiveSessionsNoMaster>0` = inverse defect (a live session whose master is gone — it cannot operate). Both are alarms.
131
+
132
+ The sweep is the catch-net for `master-fd=close-failed` and any future regression that orphans a tracker after archive. The steady-state `archivedWithMaster=0 orphanLiveSessionsNoMaster=0` is itself the signal the sweep ran.
133
+
134
+ **Manager-shutdown master-audit (Task 558).** On SIGTERM/SIGINT the manager emits `[manager-shutdown] op=master-audit held=<n> liveSessionsClosed=<n>` after walking `livePtys`. `held` is the count of trackers at shutdown entry; `liveSessionsClosed` is the subset whose master was destroyed by this shutdown. This is the data the out-of-scope "does manager restart kill on-device live sessions?" question is decided by — a logged number, not speculation.
135
+
136
+ `openFdCount()` reads `/proc/self/fd` directly on Linux and returns `-1` on darwin (the dev-Mac path). The fd-leak audit on the laptop: `~/maxy-code/platform/scripts/logs-read.sh --tail server 400 | grep -E '\[fd-audit\]|op=archive-release'`. Full per-spawn lifeline: `grep -E '\[rc-spawn\]|\[pty-tracker\]'` filtered by `unitToken`.
122
137
 
123
138
  **Non-PTY scope spawn — activation gate (Tasks 552 + 554 + 555 + 556).** `claude rc` daemon spawns (and historically `/rc-spawn`, until Task 557 restored node-pty for that path) go through `systemdSpawn` (a `child_process.spawn` around `systemd-run --user --scope ...`, no PTY). The scope's inner command is `script -q -f -c "<claude cmd>" "<captureFilePath>"` (Task 556): `script(1)` allocates a real pty master and slave so claude runs on a TTY (without one, `claude --remote-control --resume` and `claude rc --spawn` early-exit because `--scope` units inherit the launcher's `stdio: 'ignore'` fds), and writes its typescript to a per-spawn capture file at `~/.<brand>/logs/spawn-<unitToken>.log`. The capture file holds the child's stdout/stderr and survives scope reaping, so a fast-exiting child's last words are recoverable by reading the file. (The prior Task 555 attempt — `StandardOutput=journal` + `SyslogIdentifier=` scope properties — was a no-op because those are `Exec*=` *service-unit* fields that systemd silently ignores on `--scope` units.) The activation gate polls `systemctl show -p ActiveState -p ControlGroup -p Result -p SubState` every 50 ms until either the scope reaches `ActiveState=active` with a pid in `cgroup.procs` (success — a handle is returned) or it reaches a terminal state. Four terminal outcomes are distinguished:
124
139
 
@@ -19,6 +19,7 @@ This skill is invoked two ways, and they want opposite outputs. Decide which one
19
19
  - Write it as plain prose or a short bulleted brief. No fenced code block. No role line, no XML tags, no length-cap ceremony unless the user asked for one.
20
20
  - Do not end with "Think before answering". Do not ask the user to paste anything — you already have the content and the context.
21
21
  - Do not invent requirements or add scope the user did not ask for. If the prompt was already clear, say so in one line and restate it tightly.
22
+ - The restated brief is bound by the three doctrines (see `/doctrine`): precise/concise/plain, no speculation, observability-first. If the user's wording carries a speculative claim or skips an observability step the doctrines require, the restatement corrects it rather than echoing the slip.
22
23
  - End the restated brief with this exact sentence as its own final line: "Delegate this task to the appropriate specialist." Apply on every restatement, without exception. The owning specialist is identified from the AGENTS.md roster loaded alongside this seat — the closing line is the trigger, not the classifier (Task 529).
23
24
 
24
25
  Then do the work from the restated task. Everything below this section is for the other mode and does not apply — stop reading here.
@@ -121,6 +121,12 @@ cat > "$ACCOUNT_SETTINGS" << SETTINGS_EOF
121
121
  }
122
122
  ],
123
123
  "PostToolUse": [
124
+ {
125
+ "matcher": "Agent",
126
+ "hooks": [
127
+ { "type": "command", "command": "bash $HOOKS_PATH/post-tool-use-agent.sh" }
128
+ ]
129
+ },
124
130
  {
125
131
  "matcher": "mcp__.*__.*-(export|import)-parse$",
126
132
  "hooks": [
@@ -1 +1 @@
1
- {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAM3B,OAAO,EAiBL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAA;AAEzB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAa5D,OAAO,KAAK,EAAE,SAAS,EAAc,MAAM,iBAAiB,CAAA;AAE5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAqB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAA;AA+E9E,eAAO,MAAM,kBAAkB,QAA2B,CAAA;AAI1D,MAAM,WAAW,QAAS,SAAQ,IAAI,CAAC,SAAS,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAC7E,OAAO,EAAE,SAAS,CAAA;IAClB,WAAW,EAAE,MAAM,CAAA;IACnB,iBAAiB,EAAE,MAAM,CAAA;IACzB,kBAAkB,EAAE,WAAW,CAAA;IAC/B,eAAe,EAAE,aAAa,CAAA;IAC9B;kFAC8E;IAC9E,cAAc,EAAE,cAAc,CAAA;IAC9B;;;;2DAIuD;IACvD,YAAY,EAAE,CAAC,MAAM,EAAE;QACrB,SAAS,EAAE,MAAM,CAAA;QACjB,UAAU,EAAE,SAAS,MAAM,EAAE,CAAA;QAC7B,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;QACrC,GAAG,EAAE,MAAM,CAAA;QACX,SAAS,EAAE,MAAM,CAAA;QACjB,eAAe,EAAE,MAAM,CAAA;KACxB,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAA;CAClC;AA4LD;;;kEAGkE;AAClE,MAAM,MAAM,OAAO,GAAG,IAAI,GAAG;IAC3B,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACpD;;;+CAG2C;IAC3C,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CAChD,CAAA;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CA83CpD"}
1
+ {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAM3B,OAAO,EAgBL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAA;AAEzB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAa5D,OAAO,KAAK,EAAE,SAAS,EAAc,MAAM,iBAAiB,CAAA;AAE5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAqB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAA;AA+E9E,eAAO,MAAM,kBAAkB,QAA2B,CAAA;AAI1D,MAAM,WAAW,QAAS,SAAQ,IAAI,CAAC,SAAS,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAC7E,OAAO,EAAE,SAAS,CAAA;IAClB,WAAW,EAAE,MAAM,CAAA;IACnB,iBAAiB,EAAE,MAAM,CAAA;IACzB,kBAAkB,EAAE,WAAW,CAAA;IAC/B,eAAe,EAAE,aAAa,CAAA;IAC9B;kFAC8E;IAC9E,cAAc,EAAE,cAAc,CAAA;IAC9B;;;;2DAIuD;IACvD,YAAY,EAAE,CAAC,MAAM,EAAE;QACrB,SAAS,EAAE,MAAM,CAAA;QACjB,UAAU,EAAE,SAAS,MAAM,EAAE,CAAA;QAC7B,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;QACrC,GAAG,EAAE,MAAM,CAAA;QACX,SAAS,EAAE,MAAM,CAAA;QACjB,eAAe,EAAE,MAAM,CAAA;KACxB,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAA;CAClC;AA4LD;;;kEAGkE;AAClE,MAAM,MAAM,OAAO,GAAG,IAAI,GAAG;IAC3B,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACpD;;;+CAG2C;IAC3C,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CAChD,CAAA;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAm2CpD"}
@@ -24,7 +24,7 @@ import { stream } from 'hono/streaming';
24
24
  import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, mkdirSync, renameSync, readFileSync } from 'node:fs';
25
25
  import { randomUUID } from 'node:crypto';
26
26
  import { buildRcChildEnv } from './rc-daemon.js';
27
- import { spawnClaudeSession, stopSession, writeInputToPty, isLive, onPtyExit, livePtyCount, getPtyTrackerForTests, livePidForSession, priorExitedCountForSession, PERMISSION_MODES, resolveLiveMemory, openFdCount, registerRcSpawnPty, releaseAndUnregisterRcSpawnPty, } from './pty-spawner.js';
27
+ import { spawnClaudeSession, stopSession, writeInputToPty, isLive, onPtyExit, livePtyCount, getPtyTrackerForTests, livePidForSession, priorExitedCountForSession, PERMISSION_MODES, resolveLiveMemory, openFdCount, registerRcSpawnPty, } from './pty-spawner.js';
28
28
  import { readSidecar, updateSidecar, appendBridgeId } from './session-sidecar.js';
29
29
  import { readJsonlSession } from './jsonl-enumerator.js';
30
30
  import { claudeStateRoot, projectSlugForCwd, jsonlPathForSessionId, sidecarPathForSessionId, permissionModeLogPathForSessionId, findExistingJsonlForSessionId, } from './jsonl-path.js';
@@ -1537,12 +1537,16 @@ export function buildHttpApp(deps) {
1537
1537
  if (!outputEmitted)
1538
1538
  emitChildOutput();
1539
1539
  });
1540
- // Step 6/8/9 — session-ready (pid file landed) fd-release
1541
- // survives-fd-release fd-census. Runs async; the route returns
1542
- // immediately after spawn (the sidebar UX is fire-and-forget). The
1543
- // `waitForPid` timeout cap is the spawner's `pidFileTimeoutMs`; on
1544
- // timeout we log but do not release the fd (the tracker's onExit
1545
- // path handles cleanup if claude exits later).
1540
+ // Task 558 — session-ready is terminal for the spawn path. The PTY
1541
+ // master IS the live session, not a reclaimable spawn artifact:
1542
+ // claude operates on the slave, and closing the master hangs up the
1543
+ // slave (Task 557 evidence: claude exits ~0.8s after `op=fd-release
1544
+ // trigger=session-ready`). The master must stay open for the slave
1545
+ // to function. The only valid release points are the explicit
1546
+ // operator teardown — `/stop` → `stopSession` → `releasePtyMasterFd`
1547
+ // — and the natural-exit path inside `pty.onExit →
1548
+ // handlePtyNaturalExit`. The tracker remains in `livePtys` for the
1549
+ // session's lifetime.
1546
1550
  void deps.watcher
1547
1551
  .waitForPid(pty.pid, deps.pidFileTimeoutMs)
1548
1552
  .then((row) => {
@@ -1550,26 +1554,6 @@ export function buildHttpApp(deps) {
1550
1554
  const aliveMs = Date.now() - start;
1551
1555
  deps.logger(`[rc-spawn] op=alive unitToken=${unitToken} pid=${pty.pid} ranMs=${aliveMs}`);
1552
1556
  deps.logger(`[rc-spawn] op=session-ready unitToken=${unitToken} pid=${pty.pid} pidFile=${row.pidFilePath ?? 'unknown'} bridgeId=${row.bridgeSessionId ?? 'unknown'}`);
1553
- // Master-fd release at the session-ready point. Whether claude
1554
- // survives the release is the empirical question Task 557 was
1555
- // filed to answer; the `op=survives-fd-release` line below is
1556
- // the evidence. If `alive=false` lands across the verification
1557
- // spawns, the release point moves later (per the brief). The
1558
- // tracker is unregistered here because `pty.onExit` will not
1559
- // fire for the surviving child after destroy() closes the
1560
- // master fd.
1561
- const release = releaseAndUnregisterRcSpawnPty({ logger: deps.logger }, tracker, 'session-ready');
1562
- deps.logger(`[rc-spawn] op=fd-release unitToken=${unitToken} pid=${pty.pid} trigger=session-ready ${release.suffix} openFds=${release.openFds}`);
1563
- let alive = false;
1564
- try {
1565
- process.kill(pty.pid, 0);
1566
- alive = true;
1567
- }
1568
- catch {
1569
- alive = false;
1570
- }
1571
- deps.logger(`[rc-spawn] op=survives-fd-release unitToken=${unitToken} pid=${pty.pid} alive=${alive}`);
1572
- deps.logger(`[fd-census] trigger=rc-spawn openFds=${openFdCount()} livePtys=${livePtyCount()}`);
1573
1557
  })
1574
1558
  .catch((err) => {
1575
1559
  deps.logger(`[rc-spawn] op=wait-pid-failed unitToken=${unitToken} pid=${pty.pid} err=${err instanceof Error ? err.message : String(err)}`);