@rubytech/create-maxy-code 0.1.65 → 0.1.67

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 (34) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  3. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +317 -81
  4. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +183 -49
  5. package/payload/platform/plugins/docs/references/admin-session.md +23 -4
  6. package/payload/platform/plugins/docs/references/internals.md +2 -0
  7. package/payload/platform/plugins/docs/references/platform.md +1 -1
  8. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +3 -0
  9. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  10. package/payload/platform/services/claude-session-manager/dist/http-server.js +54 -1
  11. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  12. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.d.ts +36 -0
  13. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.d.ts.map +1 -0
  14. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.js +131 -0
  15. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.js.map +1 -0
  16. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +43 -0
  17. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  18. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +119 -5
  19. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  20. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +9 -0
  21. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  22. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  23. package/payload/platform/templates/specialists/agents/database-operator.md +35 -10
  24. package/payload/server/public/assets/{admin-ymReS1mN.js → admin-t8QqVwJF.js} +1 -1
  25. package/payload/server/public/assets/{data-D6U2e85B.js → data-vHmNQyXt.js} +1 -1
  26. package/payload/server/public/assets/{graph-C274PN27.js → graph-C7QOwigU.js} +1 -1
  27. package/payload/server/public/assets/graph-labels-Dm2RMEFy.js +1 -0
  28. package/payload/server/public/assets/{page-D-OLjort.js → page-B9GFro9g.js} +1 -1
  29. package/payload/server/public/assets/{page-DFFt9_Q0.js → page-BWKfXlHt.js} +1 -1
  30. package/payload/server/public/data.html +3 -3
  31. package/payload/server/public/graph.html +3 -3
  32. package/payload/server/public/index.html +4 -4
  33. package/payload/server/server.js +2 -0
  34. package/payload/server/public/assets/graph-labels-DQFzd1FY.js +0 -1
@@ -1,21 +1,32 @@
1
1
  #!/usr/bin/env bash
2
2
  # Stop hook — fires on every completed admin-agent turn and dispatches one
3
- # headless database-operator session against the operator's most recent turn
4
- # text. The recorder is the only writer to the Neo4j graph; the admin agent
5
- # stays focused on the operator's request.
3
+ # headless database-operator session against the operator's full
4
+ # conversation transcript. The recorder is the only writer to the Neo4j
5
+ # graph; the admin agent stays focused on the operator's request.
6
6
  #
7
7
  # Task 147 — the recorder spawn is byte-for-byte equivalent to a Sidebar
8
8
  # "New session" body, with three overrides:
9
9
  # - specialist: "database-operator"
10
10
  # - model: "haiku"
11
- # - initialMessage: "update the graph with any new or missing information
12
- # or intent\n\n<last-turn-text>"
11
+ # - initialMessage: JSON-stringified envelope (Task 177)
12
+ #
13
13
  # It POSTs to the SAME route the Sidebar uses
14
14
  # (`POST /api/admin/claude-sessions`). The wrapper accepts the loopback
15
15
  # request without a cookie, resolves the operator's `senderId` from the
16
16
  # manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn
17
17
  # body. No recorder-only carving on the manager side.
18
18
  #
19
+ # Task 177 — `initialMessage` is a JSON object stringified to a string.
20
+ # Top-level keys EXACTLY: turns, conversationId, accountId, occurredAt.
21
+ # `turns` is the chronologically-ordered conversation window — every
22
+ # user / assistant message in the operator's JSONL, oldest first, no
23
+ # windowing or truncation. Each entry:
24
+ # { role: "user"|"assistant", text: string, ts: string, toolCalls?: [...] }
25
+ # `toolCalls` (assistant-only, omitted when empty) carries
26
+ # `[{ tool, input, output }]` for tool_use/tool_result pairs in that turn.
27
+ # Replaces the Task 175 `(operatorMessage, assistantReply)` pair contract,
28
+ # which asserted a temporal pairing the walker never enforced.
29
+ #
19
30
  # Gating (emits a `trigger-skipped` line via `/api/admin/log-ingest`;
20
31
  # stderr stays silent on the success path):
21
32
  # - MAXY_SESSION_ROLE must equal "admin" → reason=role-not-admin
@@ -23,8 +34,7 @@
23
34
  # "database-operator" (recursion guard)
24
35
  # - Stop-hook stdin must be non-empty → reason=empty-stdin
25
36
  # - transcript_path must exist on disk → reason=missing-transcript
26
- # - at least one user or assistant record with → reason=conversation-empty
27
- # non-empty text must exist in the JSONL (Task 165)
37
+ # - turns array is empty after walker runs → reason=conversation-empty
28
38
  #
29
39
  # Input: Claude Code's Stop hook stdin shape
30
40
  # { "session_id": "<intrinsic>", "transcript_path": "<jsonl path>", ... }
