@rubytech/create-maxy-code 0.1.66 → 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 +251 -94
  4. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +119 -71
  5. package/payload/platform/plugins/docs/references/admin-session.md +16 -10
  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,14 +1,14 @@
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
4
- # turn payload. The recorder is the only writer to the Neo4j graph; the
5
- # admin agent 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: JSON-stringified envelope (Task 175)
11
+ # - initialMessage: JSON-stringified envelope (Task 177)
12
12
  #
13
13
  # It POSTs to the SAME route the Sidebar uses
14
14
  # (`POST /api/admin/claude-sessions`). The wrapper accepts the loopback
@@ -16,10 +16,16 @@
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 175 — `initialMessage` is now a JSON object stringified to a
20
- # string. Top-level keys EXACTLY match the database-operator agent file
21
- # `## Input shape`: operatorMessage, assistantReply, toolCalls,
22
- # conversationId, accountId, occurredAt. No leading instruction prose.
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.
23
29
  #
24
30
  # Gating (emits a `trigger-skipped` line via `/api/admin/log-ingest`;
25
31
  # stderr stays silent on the success path):
@@ -28,8 +34,7 @@
28
34
  # "database-operator" (recursion guard)
29
35
  # - Stop-hook stdin must be non-empty → reason=empty-stdin
30
36
  # - transcript_path must exist on disk → reason=missing-transcript
31
- # - operatorMessage, assistantReply, toolCalls → reason=conversation-empty
32
- # all empty after the walker runs
37
+ # - turns array is empty after walker runs → reason=conversation-empty
33
38
  #
34
39
  # Input: Claude Code's Stop hook stdin shape
35
40
  # { "session_id": "<intrinsic>", "transcript_path": "<jsonl path>", ... }
