@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.
- 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 +317 -81
- package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +183 -49
- package/payload/platform/plugins/docs/references/admin-session.md +23 -4
- 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,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
|
|
4
|
-
#
|
|
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:
|
|
12
|
-
#
|
|
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
|
-
# -
|
|
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
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
# "
|
|
99
|
-
|
|
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:
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if not
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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":
|
|
299
|
+
"initialMessage": envelope_str,
|
|
166
300
|
}
|
|
167
301
|
print(json.dumps(body))
|
|
168
|
-
' "$ADMIN_SESSION_ID" "$
|
|
302
|
+
' "$ADMIN_SESSION_ID" "$ENVELOPE" 2>/dev/null)
|
|
169
303
|
|
|
170
|
-
INITIAL_BYTES=$(printf '%s
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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();
|