@@ -91,62 +101,188 @@ if [ -z "$ADMIN_SESSION_ID" ] || [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIP
91
101
  exit 0
92
102
  fi
93
103
 
94
- # Task 165walk every user/assistant record in transcript order; emit
95
- # "<type>: <text>" per record; join with "\n". The recorder needs the
96
- # whole exchange to infer operator-asserted facts that span multiple
97
- # turns (e.g. "hello / what's your business? / Smalleys"). The earlier
98
- # "last-turn-only" extractor stripped the framing the recorder needed.
99
- CONVERSATION_TEXT=$(python3 - "$TRANSCRIPT_PATH" <<'PY'
104
+ # Task 177build the envelope the database-operator agent file declares
105
+ # as its stdin contract. One Python pass walks the transcript and emits:
106
+ # {
107
+ # "turns": [
108
+ # { "role": "user"|"assistant", "text": "...", "ts": "...",
109
+ # "toolCalls"?: [{ "tool": "...", "input": {...}, "output": ... }] },
110
+ # ...
111
+ # ],
112
+ # "conversationId": "<session_id>",
113
+ # "accountId": "<ACCOUNT_ID env>",
114
+ # "occurredAt": "<hook fire time, ISO-8601 UTC, .000Z>"
115
+ # }
116
+ # `python3 json.dumps` handles every escape so the hook never has to
117
+ # concatenate strings into JSON by hand.
118
+ OCCURRED_AT=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
119
+ ACCOUNT_ID_ENV="${ACCOUNT_ID:-}"
120
+
121
+ ENVELOPE=$(python3 - "$TRANSCRIPT_PATH" "$ADMIN_SESSION_ID" "$ACCOUNT_ID_ENV" "$OCCURRED_AT" <<'PY'
100
122
  import sys, json
101
123
 
102
- def block_text(block):
103
- if not isinstance(block, dict): return ""
104
- if block.get("type") != "text": return ""
105
- t = block.get("text")
106
- return t if isinstance(t, str) else ""
107
-
108
- def record_text(rec):
109
- if not isinstance(rec, dict): return ""
110
- msg = rec.get("message")
111
- if not isinstance(msg, dict): return ""
112
- content = msg.get("content")
113
- if isinstance(content, str): return content
114
- if isinstance(content, list):
115
- parts = [block_text(b) for b in content]
116
- return "\n".join(p for p in parts if p)
117
- return ""
118
-
119
- path = sys.argv[1]
120
- turns = []
124
+ def concat_text(content):
125
+ """Concatenate every `text` block in a content list (or return the
126
+ string content as-is). `thinking` blocks contribute nothing."""
127
+ if isinstance(content, str):
128
+ return content
129
+ if not isinstance(content, list):
130
+ return ""
131
+ out = []
132
+ for b in content:
133
+ if isinstance(b, dict) and b.get("type") == "text":
134
+ t = b.get("text")
135
+ if isinstance(t, str):
136
+ out.append(t)
137
+ return "".join(out)
138
+
139
+ path, conversation_id, account_id, occurred_at = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
140
+
141
+ turns = [] # ordered output: { role, text, ts, toolCalls? }
142
+ msg_id_to_turn_index = {} # collapse rule for assistant message.id
143
+ pending_tool_calls = {} # tool_use_id -> (turn_index, toolCalls_index)
144
+
121
145
  try:
122
146
  with open(path, "r", encoding="utf-8") as f:
123
147
  for line in f:
124
148
  line = line.strip()
125
- if not line: continue
126
- try: rec = json.loads(line)
127
- except Exception: continue
128
- t = rec.get("type") if isinstance(rec, dict) else None
129
- if t not in ("user", "assistant"): continue
130
- tx = record_text(rec).strip()
131
- if not tx: continue
132
- turns.append(f"{t}: {tx}")
149
+ if not line:
150
+ continue
151
+ try:
152
+ rec = json.loads(line)
153
+ except Exception:
154
+ continue
155
+ if not isinstance(rec, dict):
156
+ continue
157
+ t = rec.get("type")
158
+ msg = rec.get("message")
159
+ if not isinstance(msg, dict):
160
+ continue
161
+ content = msg.get("content")
162
+ ts = rec.get("timestamp", "") or ""
163
+
164
+ if t == "user":
165
+ # tool_result blocks attach to the assistant turn that
166
+ # owned the corresponding tool_use; they never create a
167
+ # separate user turn entry.
168
+ if isinstance(content, list):
169
+ for b in content:
170
+ if isinstance(b, dict) and b.get("type") == "tool_result":
171
+ tu_id = b.get("tool_use_id")
172
+ if not isinstance(tu_id, str):
173
+ continue
174
+ slot = pending_tool_calls.pop(tu_id, None)
175
+ if slot is None:
176
+ continue
177
+ turn_idx, call_idx = slot
178
+ turns[turn_idx]["toolCalls"][call_idx]["output"] = b.get("content")
179
+
180
+ text = concat_text(content)
181
+ if text:
182
+ turns.append({
183
+ "role": "user",
184
+ "text": text,
185
+ "ts": ts,
186
+ })
187
+ # No user turn emitted when content is empty or only
188
+ # carries tool_result blocks — those landed on the
189
+ # owning assistant turn above.
190
+
191
+ elif t == "assistant":
192
+ msg_id = msg.get("id") if isinstance(msg.get("id"), str) else None
193
+ text = concat_text(content)
194
+ tool_use_blocks = []
195
+ if isinstance(content, list):
196
+ for b in content:
197
+ if isinstance(b, dict) and b.get("type") == "tool_use":
198
+ tool_use_blocks.append(b)
199
+
200
+ # Thinking-only assistant records contribute nothing and
201
+ # do NOT create a turn entry on their own.
202
+ if not text and not tool_use_blocks:
203
+ continue
204
+
205
+ # Multi-block collapse: if a prior record with the same
206
+ # message.id created a turn, extend that one. Otherwise
207
+ # open a new assistant turn.
208
+ if msg_id is not None and msg_id in msg_id_to_turn_index:
209
+ idx = msg_id_to_turn_index[msg_id]
210
+ entry = turns[idx]
211
+ if text:
212
+ entry["text"] = (entry["text"] or "") + text
213
+ else:
214
+ entry = {
215
+ "role": "assistant",
216
+ "text": text,
217
+ "ts": ts,
218
+ }
219
+ turns.append(entry)
220
+ idx = len(turns) - 1
221
+ if msg_id is not None:
222
+ msg_id_to_turn_index[msg_id] = idx
223
+
224
+ for b in tool_use_blocks:
225
+ tc_list = entry.setdefault("toolCalls", [])
226
+ tc_list.append({
227
+ "tool": b.get("name"),
228
+ "input": b.get("input"),
229
+ "output": None,
230
+ })
231
+ tu_id = b.get("id")
232
+ if isinstance(tu_id, str):
233
+ pending_tool_calls[tu_id] = (idx, len(tc_list) - 1)
133
234
  except Exception:
134
235
  pass
135
236
 
136
- print("\n".join(turns), end="")
237
+ envelope = {
238
+ "turns": turns,
239
+ "conversationId": conversation_id,
240
+ "accountId": account_id,
241
+ "occurredAt": occurred_at,
242
+ }
243
+ print(json.dumps(envelope, ensure_ascii=False), end="")
137
244
  PY
138
245
  )
139
246
 
140
- CONVERSATION_BYTES=$(printf '%s' "$CONVERSATION_TEXT" | wc -c | tr -d ' ')
247
+ # Skip when turns is empty no user text, no assistant text, no tool_use
248
+ # in the operator JSONL. Same skip surface as the prior `conversation-empty`
249
+ # reason; the meaning is now binary on turns.length === 0.
250
+ EMPTY_CHECK=$(printf '%s' "$ENVELOPE" | python3 -c '
251
+ import sys, json
252
+ try:
253
+ e = json.load(sys.stdin)
254
+ print("empty" if not (e.get("turns") or []) else "ok")
255
+ except Exception:
256
+ print("empty")
257
+ ')
258
+
259
+ CONVERSATION_BYTES=$(printf '%s' "$ENVELOPE" | wc -c | tr -d ' ')
141
260
  TRANSCRIPT_BYTES=$(wc -c <"$TRANSCRIPT_PATH" 2>/dev/null | tr -d ' ' || echo 0)
142
261
 
143
262
  emit_log "trigger sessionId=${ADMIN_SESSION_ID} turnIndex=0 transcriptBytes=${TRANSCRIPT_BYTES} conversationBytes=${CONVERSATION_BYTES}"
144
263
 
145
- if [ -z "$CONVERSATION_TEXT" ]; then
264
+ if [ "$EMPTY_CHECK" = "empty" ]; then
146
265
  emit_log "trigger-skipped sessionId=${ADMIN_SESSION_ID} reason=conversation-empty"
147
266
  exit 0
148
267
  fi
149
268
 
269
+ # Task 177 observability — one envelope summary line per recorder fire,
270
+ # emitted BEFORE the spawn POST so a future audit reads counts from
271
+ # server.log without re-walking the JSONL. `turnsCount` is monotone
272
+ # across spawns in the same operator session — flat or decreasing is
273
+ # the regression signature for "windowing crept back in".
274
+ ENVELOPE_COUNTS=$(printf '%s' "$ENVELOPE" | python3 -c '
275
+ import sys, json
276
+ e = json.load(sys.stdin)
277
+ turns = e.get("turns") or []
278
+ u = sum(1 for t in turns if t.get("role") == "user")
279
+ a = sum(1 for t in turns if t.get("role") == "assistant")
280
+ tc = sum(1 for t in turns if (t.get("toolCalls") or []))
281
+ print(f"{len(turns)} {u} {a} {tc}")
282
+ ')
283
+ read -r TURNS_COUNT USER_TURNS ASST_TURNS TC_TURNS <<<"$ENVELOPE_COUNTS"
284
+ emit_log "envelope sessionId=${ADMIN_SESSION_ID} turnsCount=${TURNS_COUNT} userTurns=${USER_TURNS} assistantTurns=${ASST_TURNS} toolCallTurns=${TC_TURNS}"
285
+
150
286
  # Compose the Sidebar-shape spawn body. Three overrides relative to a
151
287
  # plain Sidebar "New session" click: specialist, model, initialMessage.
152
288
  # `adminSessionId` is the loopback-bypass key the wrapper uses to resolve
@@ -154,20 +290,18 @@ fi
154
290
  SPAWN_BODY=$(python3 -c '
155
291
  import sys, json
156
292
  sid = sys.argv[1]
157
- conversation = sys.argv[2]
158
- instruction = "update the graph with any new or missing information or intent"
159
- text = instruction + "\n\n" + conversation
293
+ envelope_str = sys.argv[2]
160
294
  body = {
161
295
  "adminSessionId": sid,
162
296
  "channel": "browser",
163
297
  "specialist": "database-operator",
164
298
  "model": "haiku",
165
- "initialMessage": text,
299
+ "initialMessage": envelope_str,
166
300
  }
167
301
  print(json.dumps(body))
168
- ' "$ADMIN_SESSION_ID" "$CONVERSATION_TEXT" 2>/dev/null)
302
+ ' "$ADMIN_SESSION_ID" "$ENVELOPE" 2>/dev/null)
169
303
 
170
- INITIAL_BYTES=$(printf '%s\n\n%s' "update the graph with any new or missing information or intent" "$CONVERSATION_TEXT" | wc -c | tr -d ' ')
304
+ INITIAL_BYTES=$(printf '%s' "$ENVELOPE" | wc -c | tr -d ' ')
171
305
  emit_log "spawn-request sessionId=${ADMIN_SESSION_ID} specialist=database-operator initialMessageBytes=${INITIAL_BYTES}"
172
306
 
173
307
  SPAWN_RES_FILE=$(mktemp)
@@ -92,11 +92,29 @@ After every completed operator admin turn, the Stop hook `platform/plugins/admin
92
92
  Body overrides relative to a plain Sidebar click:
93
93
  - `specialist: 'database-operator'`
94
94
  - `model: 'haiku'`
95
- - `initialMessage: "update the graph with any new or missing information or intent\n\n<conversation-text>"`
95
+ - `initialMessage: <json-envelope>` (string carrying a JSON-stringified object see below)
96
96
 
97
97
  **Spawn body.** The hook POSTs `{adminSessionId, channel: 'browser', specialist: 'database-operator', model: 'haiku', initialMessage}` to `POST /api/admin/claude-sessions`. `adminSessionId` is the loopback-bypass key — the wrapper resolves the operator's real `senderId` from `GET <managerBase>/<adminSessionId>/meta` and forwards a Sidebar-shape body to the manager.
98
98
 
99
- **`<conversation-text>`** is the operator JSONL walked in transcript order: every `user`/`assistant` record with non-empty text emitted as `"<type>: <text>"`, joined by `\n`. The full exchange is preserved so the recorder can infer operator-asserted facts that span multiple turns (e.g. "hello / what's your business? / Smalleys"). The earlier "last-turn-only" extractor stripped the framing the recorder needed. If no qualifying record exists, the hook emits `trigger-skipped reason=conversation-empty` and no recorder is spawned.
99
+ **`initialMessage` JSON envelope (Task 177).** A single JSON object stringified to a string. Top-level keys exactly match the database-operator agent file `## Input shape`:
100
+
101
+ ```
102
+ {
103
+ "turns": [
104
+ { "role": "user", "text": "...", "ts": "<ISO-8601>" },
105
+ { "role": "assistant", "text": "...", "ts": "<ISO-8601>",
106
+ "toolCalls": [ { "tool": "...", "input": {...}, "output": ... } ] },
107
+ ...
108
+ ],
109
+ "conversationId": "<operator session_id>",
110
+ "accountId": "<ACCOUNT_ID env, stamped on the manager systemd unit>",
111
+ "occurredAt": "<hook fire time, ISO-8601 UTC, .000Z>"
112
+ }
113
+ ```
114
+
115
+ `turns` is the operator's full conversation transcript, oldest first, newest last — every user / assistant message in the operator's JSONL from session start to the moment the recorder fired. No windowing, no truncation, no env knob. The walker collapses multi-record assistant messages on `message.id` (e.g. one `thinking` record + one `text` record from the same message become one `turns` entry with combined text). `toolCalls` is assistant-only and omitted on turns that called no tools. `toolCalls[].input` and `toolCalls[].output` are native JSON values, not re-stringified — `jq -r '.toolCalls[0].input.foo'` reaches the inner field directly. `tool_use` and `tool_result` blocks are paired by `tool_use_id` and attach to the assistant turn that owned the `tool_use`; the user record carrying only the `tool_result` does not create a separate user turn. Unanswered `tool_use` calls surface with `output: null`. No leading instruction prose — the agent file's system prompt teaches the model what to do with the payload. If `turns` is empty after the walker runs, the hook emits `trigger-skipped reason=conversation-empty` and no recorder is spawned. (Task 175's earlier `(operatorMessage, assistantReply)` pair contract was superseded — the pair asserted a temporal Q→A relationship the walker never enforced, so Task 177 replaced it with the ordered window.)
116
+
117
+ The hook also emits one summary line per fire immediately before `spawn-request`: `[turn-recorder] envelope sessionId=<op> turnsCount=<n> userTurns=<n> assistantTurns=<n> toolCallTurns=<n>`. `turnsCount` is monotone across spawns in the same operator session — a flat or decreasing series is the regression signature for "windowing crept back in".
100
118
 
101
119
  **Manager-side specialist branches.** The recorder is the first specialist subagent that exercises the full specialist flag matrix:
102
120
  - The bundled `platform/templates/specialists/agents/database-operator.md` template is symlinked into `$CLAUDE_CONFIG_DIR/agents/database-operator.md` by the installer; without that link `claude --agent database-operator` silently falls back to the admin agent.
@@ -109,16 +127,17 @@ Body overrides relative to a plain Sidebar click:
109
127
 
110
128
  **Hook recursion gate.** `pty-spawner` stamps `MAXY_SPECIALIST=<specialist>` on every PTY env. The Stop hook short-circuits when `MAXY_SPECIALIST=database-operator` so the recorder's own end-of-turn does not re-fire the hook.
111
129
 
112
- **Observability.** The hook emits exactly two lines per operator turn via `/api/admin/log-ingest`: `[turn-recorder] trigger sessionId=<op> turnIndex=0 transcriptBytes=<n> conversationBytes=<n>` and `[turn-recorder] spawn-request sessionId=<op> specialist=database-operator initialMessageBytes=<n>`. The manager adds `pty-spawn-allowlist specialist=database-operator count=11 stripped=0 sourced-from=agent-frontmatter` and `pty-spawn-start … specialist=database-operator append-system-prompt-bytes=0` before the PTY launches. The remainder of the recorder lifecycle is covered by the Sidebar's existing lines (`pty-spawned`, JSONL events, `auto-archive`). Failure-mode names: `trigger-skipped reason=…` enumerates `role-not-admin | is-recorder | empty-stdin | missing-transcript | conversation-empty`. Hook-side spawn errors emit `[turn-recorder] spawn-failed reason=loopback-http http=<code>`.
130
+ **Observability.** The hook emits exactly two lines per operator turn via `/api/admin/log-ingest`: `[turn-recorder] trigger sessionId=<op> turnIndex=0 transcriptBytes=<n> conversationBytes=<n>` and `[turn-recorder] spawn-request sessionId=<op> specialist=database-operator initialMessageBytes=<n>`. The manager adds `pty-spawn-allowlist specialist=database-operator count=11 stripped=0 sourced-from=agent-frontmatter`, `pty-spawn-start … specialist=database-operator append-system-prompt-bytes=0` before the PTY launches, and one `[pty-spawn-tool-inventory] sessionId=<rec> specialist=database-operator argv-tools=<n> mcp-listed-tools=<n> exposed=<csv> not-exposed=<csv>` line per spawn once the shadow probe of each MCP server returns. The remainder of the recorder lifecycle is covered by the Sidebar's existing lines (`pty-spawned`, JSONL events, `auto-archive`). Failure-mode names: `trigger-skipped reason=…` enumerates `role-not-admin | is-recorder | empty-stdin | missing-transcript | conversation-empty`. Hook-side spawn errors emit `[turn-recorder] spawn-failed reason=loopback-http http=<code>`. Probe-side failure on the inventory line emits `[pty-spawn-tool-inventory-failed] sessionId=<rec> specialist=<name> err=<json>`.
113
131
 
114
132
  ### Recorder lifecycle diagnostic
115
133
 
116
- When the operator reports "the recorder did nothing this turn", run these six greps in order. The first absent line names the phase that failed. `<op>` is the admin operator's session id from the original Stop hook; `<rec>` is the recorder session id returned in phase 2's response and reused across phases 3–6.
134
+ When the operator reports "the recorder did nothing this turn", run these seven greps in order. The first absent line names the phase that failed. `<op>` is the admin operator's session id from the original Stop hook; `<rec>` is the recorder session id returned in phase 2's response and reused across phases 3–6.
117
135
 
118
136
  1. **Stop hook fired.** `grep '\[turn-recorder\] trigger sessionId=<op>' ~/.<brand>/logs/server.log` — expects one line of shape `[turn-recorder] trigger sessionId=<op> turnIndex=0 transcriptBytes=<n> conversationBytes=<n>`. Absent: the Stop hook didn't run; check that `MAXY_SESSION_ROLE=admin` was set on the operator PTY and that `MAXY_SPECIALIST!=database-operator` (recursion gate).
119
137
  2. **/spawn accepted.** `grep '\[turn-recorder\] spawn-request sessionId=<op>' ~/.<brand>/logs/server.log` — expects `[turn-recorder] spawn-request sessionId=<op> specialist=database-operator initialMessageBytes=<n>`. The response body of this POST carries `<rec>`. Absent: the hook fired but `/api/admin/claude-sessions` rejected; look for `[turn-recorder] spawn-failed reason=loopback-http http=<code>` on the next line.
120
138
  3. **PTY started.** `grep 'pty-spawn-start .* specialist=database-operator' ~/.<brand>/logs/server.log` — expects `pty-spawn-start claudeBin=<…> argv-count=<n> append-system-prompt-bytes=0 … specialist=database-operator prompt-positional=yes prompt-bytes=<n>`. Absent: the wrapper accepted but the manager rejected before exec; check the spawn-failure surfaces (`which-claude-not-found | pty-spawn-failed | pid-file-timeout | host-context-unresolved | identity-unresolved | mcp-config-write-failed`).
121
139
  4. **Agent file resolved.** `grep 'pty-spawn-allowlist specialist=database-operator count=11 stripped=0 sourced-from=agent-frontmatter' ~/.<brand>/logs/server.log` — expects exactly one line per recorder spawn. Absent (or `count` not equal to the frontmatter's tool count, or `stripped>0` meaning the brand excluded a plugin the recorder template references — Task 173): the agent file at `$CLAUDE_CONFIG_DIR/agents/database-operator.md` is missing the `tools:` line, the symlink the installer creates never landed, or the brand-aware drift filter dropped tools. Re-run the installer; cross-reference `brand.json#plugins.excluded` against the agent frontmatter.
140
+ 4b. **Tool inventory exposed to the model.** `grep '\[pty-spawn-tool-inventory\] sessionId=<rec>' ~/.<brand>/logs/server.log` — expects one line of shape `[pty-spawn-tool-inventory] sessionId=<rec> specialist=database-operator argv-tools=11 mcp-listed-tools=<n> exposed=<csv> not-exposed=<csv>`. The line lands once per spawn after the manager's shadow probe finishes a `tools/list` against each MCP server in the per-spawn `mcp.json`. The four fields decode the recorder's runtime tool surface directly: `argv-tools` is the allowlist on the `claude` argv (= phase 4's `count`); `mcp-listed-tools` is the sum across servers of names the probe captured; `exposed` is the intersection (in frontmatter order); `not-exposed` is the allowlist minus `exposed`. The regression query for "model says no tools" is `exposed=$|exposed= ` — when the line ends `exposed= not-exposed=<all 11>`, the allowlist landed on the argv but none of the names came back from any MCP server (cause space: frontmatter format, name canonicalisation, MCP handshake repair). Absent line: probe is fire-and-forget, so absence either means the spawn was not a specialist (operator chat) or the probe itself crashed — in which case `[pty-spawn-tool-inventory-failed] sessionId=<rec> specialist=… err=…` is the partner line to grep. Note: the probe captures what each MCP server offers, not what claude code's own tools/list saw — CLI-side filtering after handshake is a separate observability concern.
122
141
  5. **Graph write outcome.** `grep '\[mcp:memory\] memory-write .* session=<rec>' ~/.<brand>/logs/server.log` — expects one line ending `result=ok elementId=<id>` or `result=error reason=<slug>` (slug enumerated by the memory MCP write-path observability work). Absent: the recorder loaded but produced no tool calls — model-side decision, not infra. Read the recorder JSONL at `<accountDir>/.claude/projects/<slug>/<rec>.jsonl` to see what the LLM did.
123
142
  6. **Auto-archive.** `grep 'auto-archive .* sessionId=<rec> .* specialist=database-operator reason=end-turn' ~/.<brand>/logs/server.log` — expects one line. Absent: the recorder finished but the manager's end-turn watcher didn't fire; the fs-watcher row will get reaped on its TTL but the recorder hung longer than expected. Investigate `pty-spawn-stop` and `pid-file-removed` lines on the same `<rec>`.
124
143
 
@@ -504,6 +504,8 @@ This gate was Task 173. The `brand-excluded` branch closes the recurring crash-r
504
504
 
505
505
  **Recorder auto-archive (lifecycle, not user-initiated).** The session manager's `attachRecorderAutoArchive` ([`platform/services/claude-session-manager/src/http-server.ts:178`](../../../services/claude-session-manager/src/http-server.ts)) wires every spawn whose `senderId === 'turn-recorder'` to a JSONL watcher: as soon as the recorder's JSONL contains `"stop_reason":"end_turn"`, the manager calls `stopSession`, the PTY exits, the PID file is removed, and `fs-watcher.ts:275-297` demotes the row to `state: 'archived'`. This is the lifecycle archive path — the row stays in place, the JSONL stays on disk, no directory move. It is structurally distinct from the user-initiated `POST /api/admin/claude-sessions/:id/archive` route, which actually `mv`s the JSONL between `<slugDir>` and `<slugDir>/archive/`; that path is the operator pruning their visible session list, not the recorder's per-turn cleanup.
506
506
 
507
+ **Resume idempotency and specialist propagation (Task 179).** `POST /resume` accepts an optional `idempotencyKey` on the body; two POSTs sharing the same `${senderId}:${idempotencyKey}` within 5 seconds return the cached payload without spawning a second PTY. The client (`platform/ui/app/lib/session-actions.ts`) generates a fresh `crypto.randomUUID()` per Resume click and forwards it through the proxy. A synchronous `useRef` guard inside `resume()` short-circuits a same-frame re-fire (React StrictMode, rapid double-click) before the network is touched, and `Sidebar.tsx` disables the Resume button while `inFlight === 'resume'`. The resume route also accepts a `specialist` field on the body, validated by the same regex `/^[A-Za-z0-9_-]{1,64}$/` that `/spawn` uses; when absent, the route falls back to `resolveRow(deps.watcher, sessionId).agent` so a `database-operator` resume always lands as a `database-operator` session rather than collapsing to an admin row. Forensic log lines: `resume-dedup senderId=… idempotencyKey=… cacheHit=yes age-ms=<n>` on a deduplicated POST, and `resume-specialist-propagated source=<sid> specialist=<name>` on a resume that carried a non-default specialist.
508
+
507
509
  ## Tool Call Audit Trail
508
510
 
509
511
  Every tool invocation by the admin agent produces a durable `ToolCall` node in the knowledge graph, linked to the `Conversation` that triggered it. This covers all admin agent tool calls — the full history of what the agent did, when, and in what context.
@@ -88,7 +88,7 @@ The sidebar row's displayed name is `tail.aiTitle` verbatim, parsed by `jsonl-en
88
88
 
89
89
  **Stop vs. delete.** `POST /<id>/stop` sends SIGTERM, leaves the JSONL on disk for audit, and is idempotent against an already-dead row. `DELETE /<id>` removes the JSONL + per-session subdir and returns 409 if the PTY is still alive (stop first). Any unknown id returns 404; nothing returns a silent 204 against an id the manager does not know.
90
90
 
91
- **stopSession fd contract (Task 170).** Before `/stop` returns, the manager explicitly releases the pty master file descriptor by calling `pty.destroy()` on node-pty's `UnixTerminal` the only path that synchronously closes the internal tty socket. Without the explicit call, the master fd waits on V8 GC finalising the IPty object, which is non-deterministic and accumulates under heavy operator-stop traffic until the kernel pty cap (Linux 3072, macOS 511) refuses new spawns. The release is verified by the `stop-session-fd-release` integration test, and every `kill reason=operator-request` log line now ends in `master-fd=closed` (or `master-fd=close-failed err=<msg>` on the rescued throw branch — a graceful degradation so a corner-case socket-state failure cannot turn a logically-successful stop into a 500).
91
+ **PTY lifecycle contract (Tasks 170 + 176).** A PTY reaches its end via one of two branches: **operator-request** (operator clicks End or the auto-archive Stop hook calls `stopSession`) or **natural-exit** (the claude child exits on its own — operator typed `/quit`, SIGINT in the PTY, crash, network drop on `--remote-control`). Both branches honour a single invariant: the pty master file descriptor is released by an explicit `pty.destroy()` and the SessionStore row is removed before the next `/list` or `/events` tick. Without the explicit destroy, the master fd lingers in node-pty's internal socket until V8 GC finalises the IPty object non-deterministic and accumulates under load until the kernel pty cap (Linux 3072, macOS 511) refuses new spawns. Without the explicit row removal, the manager shutdown loop SIGTERMs PIDs that already logged `process-exited`, masking the leak only because the manager restarts every few hours. When both branches fire on the same exit (operator clicks End and node-pty's `onExit` fans out the SIGTERM to both listeners), a per-row `fdReleased` flag short-circuits the second branch so `pty.destroy()` runs exactly once on the live socket — without the flag, the second call throws "socket already destroyed" and the operator-request line would falsely log `master-fd=close-failed`. If the first branch's destroy throws and is rescued, the flag stays unset and the second branch retries (defense in depth). Every `kill … pid=<n>` log line carries a `master-fd=closed` suffix (or `master-fd=close-failed err=<msg>` on the rescued throw branch — a graceful degradation so a corner-case socket-state failure cannot turn a logically-successful exit into a 500); the operator-request line additionally identifies `reason=operator-request`, the natural-exit line identifies `reason=process-exited`. Both branches are verified by the `stop-session-fd-release` and `endpoint-stop-delete` integration tests (operator-request live and already-exited cycles + natural-exit cycle + throw-then-retry coordination, Linux kernel-level ptmx fd accounting on each).
92
92
 
93
93
  The metadata pane subscribes to the same /list projection. When an operator clicks End on an alive row, the DELETE returns 200 and the post-mutation refetch decides what happens next: a session that wrote a JSONL surfaces as a dehydrated `status: 'ended'` row (the pane swaps `End session` for `Purge JSONL` plus `Resume`), and a session that never wrote a JSONL (`Turns: 0`) leaves the list entirely (the pane shows a `Session ended without a transcript. Close this pane.` banner with a Close button and no destructive action). The manager's `/list` and `/meta` are the only authorities on post-End state; the client does not pre-empt either response with an optimistic mutation.
94
94
 
@@ -4,6 +4,9 @@ import { type SpawnDeps } from './pty-spawner.js';
4
4
  import type { FsWatcher } from './fs-watcher.js';
5
5
  import type { RateLimiter } from './spawn-rate-limiter.js';
6
6
  import type { AuditRegistry } from './public-tool-audit.js';
7
+ /** Test-only — clear the dedup cache between vitest cases so module state
8
+ * does not leak across describe blocks. */
9
+ export declare function __resetResumeDedupCacheForTests(): void;
7
10
  export interface HttpDeps extends Omit<SpawnDeps, 'store' | 'onSessionReady' | 'watcher'> {
8
11
  store: SessionStore;
9
12
  watcher: FsWatcher;
@@ -1 +1 @@
1
- {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAwBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAI3B,OAAO,KAAK,EAAE,YAAY,EAAiB,MAAM,oBAAoB,CAAA;AACrE,OAAO,EAA0E,KAAK,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAIzH,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;AA6E3D,MAAM,WAAW,QAAS,SAAQ,IAAI,CAAC,SAAS,EAAE,OAAO,GAAG,gBAAgB,GAAG,SAAS,CAAC;IACvF,KAAK,EAAE,YAAY,CAAA;IACnB,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;CAC/B;AAoHD,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,CA8sBjD"}
1
+ {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAwBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAI3B,OAAO,KAAK,EAAE,YAAY,EAAiB,MAAM,oBAAoB,CAAA;AACrE,OAAO,EAA0E,KAAK,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAIzH,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;AAoB3D;4CAC4C;AAC5C,wBAAgB,+BAA+B,IAAI,IAAI,CAEtD;AA0ED,MAAM,WAAW,QAAS,SAAQ,IAAI,CAAC,SAAS,EAAE,OAAO,GAAG,gBAAgB,GAAG,SAAS,CAAC;IACvF,KAAK,EAAE,YAAY,CAAA;IACnB,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;CAC/B;AAoHD,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,CAiwBjD"}
@@ -30,6 +30,25 @@ import { claudeStateRoot, projectSlugForCwd } from './jsonl-path.js';
30
30
  import { basename, join } from 'node:path';
31
31
  const ROLES = ['admin', 'public'];
32
32
  const CHANNELS = ['browser', 'whatsapp', 'telegram', 'webchat', 'email'];
33
+ // Task 179 — resume idempotency. Two POSTs 9ms apart with the same
34
+ // idempotencyKey produce one PTY and return the same ManagerSession.
35
+ // Keyed on `${senderId}:${idempotencyKey}` so two operators' keys cannot
36
+ // collide; TTL is 5000ms — long enough to absorb a React StrictMode
37
+ // re-render race or a cross-surface double-click, short enough that a
38
+ // real second resume two seconds later still spawns a new PTY.
39
+ const RESUME_DEDUP_TTL_MS = 5_000;
40
+ const resumeDedupCache = new Map();
41
+ function pruneResumeDedupCache(now) {
42
+ for (const [key, entry] of resumeDedupCache) {
43
+ if (now - entry.createdAt > RESUME_DEDUP_TTL_MS)
44
+ resumeDedupCache.delete(key);
45
+ }
46
+ }
47
+ /** Test-only — clear the dedup cache between vitest cases so module state
48
+ * does not leak across describe blocks. */
49
+ export function __resetResumeDedupCacheForTests() {
50
+ resumeDedupCache.clear();
51
+ }
33
52
  function parseAboutOwner(raw) {
34
53
  if (!raw || typeof raw !== 'object')
35
54
  return undefined;
@@ -568,6 +587,22 @@ export function buildHttpApp(deps) {
568
587
  timed(deps.logger, 'POST', '/resume', 400, Date.now() - start);
569
588
  return c.json({ error: 'invalid-arguments' }, 400);
570
589
  }
590
+ // Task 179 — idempotency dedup. Same key + same senderId within 5s
591
+ // returns the cached payload; no new spawn.
592
+ const idempotencyKey = typeof body.idempotencyKey === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.idempotencyKey)
593
+ ? body.idempotencyKey
594
+ : undefined;
595
+ if (idempotencyKey) {
596
+ const now = Date.now();
597
+ pruneResumeDedupCache(now);
598
+ const cacheKey = `${senderId}:${idempotencyKey}`;
599
+ const hit = resumeDedupCache.get(cacheKey);
600
+ if (hit && now - hit.createdAt <= RESUME_DEDUP_TTL_MS) {
601
+ deps.logger(`resume-dedup senderId=${senderId} idempotencyKey=${idempotencyKey} cacheHit=yes age-ms=${now - hit.createdAt}`);
602
+ timed(deps.logger, 'POST', '/resume', 200, Date.now() - start);
603
+ return c.json(hit.payload, 200);
604
+ }
605
+ }
571
606
  const channels = Array.isArray(body.channels)
572
607
  ? body.channels.filter((c) => typeof c === 'string' && c.length > 0)
573
608
  : undefined;
@@ -580,6 +615,16 @@ export function buildHttpApp(deps) {
580
615
  : body.tunnelUrl === null
581
616
  ? null
582
617
  : undefined;
618
+ // Task 179 — specialist propagation. Body field takes precedence; when
619
+ // absent, fall back to the source row's `agent`. Either path: the
620
+ // resumed PTY is spawned with `--agent <name>` so the new row inherits
621
+ // the same specialist as the source. A database-operator resume now
622
+ // produces a database-operator session, not an admin session.
623
+ const bodySpecialist = typeof body.specialist === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.specialist)
624
+ ? body.specialist
625
+ : undefined;
626
+ const sourceRow = resolveRow(deps.watcher, sessionId);
627
+ const specialist = bodySpecialist ?? sourceRow?.agent ?? undefined;
583
628
  const result = await spawnClaudeSession({ ...deps, store: deps.store, watcher: deps.watcher, onSessionReady: (s) => { attachPublicAudit(deps, s); attachSpecialistEndTurnAutoArchive(deps, s); }, tunnelUrlOverride }, {
584
629
  senderId,
585
630
  role: role,
@@ -590,6 +635,7 @@ export function buildHttpApp(deps) {
590
635
  dormantPlugins,
591
636
  activePlugins,
592
637
  specialistDomains,
638
+ specialist,
593
639
  });
594
640
  if (!result.ok) {
595
641
  if ('rejected' in result) {
@@ -603,9 +649,16 @@ export function buildHttpApp(deps) {
603
649
  timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
604
650
  return c.json({ error: 'resume-failed', reason: result.reason }, 500);
605
651
  }
652
+ if (specialist) {
653
+ deps.logger(`resume-specialist-propagated source=${sessionId} specialist=${specialist}`);
654
+ }
606
655
  deps.logger(`resume pid=${result.session.pid} sessionId=${result.session.sessionId} resumeFrom=${sessionId} latency-ms=${Date.now() - start}`);
656
+ const payload = toLivePayload(result.session);
657
+ if (idempotencyKey) {
658
+ resumeDedupCache.set(`${senderId}:${idempotencyKey}`, { payload, createdAt: Date.now() });
659
+ }
607
660
  timed(deps.logger, 'POST', '/resume', 200, Date.now() - start);
608
- return c.json(toLivePayload(result.session), 200);
661
+ return c.json(payload, 200);
609
662
  });
610
663
  app.get('/:sessionId/meta', (c) => {
611
664
  const start = Date.now();