@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.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -0
- package/payload/platform/plugins/admin/hooks/__tests__/hook-emit.test.sh +75 -0
- package/payload/platform/plugins/admin/hooks/__tests__/post-tool-use-agent.test.sh +77 -0
- package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-admin-tool-gate.test.sh +64 -38
- package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-base64-guard.test.sh +4 -4
- package/payload/platform/plugins/admin/hooks/lib/hook-emit.sh +84 -0
- package/payload/platform/plugins/admin/hooks/post-tool-use-agent.sh +84 -0
- package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +110 -68
- package/payload/platform/plugins/admin/skills/investigate/SKILL.md +15 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +23 -8
- package/payload/platform/plugins/admin/skills/task/SKILL.md +31 -0
- package/payload/platform/plugins/docs/references/platform.md +22 -7
- package/payload/platform/plugins/prompt-optimiser/skills/prompt-optimiser/SKILL.md +1 -0
- package/payload/platform/scripts/setup-account.sh +6 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +11 -27
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +44 -4
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +59 -9
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +135 -22
- 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)
|
|
31
|
-
#
|
|
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
|
-
|
|
66
|
+
PARENT_TOOL_USE_ID=$(echo "$INPUT" | python3 -c '
|
|
39
67
|
import sys, json
|
|
40
68
|
try:
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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:
|
|
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
|
|
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 (
|
|
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=<…>`
|
|
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=
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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=<…>`
|
|
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=
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
1541
|
-
//
|
|
1542
|
-
//
|
|
1543
|
-
//
|
|
1544
|
-
//
|
|
1545
|
-
//
|
|
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)}`);
|