@@ -96,16 +101,17 @@ if [ -z "$ADMIN_SESSION_ID" ] || [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIP
96
101
  exit 0
97
102
  fi
98
103
 
99
- # Task 175 — build the JSON envelope the database-operator agent file
100
- # declares as its stdin contract. One Python pass walks the transcript
101
- # and emits:
104
+ # Task 177 — build the envelope the database-operator agent file declares
105
+ # as its stdin contract. One Python pass walks the transcript and emits:
102
106
  # {
103
- # "operatorMessage": <last role=user text>,
104
- # "assistantReply": <last role=assistant text>,
105
- # "toolCalls": [ {tool, input, output}, ... ],
106
- # "conversationId": <session_id>,
107
- # "accountId": <ACCOUNT_ID env>,
108
- # "occurredAt": <hook fire time, ISO-8601 UTC, .000Z>
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>"
109
115
  # }
110
116
  # `python3 json.dumps` handles every escape so the hook never has to
111
117
  # concatenate strings into JSON by hand.
@@ -115,26 +121,26 @@ ACCOUNT_ID_ENV="${ACCOUNT_ID:-}"
115
121
  ENVELOPE=$(python3 - "$TRANSCRIPT_PATH" "$ADMIN_SESSION_ID" "$ACCOUNT_ID_ENV" "$OCCURRED_AT" <<'PY'
116
122
  import sys, json
117
123
 
118
- def last_text(content):
119
- """Return the last `text` block string in a content list, or '' if none."""
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."""
120
127
  if isinstance(content, str):
121
128
  return content
122
129
  if not isinstance(content, list):
123
130
  return ""
124
- last = ""
131
+ out = []
125
132
  for b in content:
126
133
  if isinstance(b, dict) and b.get("type") == "text":
127
134
  t = b.get("text")
128
135
  if isinstance(t, str):
129
- last = t
130
- return last
136
+ out.append(t)
137
+ return "".join(out)
131
138
 
132
139
  path, conversation_id, account_id, occurred_at = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
133
140
 
134
- last_user_text = ""
135
- last_assistant_text = ""
136
- pending_calls = {} # tool_use_id -> {tool, input}
137
- tool_calls = [] # ordered as tool_results arrive
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)
138
144
 
139
145
  try:
140
146
  with open(path, "r", encoding="utf-8") as f:
@@ -153,74 +159,99 @@ try:
153
159
  if not isinstance(msg, dict):
154
160
  continue
155
161
  content = msg.get("content")
162
+ ts = rec.get("timestamp", "") or ""
163
+
156
164
  if t == "user":
157
- text = last_text(content)
158
- if text:
159
- last_user_text = text
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.
160
168
  if isinstance(content, list):
161
169
  for b in content:
162
170
  if isinstance(b, dict) and b.get("type") == "tool_result":
163
171
  tu_id = b.get("tool_use_id")
164
172
  if not isinstance(tu_id, str):
165
173
  continue
166
- pending = pending_calls.pop(tu_id, None)
167
- if pending is None:
174
+ slot = pending_tool_calls.pop(tu_id, None)
175
+ if slot is None:
168
176
  continue
169
- tool_calls.append({
170
- "tool": pending["tool"],
171
- "input": pending["input"],
172
- "output": b.get("content"),
173
- })
174
- elif t == "assistant":
175
- text = last_text(content)
177
+ turn_idx, call_idx = slot
178
+ turns[turn_idx]["toolCalls"][call_idx]["output"] = b.get("content")
179
+
180
+ text = concat_text(content)
176
181
  if text:
177
- last_assistant_text = 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 = []
178
195
  if isinstance(content, list):
179
196
  for b in content:
180
197
  if isinstance(b, dict) and b.get("type") == "tool_use":
181
- tu_id = b.get("id")
182
- if not isinstance(tu_id, str):
183
- continue
184
- pending_calls[tu_id] = {
185
- "tool": b.get("name"),
186
- "input": b.get("input"),
187
- }
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)
188
234
  except Exception:
189
235
  pass
190
236
 
191
- # Unanswered tool_use blocks (tool_result never landed) still surface, so
192
- # the recorder sees the call attempt; `output` is null.
193
- for tu_id, pending in pending_calls.items():
194
- tool_calls.append({
195
- "tool": pending["tool"],
196
- "input": pending["input"],
197
- "output": None,
198
- })
199
-
200
237
  envelope = {
201
- "operatorMessage": last_user_text,
202
- "assistantReply": last_assistant_text,
203
- "toolCalls": tool_calls,
204
- "conversationId": conversation_id,
205
- "accountId": account_id,
206
- "occurredAt": occurred_at,
238
+ "turns": turns,
239
+ "conversationId": conversation_id,
240
+ "accountId": account_id,
241
+ "occurredAt": occurred_at,
207
242
  }
208
243
  print(json.dumps(envelope, ensure_ascii=False), end="")
209
244
  PY
210
245
  )
211
246
 
212
- # Skip when the envelope carries no user text, no assistant text, and no
213
- # tool calls same skip surface as the prior `conversation-empty` reason.
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.
214
250
  EMPTY_CHECK=$(printf '%s' "$ENVELOPE" | python3 -c '
215
251
  import sys, json
216
252
  try:
217
253
  e = json.load(sys.stdin)
218
- empty = (
219
- not (e.get("operatorMessage") or "")
220
- and not (e.get("assistantReply") or "")
221
- and not (e.get("toolCalls") or [])
222
- )
223
- print("empty" if empty else "ok")
254
+ print("empty" if not (e.get("turns") or []) else "ok")
224
255
  except Exception:
225
256
  print("empty")
226
257
  ')
@@ -235,6 +266,23 @@ if [ "$EMPTY_CHECK" = "empty" ]; then
235
266
  exit 0
236
267
  fi
237
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
+
238
286
  # Compose the Sidebar-shape spawn body. Three overrides relative to a
239
287
  # plain Sidebar "New session" click: specialist, model, initialMessage.
240
288
  # `adminSessionId` is the loopback-bypass key the wrapper uses to resolve
@@ -96,20 +96,25 @@ Body overrides relative to a plain Sidebar click:
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
- **`initialMessage` JSON envelope (Task 175).** A single JSON object stringified to a string. Top-level keys exactly match the database-operator agent file `## Input shape`:
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
100
 
101
101
  ```
102
102
  {
103
- "operatorMessage": "<last role=user text block>",
104
- "assistantReply": "<last role=assistant text block, or '' if none>",
105
- "toolCalls": [ { "tool": "...", "input": {...}, "output": ... }, ... ],
106
- "conversationId": "<operator session_id>",
107
- "accountId": "<ACCOUNT_ID env, stamped on the manager systemd unit>",
108
- "occurredAt": "<hook fire time, ISO-8601 UTC, .000Z>"
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>"
109
112
  }
110
113
  ```
111
114
 
112
- `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`; 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 `operatorMessage`, `assistantReply`, and `toolCalls` are all empty after the walker runs, the hook emits `trigger-skipped reason=conversation-empty` and no recorder is spawned.
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".
113
118
 
114
119
  **Manager-side specialist branches.** The recorder is the first specialist subagent that exercises the full specialist flag matrix:
115
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.
@@ -122,16 +127,17 @@ Body overrides relative to a plain Sidebar click:
122
127
 
123
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.
124
129
 
125
- **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>`.
126
131
 
127
132
  ### Recorder lifecycle diagnostic
128
133
 
129
- 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.
130
135
 
131
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).
132
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.
133
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`).
134
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.
135
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.
136
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>`.
137
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();