@rubytech/create-realagent-code 0.1.249 → 0.1.250
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__/session-end-retrospective.test.sh +3 -3
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +16 -10
- package/payload/platform/plugins/docs/PLUGIN.md +2 -2
- package/payload/platform/plugins/docs/references/admin-session.md +7 -67
- package/payload/platform/plugins/docs/references/admin-ui.md +3 -3
- package/payload/platform/plugins/docs/references/deployment.md +1 -1
- package/payload/platform/plugins/docs/references/internals.md +8 -2
- package/payload/platform/plugins/docs/references/platform.md +3 -3
- package/payload/platform/scripts/check-no-legacy-spawn-route.mjs +37 -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 +57 -21
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +1 -0
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +8 -0
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +14 -4
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
- package/payload/server/server.js +120 -121
- package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +0 -601
- package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +0 -441
|
@@ -1,441 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Stop hook — fires on every completed admin-agent turn and dispatches one
|
|
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
|
-
#
|
|
7
|
-
# Task 147 — the recorder spawn is byte-for-byte equivalent to a Sidebar
|
|
8
|
-
# "New session" body, with two overrides:
|
|
9
|
-
# - specialist: "database-operator"
|
|
10
|
-
# - initialMessage: JSON-stringified envelope (Task 177)
|
|
11
|
-
# The model is taken from the agent file frontmatter (Task 200).
|
|
12
|
-
#
|
|
13
|
-
# It POSTs to the SAME route the Sidebar uses
|
|
14
|
-
# (`POST /api/admin/claude-sessions`). The wrapper accepts the loopback
|
|
15
|
-
# request without a cookie, resolves the operator's `senderId` from the
|
|
16
|
-
# manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn
|
|
17
|
-
# body. No recorder-only carving on the manager side.
|
|
18
|
-
#
|
|
19
|
-
# Task 177 — `initialMessage` is a JSON object stringified to a string.
|
|
20
|
-
# Top-level keys EXACTLY: turns, sessionId, 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
|
-
#
|
|
30
|
-
# Gating (emits a `trigger-skipped` line via `/api/admin/log-ingest`;
|
|
31
|
-
# stderr stays silent on the success path):
|
|
32
|
-
# - MAXY_SESSION_ROLE must equal "admin" → reason=role-not-admin
|
|
33
|
-
# - MAXY_SPECIALIST must NOT equal → reason=is-recorder
|
|
34
|
-
# "database-operator" (recursion guard)
|
|
35
|
-
# - Stop-hook stdin must be non-empty → reason=empty-stdin
|
|
36
|
-
# - transcript_path must exist on disk → reason=missing-transcript
|
|
37
|
-
# - turns array is empty after walker runs → reason=conversation-empty
|
|
38
|
-
#
|
|
39
|
-
# Input: Claude Code's Stop hook stdin shape
|
|
40
|
-
# { "session_id": "<intrinsic>", "transcript_path": "<jsonl path>", ... }
|
|
41
|
-
|
|
42
|
-
set -uo pipefail
|
|
43
|
-
|
|
44
|
-
UI_PORT="${MAXY_UI_INTERNAL_PORT:-}"
|
|
45
|
-
if [ -z "$UI_PORT" ]; then
|
|
46
|
-
echo "[turn-recorder] spawn-failed sessionId=unknown reason=missing-env env=MAXY_UI_INTERNAL_PORT" >&2
|
|
47
|
-
exit 0
|
|
48
|
-
fi
|
|
49
|
-
UI_BASE="http://127.0.0.1:${UI_PORT}"
|
|
50
|
-
LOG_INGEST_URL="${UI_BASE}/api/admin/log-ingest"
|
|
51
|
-
|
|
52
|
-
emit_log() {
|
|
53
|
-
local line="$1"
|
|
54
|
-
curl -sS -o /dev/null -X POST \
|
|
55
|
-
-H 'Content-Type: application/json' \
|
|
56
|
-
--max-time 2 \
|
|
57
|
-
--data "$(python3 -c '
|
|
58
|
-
import sys, json
|
|
59
|
-
print(json.dumps({"tag": "turn-recorder", "level": "info", "line": sys.argv[1]}))
|
|
60
|
-
' "$line")" \
|
|
61
|
-
"$LOG_INGEST_URL" 2>/dev/null || true
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
INPUT=""
|
|
65
|
-
if [ -t 0 ]; then
|
|
66
|
-
INPUT=""
|
|
67
|
-
else
|
|
68
|
-
INPUT=$(cat)
|
|
69
|
-
fi
|
|
70
|
-
|
|
71
|
-
PARSED=$(printf '%s' "$INPUT" | python3 -c '
|
|
72
|
-
import sys, json
|
|
73
|
-
try:
|
|
74
|
-
d = json.load(sys.stdin)
|
|
75
|
-
sid = d.get("session_id", "") or ""
|
|
76
|
-
tpath = d.get("transcript_path", "") or ""
|
|
77
|
-
print(f"{sid}\t{tpath}")
|
|
78
|
-
except Exception:
|
|
79
|
-
print("\t")
|
|
80
|
-
' 2>/dev/null)
|
|
81
|
-
ADMIN_SESSION_ID="${PARSED%% *}"
|
|
82
|
-
TRANSCRIPT_PATH="${PARSED#* }"
|
|
83
|
-
|
|
84
|
-
if [ "${MAXY_SESSION_ROLE:-}" != "admin" ]; then
|
|
85
|
-
emit_log "trigger-skipped sessionId=${ADMIN_SESSION_ID:-unknown} reason=role-not-admin"
|
|
86
|
-
exit 0
|
|
87
|
-
fi
|
|
88
|
-
# Recursion guard. The recorder spawn carries
|
|
89
|
-
# `specialist=database-operator`; pty-spawner stamps MAXY_SPECIALIST on the
|
|
90
|
-
# PTY env. Skip recording the recorder's own turn.
|
|
91
|
-
if [ "${MAXY_SPECIALIST:-}" = "database-operator" ]; then
|
|
92
|
-
emit_log "trigger-skipped sessionId=${ADMIN_SESSION_ID:-unknown} reason=is-recorder"
|
|
93
|
-
exit 0
|
|
94
|
-
fi
|
|
95
|
-
if [ -z "$INPUT" ]; then
|
|
96
|
-
emit_log "trigger-skipped sessionId=${ADMIN_SESSION_ID:-unknown} reason=empty-stdin"
|
|
97
|
-
exit 0
|
|
98
|
-
fi
|
|
99
|
-
if [ -z "$ADMIN_SESSION_ID" ] || [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
|
|
100
|
-
emit_log "trigger-skipped sessionId=${ADMIN_SESSION_ID:-unknown} reason=missing-transcript"
|
|
101
|
-
exit 0
|
|
102
|
-
fi
|
|
103
|
-
|
|
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
|
-
# "sessionId": "<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'
|
|
122
|
-
import sys, json
|
|
123
|
-
|
|
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, session_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
|
-
|
|
145
|
-
try:
|
|
146
|
-
with open(path, "r", encoding="utf-8") as f:
|
|
147
|
-
for line in f:
|
|
148
|
-
line = line.strip()
|
|
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)
|
|
234
|
-
except Exception:
|
|
235
|
-
pass
|
|
236
|
-
|
|
237
|
-
envelope = {
|
|
238
|
-
"turns": turns,
|
|
239
|
-
"sessionId": session_id,
|
|
240
|
-
"accountId": account_id,
|
|
241
|
-
"occurredAt": occurred_at,
|
|
242
|
-
}
|
|
243
|
-
print(json.dumps(envelope, ensure_ascii=False), end="")
|
|
244
|
-
PY
|
|
245
|
-
)
|
|
246
|
-
|
|
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 ' ')
|
|
260
|
-
TRANSCRIPT_BYTES=$(wc -c <"$TRANSCRIPT_PATH" 2>/dev/null | tr -d ' ' || echo 0)
|
|
261
|
-
|
|
262
|
-
emit_log "trigger sessionId=${ADMIN_SESSION_ID} turnIndex=0 transcriptBytes=${TRANSCRIPT_BYTES} conversationBytes=${CONVERSATION_BYTES}"
|
|
263
|
-
|
|
264
|
-
if [ "$EMPTY_CHECK" = "empty" ]; then
|
|
265
|
-
emit_log "trigger-skipped sessionId=${ADMIN_SESSION_ID} reason=conversation-empty"
|
|
266
|
-
exit 0
|
|
267
|
-
fi
|
|
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
|
-
|
|
286
|
-
# Task 195 — substitute {schema} and {conversation} placeholders in the
|
|
287
|
-
# database-operator agent body before spawning. The agent file on disk
|
|
288
|
-
# carries the four-sentence template verbatim with both placeholders; the
|
|
289
|
-
# hook renders the actual schema text and a flat `role: text` conversation
|
|
290
|
-
# transcript here, then sends the filled paragraph as `initialMessage`.
|
|
291
|
-
# The model's first input is the filled prompt — no JSON envelope, no
|
|
292
|
-
# instruction prose, no placeholders.
|
|
293
|
-
HOOK_DIR_RESOLVED="$(cd "$(dirname "$0")" && pwd)"
|
|
294
|
-
PLATFORM_ROOT_RESOLVED="$(cd "${HOOK_DIR_RESOLVED}/../../.." && pwd)"
|
|
295
|
-
SCHEMA_BASE_PATH="${PLATFORM_ROOT_RESOLVED}/plugins/memory/references/schema-base.md"
|
|
296
|
-
BRAND_JSON_PATH="${PLATFORM_ROOT_RESOLVED}/config/brand.json"
|
|
297
|
-
AGENT_BODY_PATH="${PLATFORM_ROOT_RESOLVED}/templates/specialists/agents/database-operator.md"
|
|
298
|
-
|
|
299
|
-
if [ ! -f "$SCHEMA_BASE_PATH" ]; then
|
|
300
|
-
emit_log "spawn-failed sessionId=${ADMIN_SESSION_ID} reason=schema-base-missing path=${SCHEMA_BASE_PATH}"
|
|
301
|
-
exit 0
|
|
302
|
-
fi
|
|
303
|
-
if [ ! -f "$AGENT_BODY_PATH" ]; then
|
|
304
|
-
emit_log "spawn-failed sessionId=${ADMIN_SESSION_ID} reason=agent-body-missing path=${AGENT_BODY_PATH}"
|
|
305
|
-
exit 0
|
|
306
|
-
fi
|
|
307
|
-
|
|
308
|
-
# Brand-declared vertical (Task 193). Treated as null when brand.json is
|
|
309
|
-
# absent, unparseable, lacks the field, or names a file that does not
|
|
310
|
-
# exist — every one of those collapses to "schema-base only", never
|
|
311
|
-
# loud-fails this hook. Task 193 itself is the loud-fail surface for
|
|
312
|
-
# brand-config defects.
|
|
313
|
-
VERTICAL_PATH=""
|
|
314
|
-
if [ -f "$BRAND_JSON_PATH" ]; then
|
|
315
|
-
VERTICAL_NAME=$(python3 -c '
|
|
316
|
-
import sys, json
|
|
317
|
-
try:
|
|
318
|
-
with open(sys.argv[1]) as f:
|
|
319
|
-
b = json.load(f)
|
|
320
|
-
v = b.get("vertical")
|
|
321
|
-
print(v if isinstance(v, str) and v else "")
|
|
322
|
-
except Exception:
|
|
323
|
-
print("")
|
|
324
|
-
' "$BRAND_JSON_PATH")
|
|
325
|
-
if [ -n "$VERTICAL_NAME" ]; then
|
|
326
|
-
CANDIDATE="${PLATFORM_ROOT_RESOLVED}/plugins/memory/references/${VERTICAL_NAME}.md"
|
|
327
|
-
if [ -f "$CANDIDATE" ]; then
|
|
328
|
-
VERTICAL_PATH="$CANDIDATE"
|
|
329
|
-
fi
|
|
330
|
-
fi
|
|
331
|
-
fi
|
|
332
|
-
|
|
333
|
-
# One Python pass:
|
|
334
|
-
# - reads the agent file and strips the frontmatter to recover the
|
|
335
|
-
# paragraph (the literal four sentences with `{schema}` and
|
|
336
|
-
# `{conversation}` placeholders);
|
|
337
|
-
# - reads schema-base.md (and concatenates the vertical when set);
|
|
338
|
-
# - renders the envelope's `turns` array into a flat transcript — one
|
|
339
|
-
# line per turn, `role: text`. The recorder is the only writer to the
|
|
340
|
-
# graph; rendering the operator's tool_use blocks inline taught the
|
|
341
|
-
# model (Task 213 evidence in sessions d2aaa85e / 481799d1) to copy
|
|
342
|
-
# the shape and emit `<tool_call>{…}</tool_call>` as text instead of
|
|
343
|
-
# real tool_use blocks. The envelope's `toolCalls` are still captured
|
|
344
|
-
# for the `[turn-recorder] envelope … toolCallTurns=N` observability
|
|
345
|
-
# line; they no longer reach the spawn prompt;
|
|
346
|
-
# - substitutes both placeholders in the paragraph;
|
|
347
|
-
# - emits one tab-separated line of byte counts, then the
|
|
348
|
-
# spawn-body JSON containing the filled paragraph as `initialMessage`.
|
|
349
|
-
# The envelope rides through a tmpfile (not stdin) so the python script
|
|
350
|
-
# can be sourced from the heredoc; the two stdin redirections would
|
|
351
|
-
# otherwise collide and Python would see an empty envelope.
|
|
352
|
-
ENVELOPE_FILE=$(mktemp)
|
|
353
|
-
printf '%s' "$ENVELOPE" > "$ENVELOPE_FILE"
|
|
354
|
-
COMBINED=$(python3 - \
|
|
355
|
-
"$AGENT_BODY_PATH" "$SCHEMA_BASE_PATH" "$VERTICAL_PATH" "$ADMIN_SESSION_ID" "$ENVELOPE_FILE" <<'PY'
|
|
356
|
-
import sys, json
|
|
357
|
-
|
|
358
|
-
agent_path, schema_path, vertical_path, sid, envelope_path = sys.argv[1:6]
|
|
359
|
-
|
|
360
|
-
with open(agent_path, "r", encoding="utf-8") as f:
|
|
361
|
-
raw = f.read()
|
|
362
|
-
parts = raw.split("---", 2)
|
|
363
|
-
body_template = parts[2].lstrip("\n").rstrip() if len(parts) >= 3 else raw.rstrip()
|
|
364
|
-
|
|
365
|
-
with open(schema_path, "r", encoding="utf-8") as f:
|
|
366
|
-
schema_text = f.read()
|
|
367
|
-
if vertical_path:
|
|
368
|
-
with open(vertical_path, "r", encoding="utf-8") as f:
|
|
369
|
-
schema_text = schema_text.rstrip() + "\n\n" + f.read()
|
|
370
|
-
|
|
371
|
-
with open(envelope_path, "r", encoding="utf-8") as f:
|
|
372
|
-
envelope = json.load(f)
|
|
373
|
-
account_id = envelope.get("accountId", "") or ""
|
|
374
|
-
turns = envelope.get("turns") or []
|
|
375
|
-
lines = []
|
|
376
|
-
for t in turns:
|
|
377
|
-
role = t.get("role", "?")
|
|
378
|
-
text_t = t.get("text", "") or ""
|
|
379
|
-
lines.append(f"{role}: {text_t}")
|
|
380
|
-
# Wrap the transcript block in newlines so each turn lands on its own
|
|
381
|
-
# line in the filled prompt — the template's `{conversation}` sits
|
|
382
|
-
# mid-sentence, so the leading newline separates the first transcript
|
|
383
|
-
# line from the surrounding prose, and the trailing newline separates
|
|
384
|
-
# the last one from the closing `, using the tools at your disposal.`
|
|
385
|
-
conversation_text = "\n" + "\n".join(lines) + "\n"
|
|
386
|
-
|
|
387
|
-
filled = (
|
|
388
|
-
body_template
|
|
389
|
-
.replace("{schema}", schema_text)
|
|
390
|
-
.replace("{accountId}", account_id)
|
|
391
|
-
.replace("{conversation}", conversation_text)
|
|
392
|
-
)
|
|
393
|
-
|
|
394
|
-
schema_bytes = len(schema_text.encode("utf-8"))
|
|
395
|
-
conv_bytes = len(conversation_text.encode("utf-8"))
|
|
396
|
-
body_bytes = len(filled.encode("utf-8"))
|
|
397
|
-
account_id_present = "yes" if account_id else "no"
|
|
398
|
-
|
|
399
|
-
spawn_body = {
|
|
400
|
-
"adminSessionId": sid,
|
|
401
|
-
"channel": "browser",
|
|
402
|
-
"specialist": "database-operator",
|
|
403
|
-
"initialMessage": filled,
|
|
404
|
-
}
|
|
405
|
-
# Task 500 — under the `claude rc` daemon, admin sessions are not
|
|
406
|
-
# maxy-spawned, so the manager's `/<sid>/meta` carries no senderId. Pass
|
|
407
|
-
# the hook's ACCOUNT_ID env as the authoritative senderId fallback. Empty
|
|
408
|
-
# is omitted so the route's loopback guard rejects rather than minting an
|
|
409
|
-
# empty-scope row.
|
|
410
|
-
if account_id:
|
|
411
|
-
spawn_body["accountId"] = account_id
|
|
412
|
-
|
|
413
|
-
print(f"{schema_bytes}\t{conv_bytes}\t{body_bytes}\t{account_id_present}")
|
|
414
|
-
print(json.dumps(spawn_body))
|
|
415
|
-
PY
|
|
416
|
-
)
|
|
417
|
-
rm -f "$ENVELOPE_FILE"
|
|
418
|
-
|
|
419
|
-
BYTES_LINE=$(printf '%s' "$COMBINED" | head -n1)
|
|
420
|
-
SPAWN_BODY=$(printf '%s' "$COMBINED" | tail -n +2)
|
|
421
|
-
IFS=$'\t' read -r SCHEMA_BYTES CONV_BYTES BODY_BYTES ACCOUNT_ID_PRESENT <<<"$BYTES_LINE"
|
|
422
|
-
|
|
423
|
-
emit_log "substitution sessionId=${ADMIN_SESSION_ID} schemaBytes=${SCHEMA_BYTES} conversationBytes=${CONV_BYTES} bodyBytes=${BODY_BYTES} accountIdPresent=${ACCOUNT_ID_PRESENT}"
|
|
424
|
-
emit_log "spawn-request sessionId=${ADMIN_SESSION_ID} specialist=database-operator initialMessageBytes=${BODY_BYTES}"
|
|
425
|
-
|
|
426
|
-
SPAWN_RES_FILE=$(mktemp)
|
|
427
|
-
SPAWN_HTTP=$(curl -sS -o "$SPAWN_RES_FILE" -w '%{http_code}' -X POST \
|
|
428
|
-
-H 'Content-Type: application/json' \
|
|
429
|
-
--max-time 10 \
|
|
430
|
-
--data "$SPAWN_BODY" \
|
|
431
|
-
"${UI_BASE}/api/admin/claude-sessions" 2>/dev/null || echo 000)
|
|
432
|
-
|
|
433
|
-
RES_BODY=$(cat "$SPAWN_RES_FILE" 2>/dev/null || true)
|
|
434
|
-
rm -f "$SPAWN_RES_FILE"
|
|
435
|
-
|
|
436
|
-
if [ "$SPAWN_HTTP" -lt 200 ] || [ "$SPAWN_HTTP" -ge 300 ]; then
|
|
437
|
-
ERR_SHORT=$(printf '%s' "$RES_BODY" | tr -d '\r\n' | cut -c1-200)
|
|
438
|
-
emit_log "spawn-failed sessionId=${ADMIN_SESSION_ID} reason=loopback-http http=${SPAWN_HTTP} err=$(python3 -c 'import sys,json; print(json.dumps(sys.argv[1]))' "${ERR_SHORT}")"
|
|
439
|
-
fi
|
|
440
|
-
|
|
441
|
-
exit 0
|