@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.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +251 -94
- package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +119 -71
- package/payload/platform/plugins/docs/references/admin-session.md +16 -10
- package/payload/platform/plugins/docs/references/internals.md +2 -0
- package/payload/platform/plugins/docs/references/platform.md +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +3 -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 +54 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.d.ts +36 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.js +131 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +43 -0
- 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 +119 -5
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +9 -0
- package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
- package/payload/platform/templates/specialists/agents/database-operator.md +35 -10
- package/payload/server/public/assets/{admin-ymReS1mN.js → admin-t8QqVwJF.js} +1 -1
- package/payload/server/public/assets/{data-D6U2e85B.js → data-vHmNQyXt.js} +1 -1
- package/payload/server/public/assets/{graph-C274PN27.js → graph-C7QOwigU.js} +1 -1
- package/payload/server/public/assets/graph-labels-Dm2RMEFy.js +1 -0
- package/payload/server/public/assets/{page-D-OLjort.js → page-B9GFro9g.js} +1 -1
- package/payload/server/public/assets/{page-DFFt9_Q0.js → page-BWKfXlHt.js} +1 -1
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- package/payload/server/server.js +2 -0
- 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
|
|
4
|
-
#
|
|
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
|
|
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
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
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
|
-
# -
|
|
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
|
|
100
|
-
#
|
|
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
|
-
# "
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
# "
|
|
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
|
|
119
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
return
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
167
|
-
if
|
|
174
|
+
slot = pending_tool_calls.pop(tu_id, None)
|
|
175
|
+
if slot is None:
|
|
168
176
|
continue
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
"
|
|
202
|
-
"
|
|
203
|
-
"
|
|
204
|
-
"
|
|
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
|
|
213
|
-
#
|
|
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
|
|
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
|
-
"
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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`;
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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;
|
|
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(
|
|
661
|
+
return c.json(payload, 200);
|
|
609
662
|
});
|
|
610
663
|
app.get('/:sessionId/meta', (c) => {
|
|
611
664
|
const start = Date.now();
|