@rubytech/create-maxy-code 0.1.49 → 0.1.51
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 +54 -55
- package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +40 -47
- package/payload/platform/plugins/docs/PLUGIN.md +3 -1
- package/payload/platform/plugins/docs/references/admin-session.md +10 -0
- package/payload/platform/plugins/docs/references/internals.md +4 -0
- package/payload/platform/plugins/docs/references/platform.md +1 -1
- package/payload/platform/plugins/loop/PLUGIN.md +24 -0
- package/payload/platform/plugins/loop/mcp/src/__tests__/plugin-md-time-doctrine.test.ts +132 -0
- package/payload/platform/plugins/tasks/mcp/dist/index.js +1 -2
- package/payload/platform/plugins/tasks/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/tasks/mcp/dist/tools/session-name.d.ts.map +1 -1
- package/payload/platform/plugins/tasks/mcp/dist/tools/session-name.js +3 -2
- package/payload/platform/plugins/tasks/mcp/dist/tools/session-name.js.map +1 -1
- 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 +17 -2
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/premium-plugins/real-agent/plugins/loop/PLUGIN.md +24 -0
- package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/__tests__/plugin-md-time-doctrine.test.ts +132 -0
- package/payload/server/{chunk-MEGIQYUD.js → chunk-CWI7NOWK.js} +1 -1
- package/payload/server/{chunk-XJVJ2B2Q.js → chunk-NPZKH5EC.js} +1 -1
- package/payload/server/{chunk-4P2MPERV.js → chunk-X3Y6ENKA.js} +0 -90
- package/payload/server/{cloudflare-task-tracker-R6MMORFU.js → cloudflare-task-tracker-TL6R364J.js} +2 -2
- package/payload/server/maxy-edge.js +2 -2
- package/payload/server/public/assets/{admin-DBuCWSPS.js → admin-BbcN8otj.js} +1 -1
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-Ch-JZ1Q0.js → architectureDiagram-Q4EWVU46-DGHlapUl.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-C08tkD4C.js → blockDiagram-DXYQGD6D-Dgnf9J14.js} +1 -1
- package/payload/server/public/assets/{brand-DCdOhh-I.css → brand-CtilcX3W.css} +1 -1
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-IceRdWw3.js → c4Diagram-AHTNJAMY-GBVmghhJ.js} +1 -1
- package/payload/server/public/assets/channel-p5ZCfK_I.js +1 -0
- package/payload/server/public/assets/{chunk-336JU56O-Cey0rxmH.js → chunk-336JU56O-CSVC_J3A.js} +2 -2
- package/payload/server/public/assets/{chunk-426QAEUC-Dzk8s0or.js → chunk-426QAEUC-CK1Z0942.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-C10nuKc8.js → chunk-4TB4RGXK-CaqJYLxL.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-B1B8871D.js → chunk-5FUZZQ4R-57BmPy_B.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-jvHIkgqP.js → chunk-5PVQY5BW-Ud03uYx_.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-7nDxvXHS.js → chunk-EDXVE4YY-p5KO0PoG.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-C8lJ1YYn.js → chunk-ENJZ2VHE-CkxgLujt.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-CT1d7veI.js → chunk-ICPOFSXX-DwMkY8p7.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-LTAfwxv0.js → chunk-OYMX7WX6-CyYOUKBw.js} +1 -1
- package/payload/server/public/assets/{chunk-U2HBQHQK-RoGrqrH0.js → chunk-U2HBQHQK-Dk5rfJM6.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-D89ZBg3z.js → chunk-X2U36JSP-D0qCg9Nq.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-Cyg122Hf.js → chunk-YZCP3GAM-C0uFHrdT.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-Dg59YYuG.js → chunk-ZZ45TVLE-C7lSyGRs.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DqDM1D4-.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DeYvPD8H.js +1 -0
- package/payload/server/public/assets/clone-60k82qeh.js +1 -0
- package/payload/server/public/assets/{dagre-BPzUsxie.js → dagre-BWZ4wr8y.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-CjDNXqPy.js → dagre-KV5264BT-C7mpqRpw.js} +1 -1
- package/payload/server/public/assets/data-oXs84UW6.js +1 -0
- package/payload/server/public/assets/{device-url-actions-B588ZlnE.js → device-url-actions-347cMDY4.js} +1 -1
- package/payload/server/public/assets/{diagram-5BDNPKRD-Bqq0vqWM.js → diagram-5BDNPKRD-BcQRB7_3.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-DSqocDhm.js → diagram-G4DWMVQ6-BLSGUKbc.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-DZRWgKRL.js → diagram-MMDJMWI5-Ra1VvGJh.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-DXRm-FXu.js → diagram-TYMM5635-Bx_r1USn.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-CFVgkUCo.js → erDiagram-SMLLAGMA-CJhoN8dG.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-C82XCt9-.js → flowDiagram-DWJPFMVM-Bd5CZIoq.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-C4tnImlS.js → ganttDiagram-T4ZO3ILL-BNQJqJFh.js} +1 -1
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-DXHutP-a.js → gitGraphDiagram-UUTBAWPF-DoE1AFmA.js} +1 -1
- package/payload/server/public/assets/graph-DcpqWbSs.js +1 -0
- package/payload/server/public/assets/graph-labels-DJvuUODB.js +1 -0
- package/payload/server/public/assets/{graphlib-BvR5tL62.js → graphlib-D7JFfLSw.js} +1 -1
- package/payload/server/public/assets/{infoDiagram-42DDH7IO-DgFMqgZy.js → infoDiagram-42DDH7IO-BtsKRO-J.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-_5gmh1-V.js → ishikawaDiagram-UXIWVN3A-DapnDR0V.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-CNfDVgwI.js → journeyDiagram-VCZTEJTY-BLqfwMMu.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-Djifb_Z6.js → kanban-definition-6JOO6SKY-DqiSbvUW.js} +1 -1
- package/payload/server/public/assets/{line-0fRVrNiw.js → line-DcwBbbhp.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-DXA7LKDt.js → mermaid-parser.core-BZwNRInp.js} +1 -1
- package/payload/server/public/assets/{mermaid.core-BCfYtdMJ.js → mermaid.core-DnsyVInc.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-sTRvikWi.js → mindmap-definition-QFDTVHPH-MPPO8aDx.js} +1 -1
- package/payload/server/public/assets/{page-nAY64ID7.js → page-Cz_pVOv_.js} +1 -1
- package/payload/server/public/assets/{page-DDyny08O.js → page-pXt2BdOS.js} +1 -1
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-QLTieded.js → pieDiagram-DEJITSTG-BKzbjD_5.js} +1 -1
- package/payload/server/public/assets/{public-rUUducAl.js → public-C-0V5aC8.js} +3 -3
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-DOKCcqAZ.js → quadrantDiagram-34T5L4WZ-CvvvRvae.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-BX6Zpq1X.js → requirementDiagram-MS252O5E-BxXZA7z2.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-B05NdgZX.js → sankeyDiagram-XADWPNL6-BRRFjjao.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-CHF8ZdUb.js → sequenceDiagram-FGHM5R23-DOy_E8xe.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DTAt7Ytz.js → stateDiagram-FHFEXIEX-CDqat01p.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-DHckg-aj.js +1 -0
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-Dzd6X_ei.js → timeline-definition-GMOUNBTQ-AK1yuR8Z.js} +1 -1
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BAbM4QlQ.js → vennDiagram-DHZGUBPP-CCKJ7H16.js} +1 -1
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-CIOXXKJd.js → wardleyDiagram-NUSXRM2D-CKHKJnG_.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-oF9sZesS.js → xychartDiagram-5P7HB3ND-BBD35k9e.js} +1 -1
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +5 -5
- package/payload/server/public/index.html +7 -7
- package/payload/server/public/public.html +4 -4
- package/payload/server/server.js +97 -74
- package/payload/server/public/assets/channel-8QQB2Zoe.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-CViZOOjI.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-Dm7eGBqB.js +0 -1
- package/payload/server/public/assets/clone-BC8oes11.js +0 -1
- package/payload/server/public/assets/data-BeblX4m4.js +0 -1
- package/payload/server/public/assets/graph-ZuBiYril.js +0 -1
- package/payload/server/public/assets/graph-labels-CnbJR6cp.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-2bJIW7wn.js +0 -1
- /package/payload/server/public/assets/{brand-Dln107gD.js → brand-B6pMzF6h.js} +0 -0
package/package.json
CHANGED
|
@@ -105,7 +105,7 @@ Tools are available via the `admin` MCP server.
|
|
|
105
105
|
- `hooks/pre-tool-use.sh` — enforces admin agent write boundaries
|
|
106
106
|
- `hooks/playwright-file-guard.sh` — rewrites file:// URLs to a backgrounded loopback http.server before Playwright sees them
|
|
107
107
|
- `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
|
|
108
|
-
- `hooks/turn-completed-graph-write.sh` — Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_HIDDEN_SPAWN=0` so it never recurses into the hidden recorder PTY or fires on public sessions.
|
|
108
|
+
- `hooks/turn-completed-graph-write.sh` — Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_HIDDEN_SPAWN=0` so it never recurses into the hidden recorder PTY or fires on public sessions. Task 122 redesign: the hook makes ONE HTTP call to `POST /api/admin/claude-sessions/recorder-spawn` (loopback-only, `senderId='turn-recorder'`, bypasses admin-session auth on 127.0.0.1). The wrapper owns the spawn-with-initial-message sequence for every caller — Sidebar single-line stimulus and recorder multi-line instruction both flow through one function, so the multi-line submit path lives in one place instead of being re-implemented in bash. The manager's `POST /:id/input` wraps multi-line `text` in DEC bracketed-paste markers (`\e[200~ ... \e[201~\r`) so the TUI submits it as one turn; single-line text keeps the typed-keystroke path. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. Hidden rows do not surface in `/list` by default.
|
|
109
109
|
|
|
110
110
|
## Failure-report breadcrumbs
|
|
111
111
|
|
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Regression test for the Stop-hook gate that fires the database-operator
|
|
3
|
-
# per completed admin-agent turn
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# per completed admin-agent turn.
|
|
4
|
+
#
|
|
5
|
+
# Task 122 redesign: the hook routes through the admin wrapper's
|
|
6
|
+
# loopback-only recorder route (POST /api/admin/claude-sessions/recorder-spawn)
|
|
7
|
+
# in ONE HTTP call. The wrapper internally calls /spawn then /<id>/input on
|
|
8
|
+
# the claude-session-manager — the multi-line submit path is owned by the
|
|
9
|
+
# wrapper, not duplicated in bash.
|
|
6
10
|
#
|
|
7
11
|
# Behaviour verified:
|
|
8
12
|
# 1. MAXY_SESSION_ROLE!=admin → exit 0, no POST attempt, no log line.
|
|
9
13
|
# 2. MAXY_HIDDEN_SPAWN=1 → exit 0, no POST attempt, no log line.
|
|
10
14
|
# 3. Empty stdin → exit 0 silently.
|
|
11
15
|
# 4. Missing transcript_path → exit 0 silently.
|
|
12
|
-
# 5. Happy path → exactly ONE POST to
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
16
|
+
# 5. Happy path → exactly ONE POST to
|
|
17
|
+
# /api/admin/claude-sessions/recorder-spawn whose body carries
|
|
18
|
+
# senderId='turn-recorder', specialist='database-operator', and an
|
|
19
|
+
# initialMessage containing the Task 114 instruction + the filtered
|
|
20
|
+
# transcript. The hook still emits the
|
|
21
|
+
# `[turn-recorder] spawn-with-input sessionId=<8> bytes=<n>
|
|
22
|
+
# filtered-from-records=<n> input-http=200` log envelope unchanged
|
|
23
|
+
# (Task 122 keeps the envelope so existing greps survive).
|
|
17
24
|
|
|
18
25
|
set -u
|
|
19
26
|
|
|
@@ -50,7 +57,7 @@ run_hook() {
|
|
|
50
57
|
printf '%s' "$stdin_json" | \
|
|
51
58
|
MAXY_SESSION_ROLE="$role" \
|
|
52
59
|
MAXY_HIDDEN_SPAWN="$hidden" \
|
|
53
|
-
|
|
60
|
+
MAXY_UI_INTERNAL_PORT="${UI_PORT:-19199}" \
|
|
54
61
|
bash "$HOOK" >"$stdout_file" 2>"$stderr_file"
|
|
55
62
|
local rc=$?
|
|
56
63
|
HOOK_RC="$rc"
|
|
@@ -94,7 +101,7 @@ elif [[ -n "$HOOK_STDERR" ]]; then fail "case-4 expected empty stderr, got: $HOO
|
|
|
94
101
|
else pass "case-4 missing transcript_path → silent (rc=0)"
|
|
95
102
|
fi
|
|
96
103
|
|
|
97
|
-
# --- Case 5: happy path →
|
|
104
|
+
# --- Case 5: happy path → exactly ONE POST to /api/admin/claude-sessions/recorder-spawn
|
|
98
105
|
REQ_LOG=$(mktemp); TMPFILES+=("$REQ_LOG")
|
|
99
106
|
LISTENER_PORT=39406
|
|
100
107
|
python3 - "$LISTENER_PORT" "$REQ_LOG" <<'PY' &
|
|
@@ -111,7 +118,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
|
|
111
118
|
self.send_response(200)
|
|
112
119
|
self.send_header('Content-Type','application/json')
|
|
113
120
|
self.end_headers()
|
|
114
|
-
if self.path
|
|
121
|
+
if self.path.endswith('/recorder-spawn'):
|
|
115
122
|
self.wfile.write(json.dumps({"sessionId":"rec00001-feedfeed"}).encode('utf-8'))
|
|
116
123
|
else:
|
|
117
124
|
self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
|
|
@@ -130,9 +137,9 @@ done
|
|
|
130
137
|
# Clear the request log of the ping write.
|
|
131
138
|
: > "$REQ_LOG"
|
|
132
139
|
|
|
133
|
-
|
|
140
|
+
UI_PORT="$LISTENER_PORT"
|
|
134
141
|
run_hook "admin" "0" "$ENVELOPE"
|
|
135
|
-
unset
|
|
142
|
+
unset UI_PORT
|
|
136
143
|
|
|
137
144
|
# Give the listener time to flush.
|
|
138
145
|
sleep 0.1
|
|
@@ -150,69 +157,61 @@ fi
|
|
|
150
157
|
|
|
151
158
|
# Assert: no legacy spawn-with-stdin log line.
|
|
152
159
|
if echo "$HOOK_STDERR" | grep -q 'spawn-with-stdin'; then
|
|
153
|
-
fail "case-5b legacy spawn-with-stdin line must not be emitted
|
|
160
|
+
fail "case-5b legacy spawn-with-stdin line must not be emitted"
|
|
154
161
|
else
|
|
155
162
|
pass "case-5b legacy spawn-with-stdin log line absent"
|
|
156
163
|
fi
|
|
157
164
|
|
|
158
|
-
# Assert:
|
|
159
|
-
|
|
160
|
-
|
|
165
|
+
# Assert: exactly ONE POST to the wrapper recorder-spawn route, and no
|
|
166
|
+
# direct /spawn or /<id>/input POSTs (the wrapper-internal calls do not
|
|
167
|
+
# touch this test listener — the listener mocks the wrapper, not the
|
|
168
|
+
# manager).
|
|
169
|
+
RECORDER_LINES=$(grep -cE '^/api/admin/claude-sessions/recorder-spawn ' "$REQ_LOG" || true)
|
|
170
|
+
LEGACY_SPAWN_LINES=$(grep -cE '^/spawn ' "$REQ_LOG" || true)
|
|
171
|
+
LEGACY_INPUT_LINES=$(grep -cE '^/[A-Za-z0-9_-]+/input ' "$REQ_LOG" || true)
|
|
161
172
|
STOP_LINES=$(grep -cE '^/[A-Za-z0-9_-]+/stop ' "$REQ_LOG" || true)
|
|
162
|
-
if [[ "$
|
|
163
|
-
fail "case-5c expected exactly 1 /spawn POST, got $
|
|
173
|
+
if [[ "$RECORDER_LINES" -ne 1 ]]; then
|
|
174
|
+
fail "case-5c expected exactly 1 /api/admin/claude-sessions/recorder-spawn POST, got $RECORDER_LINES (log: $(cat "$REQ_LOG"))"
|
|
164
175
|
else
|
|
165
|
-
pass "case-5c exactly one
|
|
176
|
+
pass "case-5c exactly one recorder-spawn POST observed"
|
|
166
177
|
fi
|
|
167
|
-
if [[ "$
|
|
168
|
-
fail "case-5d
|
|
178
|
+
if [[ "$LEGACY_SPAWN_LINES" -ne 0 ]]; then
|
|
179
|
+
fail "case-5d hook must not POST /spawn directly any more, got $LEGACY_SPAWN_LINES"
|
|
169
180
|
else
|
|
170
|
-
pass "case-5d
|
|
181
|
+
pass "case-5d no direct /spawn POST"
|
|
171
182
|
fi
|
|
172
|
-
if [[ "$
|
|
173
|
-
fail "case-5e
|
|
183
|
+
if [[ "$LEGACY_INPUT_LINES" -ne 0 ]]; then
|
|
184
|
+
fail "case-5e hook must not POST /<id>/input directly any more, got $LEGACY_INPUT_LINES"
|
|
174
185
|
else
|
|
175
|
-
pass "case-5e
|
|
186
|
+
pass "case-5e no direct /<id>/input POST"
|
|
176
187
|
fi
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
SPAWN_BODY=$(grep '^/spawn ' "$REQ_LOG" | head -1 | cut -f2-)
|
|
180
|
-
if [[ -z "$SPAWN_BODY" ]]; then
|
|
181
|
-
fail "case-5f /spawn body was empty"
|
|
188
|
+
if [[ "$STOP_LINES" -ne 0 ]]; then
|
|
189
|
+
fail "case-5f hook must not POST /<id>/stop, got $STOP_LINES"
|
|
182
190
|
else
|
|
183
|
-
|
|
184
|
-
import sys, json
|
|
185
|
-
try:
|
|
186
|
-
b = json.load(sys.stdin)
|
|
187
|
-
print("yes" if "stdinPayload" in b else "no")
|
|
188
|
-
except Exception:
|
|
189
|
-
print("parse-fail")
|
|
190
|
-
' 2>/dev/null)
|
|
191
|
-
if [[ "$HAS_STDIN" != "no" ]]; then
|
|
192
|
-
fail "case-5f /spawn body must NOT carry stdinPayload, got: $SPAWN_BODY"
|
|
193
|
-
else
|
|
194
|
-
pass "case-5f /spawn body has no stdinPayload field"
|
|
195
|
-
fi
|
|
191
|
+
pass "case-5f no /<id>/stop POSTs"
|
|
196
192
|
fi
|
|
197
193
|
|
|
198
|
-
# Assert:
|
|
199
|
-
# transcript section header.
|
|
200
|
-
|
|
201
|
-
|
|
194
|
+
# Assert: recorder-spawn body carries senderId, specialist, and a multi-line
|
|
195
|
+
# initialMessage with the Task 114 instruction + transcript section header.
|
|
196
|
+
RECORDER_BODY=$(grep -E '^/api/admin/claude-sessions/recorder-spawn ' "$REQ_LOG" | head -1 | cut -f2-)
|
|
197
|
+
BODY_OK=$(printf '%s' "$RECORDER_BODY" | python3 -c '
|
|
202
198
|
import sys, json
|
|
203
199
|
try:
|
|
204
200
|
b = json.load(sys.stdin)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
201
|
+
senderOK = b.get("senderId") == "turn-recorder"
|
|
202
|
+
specOK = b.get("specialist") == "database-operator"
|
|
203
|
+
msg = b.get("initialMessage", "") or ""
|
|
204
|
+
hasInstr = "update the graph with any missing information or intent that can be derived from this conversation" in msg
|
|
205
|
+
hasTx = "Conversation transcript:" in msg
|
|
206
|
+
hasMultiline = "\n" in msg
|
|
207
|
+
print("yes" if (senderOK and specOK and hasInstr and hasTx and hasMultiline) else "no")
|
|
209
208
|
except Exception:
|
|
210
209
|
print("parse-fail")
|
|
211
210
|
' 2>/dev/null)
|
|
212
|
-
if [[ "$
|
|
213
|
-
fail "case-5g
|
|
211
|
+
if [[ "$BODY_OK" != "yes" ]]; then
|
|
212
|
+
fail "case-5g recorder-spawn body shape wrong: $RECORDER_BODY"
|
|
214
213
|
else
|
|
215
|
-
pass "case-5g
|
|
214
|
+
pass "case-5g recorder-spawn body carries senderId, specialist, multi-line initialMessage"
|
|
216
215
|
fi
|
|
217
216
|
|
|
218
217
|
# Assert: final fired log line still emitted.
|
|
@@ -4,16 +4,15 @@
|
|
|
4
4
|
# The hidden recorder is the only writer to the Neo4j graph; the admin agent
|
|
5
5
|
# stays focused on the operator's request.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# `stop_reason === "end_turn"`. The hook does not need to follow up.
|
|
7
|
+
# Task 122 redesign — the hook now makes ONE HTTP call to the admin
|
|
8
|
+
# wrapper's loopback recorder route (`POST /api/admin/claude-sessions/recorder-spawn`)
|
|
9
|
+
# instead of curling /spawn and /<id>/input directly against the
|
|
10
|
+
# claude-session-manager. The wrapper owns the spawn-with-initial-message
|
|
11
|
+
# sequence for every caller (Sidebar and recorder alike), which keeps the
|
|
12
|
+
# multi-line submit path in one place. The wrapper bypasses admin-session
|
|
13
|
+
# auth on this route because the request originates from 127.0.0.1 and
|
|
14
|
+
# the body carries `senderId: 'turn-recorder'` — same trust boundary the
|
|
15
|
+
# claude-session-manager itself relies on.
|
|
17
16
|
#
|
|
18
17
|
# Gating:
|
|
19
18
|
# - MAXY_SESSION_ROLE must equal "admin"
|
|
@@ -100,65 +99,59 @@ PY
|
|
|
100
99
|
|
|
101
100
|
FILTERED_COUNT=$(printf '%s' "$FILTERED_TAIL" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d) if isinstance(d,list) else 0)' 2>/dev/null || echo 0)
|
|
102
101
|
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
# UI server runs on MAXY_UI_INTERNAL_PORT (default 19199). The recorder
|
|
103
|
+
# route is loopback-only; no admin-session credentials are required.
|
|
104
|
+
UI_PORT="${MAXY_UI_INTERNAL_PORT:-19199}"
|
|
105
|
+
UI_BASE="http://127.0.0.1:${UI_PORT}"
|
|
105
106
|
|
|
107
|
+
# Compose the recorder spawn body in one shot. `initialMessage` carries
|
|
108
|
+
# the instruction + conversation id + filtered transcript as a single
|
|
109
|
+
# multi-line string; the wrapper hands it to manager POST /<id>/input,
|
|
110
|
+
# which now wraps multi-line text in bracketed-paste markers so the TUI
|
|
111
|
+
# submits it as one turn (Task 122 root cause).
|
|
106
112
|
SPAWN_BODY=$(python3 -c '
|
|
107
|
-
import json
|
|
113
|
+
import sys, json
|
|
114
|
+
sid = sys.argv[1]
|
|
115
|
+
tail_json = sys.argv[2]
|
|
116
|
+
instruction = "update the graph with any missing information or intent that can be derived from this conversation"
|
|
117
|
+
text = (
|
|
118
|
+
f"{instruction}\n\n"
|
|
119
|
+
f"Conversation id: {sid}\n"
|
|
120
|
+
f"Conversation transcript:\n{tail_json}\n"
|
|
121
|
+
)
|
|
108
122
|
body = {
|
|
109
123
|
"senderId": "turn-recorder",
|
|
110
|
-
"role": "admin",
|
|
111
|
-
"channel": "browser",
|
|
112
|
-
"hidden": True,
|
|
113
124
|
"specialist": "database-operator",
|
|
125
|
+
"initialMessage": text,
|
|
114
126
|
}
|
|
115
127
|
print(json.dumps(body))
|
|
116
|
-
' 2>/dev/null)
|
|
128
|
+
' "$ADMIN_SESSION_ID" "$FILTERED_TAIL" 2>/dev/null)
|
|
117
129
|
|
|
118
|
-
|
|
130
|
+
INPUT_BYTES=$(printf '%s' "$SPAWN_BODY" | python3 -c 'import sys,json; b=json.load(sys.stdin); print(len(b.get("initialMessage","").encode("utf-8")))' 2>/dev/null || echo 0)
|
|
131
|
+
|
|
132
|
+
SPAWN_RES_FILE=$(mktemp)
|
|
133
|
+
INPUT_HTTP=$(curl -sS -o "$SPAWN_RES_FILE" -w '%{http_code}' -X POST \
|
|
119
134
|
-H 'Content-Type: application/json' \
|
|
120
135
|
--max-time 10 \
|
|
121
136
|
--data "$SPAWN_BODY" \
|
|
122
|
-
"${
|
|
137
|
+
"${UI_BASE}/api/admin/claude-sessions/recorder-spawn" 2>/dev/null || echo 000)
|
|
123
138
|
|
|
124
|
-
RECORDER_SESSION_ID=$(
|
|
139
|
+
RECORDER_SESSION_ID=$(python3 -c '
|
|
125
140
|
import sys, json
|
|
126
141
|
try:
|
|
127
|
-
|
|
142
|
+
with open(sys.argv[1], "r", encoding="utf-8") as f:
|
|
143
|
+
d = json.load(f)
|
|
128
144
|
print(d.get("sessionId", "") or "")
|
|
129
145
|
except Exception:
|
|
130
146
|
print("")
|
|
131
|
-
' 2>/dev/null)
|
|
147
|
+
' "$SPAWN_RES_FILE" 2>/dev/null)
|
|
148
|
+
rm -f "$SPAWN_RES_FILE"
|
|
132
149
|
|
|
133
150
|
if [ -z "$RECORDER_SESSION_ID" ]; then
|
|
134
|
-
echo "turn-completed-graph-write spawn-failed conversationId=${ADMIN_SESSION_ID:0:8}" >&2
|
|
151
|
+
echo "turn-completed-graph-write spawn-failed conversationId=${ADMIN_SESSION_ID:0:8} http=${INPUT_HTTP}" >&2
|
|
135
152
|
exit 0
|
|
136
153
|
fi
|
|
137
154
|
|
|
138
|
-
# Compose the input prompt and POST it to /<sessionId>/input. The instruction
|
|
139
|
-
# string is fixed (Task 114). The transcript is appended as a JSON array so
|
|
140
|
-
# the recorder reads it as a single user turn.
|
|
141
|
-
INPUT_BODY=$(python3 -c '
|
|
142
|
-
import sys, json
|
|
143
|
-
sid = sys.argv[1]
|
|
144
|
-
tail_json = sys.argv[2]
|
|
145
|
-
instruction = "update the graph with any missing information or intent that can be derived from this conversation"
|
|
146
|
-
text = (
|
|
147
|
-
f"{instruction}\n\n"
|
|
148
|
-
f"Conversation id: {sid}\n"
|
|
149
|
-
f"Conversation transcript:\n{tail_json}\n"
|
|
150
|
-
)
|
|
151
|
-
print(json.dumps({"text": text}))
|
|
152
|
-
' "$ADMIN_SESSION_ID" "$FILTERED_TAIL" 2>/dev/null)
|
|
153
|
-
|
|
154
|
-
INPUT_BYTES=$(printf '%s' "$INPUT_BODY" | python3 -c 'import sys,json; b=json.load(sys.stdin); print(len(b.get("text","").encode("utf-8")))' 2>/dev/null || echo 0)
|
|
155
|
-
|
|
156
|
-
INPUT_HTTP=$(curl -sS -o /tmp/turn-recorder-input.out -w '%{http_code}' -X POST \
|
|
157
|
-
-H 'Content-Type: application/json' \
|
|
158
|
-
--max-time 10 \
|
|
159
|
-
--data "$INPUT_BODY" \
|
|
160
|
-
"${MANAGER_BASE}/${RECORDER_SESSION_ID}/input" 2>/dev/null || echo 000)
|
|
161
|
-
|
|
162
155
|
echo "[turn-recorder] spawn-with-input sessionId=${RECORDER_SESSION_ID:0:8} bytes=${INPUT_BYTES} filtered-from-records=${FILTERED_COUNT} input-http=${INPUT_HTTP}" >&2
|
|
163
156
|
|
|
164
157
|
END_MS=$(python3 -c 'import time; print(int(time.time()*1000))')
|
|
@@ -30,7 +30,8 @@ Load these when users ask about Maxy features or need guidance:
|
|
|
30
30
|
Load these when performing admin tasks or diagnosing platform behaviour:
|
|
31
31
|
|
|
32
32
|
- **Platform architecture** → `references/platform.md` — how the platform works, agent types, the plugin model
|
|
33
|
-
- **Platform internals** → `references/internals.md` — retrieval pipeline, embedding infrastructure, guard layers, query classification, memory-rank, graph expansion, keyword subscriptions, context assembly, inbound message screening,
|
|
33
|
+
- **Platform internals** → `references/internals.md` — retrieval pipeline, embedding infrastructure, guard layers, query classification, memory-rank, graph expansion, keyword subscriptions, context assembly, inbound message screening, tool call audit trail, programmatic spawn entry point and recorder auto-archive lifecycle. Load when answering architecture questions, assessing whether a capability exists, diagnosing retrieval behaviour, or reviewing security and privacy features.
|
|
34
|
+
- **Admin session** → `references/admin-session.md` — signed sessionKey + PIN-rebind survival contract, SDK-resume across `systemctl restart`, and the single-entry `POST /api/admin/claude-sessions` wrapper that owns every programmatic admin spawn-with-initialMessage. Load when diagnosing admin session continuity, designing a programmatic admin spawn, or reviewing the turn-recorder lifecycle.
|
|
34
35
|
- **Cloudflare** → `references/cloudflare.md` — dashboard-first tunnel setup, the `cloudflared`-CLI-only tool surface, single-recovery-path (re-login) for every wrong-account failure.
|
|
35
36
|
- **Deployment** → `references/deployment.md` — Pi setup, Cloudflare tunnel, start script
|
|
36
37
|
|
|
@@ -48,5 +49,6 @@ Load these when performing admin tasks or diagnosing platform behaviour:
|
|
|
48
49
|
- references/troubleshooting.md
|
|
49
50
|
- references/platform.md
|
|
50
51
|
- references/internals.md
|
|
52
|
+
- references/admin-session.md
|
|
51
53
|
- references/cloudflare.md
|
|
52
54
|
- references/deployment.md
|
|
@@ -73,6 +73,16 @@ grep -E '\[session-rehydrate-from-token\]|\[client-acquire\] reason=pin-rebind'
|
|
|
73
73
|
grep '\[client-acquire\]' ~/.${brand}/logs/claude-agent-stream-*.log
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
## Spawn-with-`initialMessage` wrapper
|
|
77
|
+
|
|
78
|
+
The Hono route `POST /api/admin/claude-sessions` at [`platform/ui/server/routes/admin/claude-sessions.ts`](../../../ui/server/routes/admin/claude-sessions.ts) is the single canonical entry point for any caller — UI click handler or programmatic — that needs to open an admin PTY session with a first user prompt. Direct calls to the session-manager's `/spawn` and `/:id/input` are not allowed; the wrapper owns the spawn-then-input ordering, the per-spawn enrichment (`aboutOwner`, `dormantPlugins`, `activePlugins`, `specialistDomains`, `tunnelUrl`), and the `senderId` resolution from the admin session cookie. The session manager binds to `127.0.0.1` only so the wrapper is the sole authorised caller; any ad-hoc spawn that bypasses it duplicates this contract and is a bug.
|
|
79
|
+
|
|
80
|
+
**Body schema.** `{channel?, permissionMode?, initialMessage?}` for operator-driven UI spawns. The turn-recorder hook adds `senderId: 'turn-recorder'`, `specialist`, and `hidden` so the row never surfaces in the sidebar (post-Task 122). The wrapper rejects nothing structurally — string fields are coerced or defaulted (`channel` → `'browser'`, `permissionMode` → undefined, `initialMessage` → null if empty/whitespace) and the session manager validates the rest downstream.
|
|
81
|
+
|
|
82
|
+
**Forwarded endpoints.** Two upstream calls per spawn-with-message: `POST {managerBase}/spawn` (enriched body) returns `{sessionId}`; when `initialMessage` is set, the wrapper then issues `POST {managerBase}/<sessionId>/input { text: <initialMessage> }` fire-and-forget so the PTY has its first turn stimulus before the operator types. The HTTP response streams the spawn upstream body straight through.
|
|
83
|
+
|
|
84
|
+
**Callers.** `Sidebar.tsx`'s "+ New session" click handler (sends the current ISO-8601 UTC timestamp as `initialMessage` — see `platform.md` "Spawn lifecycle: PID-file driven"); the turn-recorder hook post-Task 122; future programmatic spawns. Every caller routes here. The on-the-wire signal that the contract held is the `[claude-session-manager:wrapper] spawn-request-in` log line followed by `forward-spawn-done`.
|
|
85
|
+
|
|
76
86
|
## Out of scope
|
|
77
87
|
|
|
78
88
|
- Public/WhatsApp/Telegram sessions — out of scope. Their sessionKeys remain `crypto.randomUUID()` and follow the existing rejection-on-restart contract.
|
|
@@ -468,6 +468,10 @@ A boot gate refuses to start the manager when any admin-allowlisted tool `mcp__<
|
|
|
468
468
|
|
|
469
469
|
**Per-spawn signals (server.log).** Every spawn emits `pty-spawn-mcp-config servers=<N> tools=<M> bytes=<B> path=<…>` once, plus one `pty-spawn-agents-dir role=<admin|public> path=<…>` per added directory. The diagnostic one-liner is `grep -E 'pty-spawn-mcp-config|pty-spawn-agents-dir|mcp-config-allowlist-coverage|boot-failed reason=mcp-allowlist' ~/.<brand>/logs/server.log | tail -50`.
|
|
470
470
|
|
|
471
|
+
**Programmatic spawn entry point.** Every admin PTY spawn that needs a first user prompt — UI click, turn-recorder hook, future automation — routes through the single wrapper at [`platform/ui/server/routes/admin/claude-sessions.ts`](../../../ui/server/routes/admin/claude-sessions.ts). The wrapper owns the spawn → input ordering, the per-spawn enrichment (owner profile, dormant/active plugins, specialist domains, tunnel URL), and the `senderId` resolution; it forwards to the session manager's `POST /spawn` then `POST /<sessionId>/input` on `127.0.0.1`. No caller composes those two manager endpoints directly. The recorder branch sets `senderId: 'turn-recorder'`. See `admin-session.md` "Spawn-with-initialMessage wrapper" for the body schema and caller list.
|
|
472
|
+
|
|
473
|
+
**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.
|
|
474
|
+
|
|
471
475
|
## Tool Call Audit Trail
|
|
472
476
|
|
|
473
477
|
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.
|
|
@@ -80,7 +80,7 @@ The row feed sits behind `requireAdminSession` like every other admin route, so
|
|
|
80
80
|
|
|
81
81
|
The trade-off is a longer-lived connection per tab: the manager's per-process subscriber count rises with open tabs, and the SSE channel must survive proxy idle timeouts. The manager emits a 25-second keep-alive comment line on every connection (ignored by EventSource consumers, refreshes the proxy clock) and the browser-side store force-closes-and-reconnects on transport errors with exponential backoff capped at 30s.
|
|
82
82
|
|
|
83
|
-
**Spawn lifecycle: PID-file driven.** Clicking "+ New session" spawns the PTY and waits for Claude Code's PID file to appear at `${CLAUDE_CONFIG_DIR}/sessions/<pid>.json`. The PID file lands at process init (for `entrypoint: cli` spawns) and carries the intrinsic `sessionId`, `bridgeSessionId`, `agent`, and `status` directly. The manager's filesystem watcher reports the create event; the spawn response includes the canonical `sessionId` from that file. URL capture still runs in parallel to populate the operator-facing iframe URL, but it no longer gates readiness. The JSONL transcript is written on the first operator turn (true on 2.1.143 and 2.1.128); the watcher fires a separate event for that, and `/list`, `/meta`, `/log` resolve any of four ids — `sessionId`, `bridgeSessionId`, `bridgeSuffix`, or numeric `pid` — to the same row. Every fresh spawn also injects a first user turn so the agent has the stimulus the CLI needs to emit its first reply: the Sidebar's POST body carries `initialMessage` set to the current ISO-8601 UTC timestamp (e.g. `2026-05-18T13:15:45.398Z`), computed at click-time on the client. The server forwards it as `POST /<id>/input { text }` immediately after the spawn returns. The timestamp is the minimum-information opener — it has no instructional meaning, so the agent reads it as "begin", not as a directive to do something. Resume flows are unaffected (the prior transcript is the stimulus). Out of scope here: the *content* of the agent's reply, which is owned by the orchestrator's greeting rules.
|
|
83
|
+
**Spawn lifecycle: PID-file driven.** Clicking "+ New session" spawns the PTY and waits for Claude Code's PID file to appear at `${CLAUDE_CONFIG_DIR}/sessions/<pid>.json`. The PID file lands at process init (for `entrypoint: cli` spawns) and carries the intrinsic `sessionId`, `bridgeSessionId`, `agent`, and `status` directly. The manager's filesystem watcher reports the create event; the spawn response includes the canonical `sessionId` from that file. URL capture still runs in parallel to populate the operator-facing iframe URL, but it no longer gates readiness. The JSONL transcript is written on the first operator turn (true on 2.1.143 and 2.1.128); the watcher fires a separate event for that, and `/list`, `/meta`, `/log` resolve any of four ids — `sessionId`, `bridgeSessionId`, `bridgeSuffix`, or numeric `pid` — to the same row. Every fresh spawn also injects a first user turn so the agent has the stimulus the CLI needs to emit its first reply: the Sidebar's POST body carries `initialMessage` set to the current ISO-8601 UTC timestamp (e.g. `2026-05-18T13:15:45.398Z`), computed at click-time on the client. The server forwards it as `POST /<id>/input { text }` immediately after the spawn returns. The wrapper at `platform/ui/server/routes/admin/claude-sessions.ts` is the single canonical entry point for any programmatic admin spawn-with-prompt — see `admin-session.md` "Spawn-with-initialMessage wrapper" and `internals.md` "Programmatic spawn entry point". The timestamp is the minimum-information opener — it has no instructional meaning, so the agent reads it as "begin", not as a directive to do something. Resume flows are unaffected (the prior transcript is the stimulus). Out of scope here: the *content* of the agent's reply, which is owned by the orchestrator's greeting rules.
|
|
84
84
|
|
|
85
85
|
**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 + sidecar + 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.
|
|
86
86
|
|
|
@@ -174,6 +174,30 @@ Loop covers five value pillars with API surface. Each pillar has both read and w
|
|
|
174
174
|
- `loop-customer-preferences` (action=write)
|
|
175
175
|
- `loop-supplier` (action=maintenance-complete, board-complete, maintenance-submit-quote)
|
|
176
176
|
|
|
177
|
+
## Time presentation
|
|
178
|
+
|
|
179
|
+
**Every datetime field in a Loop response is UTC, returned without a timezone marker.** The Loop V2 API stores and returns instants as bare `YYYY-MM-DDTHH:MM:SS` strings whose true zone is UTC. Presenting them verbatim is silent misrepresentation: a viewing whose `dateOfAppointment` is `12:00:00` is `12:00Z`, which is `13:00 BST` between late March and late October in the UK. Off-by-one-hour viewing times have already misled an operator (Task 126).
|
|
180
|
+
|
|
181
|
+
**Convert before any datetime appears in agent output.** The operator's zone comes from the `Person.timezone` property on the operator's `Person` node; Real Agent UK accounts default to `Europe/London` when that property is absent. The conversion is non-negotiable — there is no case where a Loop datetime is presented to the operator in its raw UTC form. State the converted local time, and where ambiguous (cross-DST scheduling, calendar invites), name the zone explicitly (`13:00 BST`, `09:30 GMT`).
|
|
182
|
+
|
|
183
|
+
The tools whose responses carry one or more datetime fields — and therefore require this conversion before any field value reaches the operator — are exhaustive:
|
|
184
|
+
|
|
185
|
+
- `loop-viewing-search` — `dateOfAppointment`, `dateOfAppointmentEnd`
|
|
186
|
+
- `loop-viewing-detail` — `dateCreated`, `dateOfAppointment`, `dateOfAppointmentEnd`, `dateBuyerFeedbackGiven`, `dateSellerFeedbackGiven`, `dateDone`
|
|
187
|
+
- `loop-feedback-get` — `viewingDateOfAppointment`
|
|
188
|
+
- `loop-people-search` — renters list: `dateRequired`
|
|
189
|
+
- `loop-people-detail` — `dateCreated`; nested `viewings[].dateOfAppointment(+End)`, `offers[].dateReceived`, `applications[].dateReceived`, `applications[].proposedStartDate`, `property.dateCreated` (sellers/landlords), `dateRequired` (renters)
|
|
190
|
+
- `loop-property-search` — `dateCreated`
|
|
191
|
+
- `loop-property-detail` — `dateCreated`, `dateInstructed`, `dateUnderOffer`, `dateLaunched`, `dateExchanged`, `dateCompleted`, `dateWithdrawn`, `dateFirstAvailable`, `leaseExpiryDate`
|
|
192
|
+
- `loop-property-listed` — `dateLaunched`, `dateFirstAvailable`, `leaseExpiryDate`
|
|
193
|
+
- `loop-property-sold` — `dateLaunched`, `leaseExpiryDate`, `images[].dateUpdated`
|
|
194
|
+
- `loop-marketing-match` — `dateCreated`, `dateInstructed`, `dateUnderOffer`, `dateLaunched`, `dateExchanged`, `dateCompleted`, `dateWithdrawn`, `dateFirstAvailable`, `viewings[].dateOfAppointment(+End)`, `applications[].dateReceived`, `applications[].proposedStartDate`, `marketListings[].dateListed`, `images[].dateUpdated`
|
|
195
|
+
- `loop-auto-responder` — `enquiry.dateMoving`
|
|
196
|
+
- `loop-team-availability` — `start`, `end`
|
|
197
|
+
- `loop-supplier` — `dateCreated`, `dateDone`, `dateReported`, `dateQuoteBy`, `dateCompleted`, `dateApproved`
|
|
198
|
+
|
|
199
|
+
Tools not listed (`loop-key-*`, `loop-team-info`, `loop-customer-preferences`, `loop-marketing-enquiry`, `loop-marketing-match-batch`, `loop-marketing-match-request`, `loop-viewing-create`, `loop-viewing-update`, `loop-feedback-submit`, `loop-property-viewing`, `loop-property-callback`, `loop-property-information`) either have no datetime in response or are write-only acknowledgements. The enumeration is grounded in the vendored swagger snapshot at `mcp/src/__tests__/loop-swagger.snapshot.json`; the pinning test at `mcp/src/__tests__/plugin-md-time-doctrine.test.ts` blocks silent drift.
|
|
200
|
+
|
|
177
201
|
### Team-key prerequisite
|
|
178
202
|
|
|
179
203
|
Every Loop tool routes through one or more team keys. If `loop-key-list` returns nothing for the account, surface the registration prompt before attempting any other Loop call — every downstream tool will fail otherwise. If a call returns `Team "X" does not have <group> permission`, the key was scoped to fewer than 8 endpoint groups; re-register with the full permissions array (`["properties","people","viewings","feedback","team","marketing","customer","supplier"]`).
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anti-regression: PLUGIN.md must carry the "Time presentation" doctrine
|
|
3
|
+
* paragraph and enumerate every Loop tool whose response contains a
|
|
4
|
+
* datetime field. The doctrine is the agent's only signal that Loop
|
|
5
|
+
* returns bare UTC strings without a zone marker — silent drop of this
|
|
6
|
+
* section recurs the off-by-one-hour viewing-time failure (Task 126).
|
|
7
|
+
*
|
|
8
|
+
* The expected tool list is derived live from the vendored swagger
|
|
9
|
+
* snapshot, so if Loop adds a new datetime-bearing endpoint and we miss
|
|
10
|
+
* mapping it in PLUGIN.md, the test fails and names the orphan.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { resolve, dirname } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const PLUGIN_MD = resolve(__dirname, '../../../PLUGIN.md');
|
|
20
|
+
const SWAGGER_PATH = resolve(__dirname, 'loop-swagger.snapshot.json');
|
|
21
|
+
|
|
22
|
+
interface SwaggerDoc {
|
|
23
|
+
paths: Record<string, Record<string, { responses?: Record<string, { content?: Record<string, { schema?: unknown }> }> }>>;
|
|
24
|
+
components?: { schemas?: Record<string, unknown> };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const swagger = JSON.parse(readFileSync(SWAGGER_PATH, 'utf-8')) as SwaggerDoc;
|
|
28
|
+
const plugin = readFileSync(PLUGIN_MD, 'utf-8');
|
|
29
|
+
|
|
30
|
+
// Swagger path → tool name. Every response-bearing GET path that surfaces
|
|
31
|
+
// a datetime field must map to exactly one tool here. Add a row when Loop
|
|
32
|
+
// publishes a new datetime endpoint that we expose.
|
|
33
|
+
const PATH_TO_TOOL: Record<string, string> = {
|
|
34
|
+
'/residential/sales/viewings': 'loop-viewing-search',
|
|
35
|
+
'/residential/lettings/viewings': 'loop-viewing-search',
|
|
36
|
+
'/residential/sales/viewings/{id}': 'loop-viewing-detail',
|
|
37
|
+
'/residential/lettings/viewings/{id}': 'loop-viewing-detail',
|
|
38
|
+
'/feedback/residential/sales/viewings/{id}': 'loop-feedback-get',
|
|
39
|
+
'/feedback/residential/lettings/viewings/{id}': 'loop-feedback-get',
|
|
40
|
+
'/people/renters': 'loop-people-search',
|
|
41
|
+
'/people/{id}': 'loop-people-detail',
|
|
42
|
+
'/people/buyers/{id}': 'loop-people-detail',
|
|
43
|
+
'/people/sellers/{id}': 'loop-people-detail',
|
|
44
|
+
'/people/landlords/{id}': 'loop-people-detail',
|
|
45
|
+
'/people/renters/{id}': 'loop-people-detail',
|
|
46
|
+
'/property/residential/sales/{id}': 'loop-property-detail',
|
|
47
|
+
'/property/residential/lettings/{id}': 'loop-property-detail',
|
|
48
|
+
'/property/residential/sales': 'loop-property-search',
|
|
49
|
+
'/property/residential/lettings': 'loop-property-search',
|
|
50
|
+
'/property/residential/sales/listed/{channel}': 'loop-property-listed',
|
|
51
|
+
'/property/residential/lettings/listed/{channel}': 'loop-property-listed',
|
|
52
|
+
'/property/residential/sales/{id}/preview/{previewHash}': 'loop-property-detail',
|
|
53
|
+
'/property/residential/lettings/{id}/preview/{previewHash}': 'loop-property-detail',
|
|
54
|
+
'/property/residential/sold/{channel}': 'loop-property-sold',
|
|
55
|
+
'/marketing/matching/{id}': 'loop-marketing-match',
|
|
56
|
+
'/marketing/rentals/matching/{id}': 'loop-marketing-match',
|
|
57
|
+
'/marketing/enquiries/auto-responder/{id}/{key}': 'loop-auto-responder',
|
|
58
|
+
'/team/{agentId}/availability/{searchDate}': 'loop-team-availability',
|
|
59
|
+
'/supplier/board-contractor/{code}/{jobid}/job-list': 'loop-supplier',
|
|
60
|
+
'/supplier/maintenance/{quoteCode}/{jobid}/job-list': 'loop-supplier',
|
|
61
|
+
'/supplier/maintenance/{quoteCode}/{jobId}/quote-list': 'loop-supplier',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function resolveRef(ref: string): unknown {
|
|
65
|
+
const parts = ref.replace('#/', '').split('/');
|
|
66
|
+
let cur: unknown = swagger;
|
|
67
|
+
for (const p of parts) cur = cur && typeof cur === 'object' ? (cur as Record<string, unknown>)[p] : null;
|
|
68
|
+
return cur;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasDateField(node: unknown, depth = 0, visited = new WeakSet<object>()): boolean {
|
|
72
|
+
if (!node || typeof node !== 'object' || depth > 12) return false;
|
|
73
|
+
if (visited.has(node as object)) return false;
|
|
74
|
+
visited.add(node as object);
|
|
75
|
+
const n = node as Record<string, unknown>;
|
|
76
|
+
if (typeof n.$ref === 'string') return hasDateField(resolveRef(n.$ref), depth + 1, visited);
|
|
77
|
+
if (n.format === 'date-time' || n.format === 'date') return true;
|
|
78
|
+
if (n.properties && typeof n.properties === 'object') {
|
|
79
|
+
for (const v of Object.values(n.properties as Record<string, unknown>)) {
|
|
80
|
+
if (hasDateField(v, depth + 1, visited)) return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (n.items && hasDateField(n.items, depth + 1, visited)) return true;
|
|
84
|
+
if (Array.isArray(n.allOf)) {
|
|
85
|
+
for (const v of n.allOf) if (hasDateField(v, depth + 1, visited)) return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function toolsRequiringDoctrine(): Set<string> {
|
|
91
|
+
const tools = new Set<string>();
|
|
92
|
+
for (const [path, methods] of Object.entries(swagger.paths)) {
|
|
93
|
+
const op = methods.get;
|
|
94
|
+
if (!op || !op.responses) continue;
|
|
95
|
+
const anyDate = Object.values(op.responses).some(r =>
|
|
96
|
+
Object.values(r.content || {}).some(sw => hasDateField(sw.schema)),
|
|
97
|
+
);
|
|
98
|
+
if (!anyDate) continue;
|
|
99
|
+
const tool = PATH_TO_TOOL[path];
|
|
100
|
+
if (tool) tools.add(tool);
|
|
101
|
+
else throw new Error(`Swagger GET ${path} returns a datetime but has no PATH_TO_TOOL mapping — add it and list the tool in PLUGIN.md "Time presentation".`);
|
|
102
|
+
}
|
|
103
|
+
return tools;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('PLUGIN.md time-presentation doctrine', () => {
|
|
107
|
+
it('contains the Time presentation section', () => {
|
|
108
|
+
expect(plugin).toMatch(/^## Time presentation$/m);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('names UTC, Person.timezone, and the Europe/London default', () => {
|
|
112
|
+
expect(plugin).toMatch(/UTC/);
|
|
113
|
+
expect(plugin).toMatch(/Person\.timezone/);
|
|
114
|
+
expect(plugin).toMatch(/Europe\/London/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('forbids raw UTC presentation', () => {
|
|
118
|
+
// Must state conversion is required before any datetime appears in output.
|
|
119
|
+
expect(plugin).toMatch(/Convert before any datetime appears in agent output/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('enumerates every tool whose Loop response carries a datetime field', () => {
|
|
123
|
+
const required = toolsRequiringDoctrine();
|
|
124
|
+
const missing: string[] = [];
|
|
125
|
+
for (const tool of required) {
|
|
126
|
+
// Each tool must appear in the doctrine section as a bullet item.
|
|
127
|
+
const re = new RegExp(`^- \`${tool.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}\``, 'm');
|
|
128
|
+
if (!re.test(plugin)) missing.push(tool);
|
|
129
|
+
}
|
|
130
|
+
expect(missing, `PLUGIN.md "Time presentation" must list: ${missing.join(', ')}`).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -300,8 +300,7 @@ eagerTool(server, "session-name", "Set a human-readable name on the current conv
|
|
|
300
300
|
// created (deferred persistence). Surface the deferral
|
|
301
301
|
// so the silent no-op is observable to the agent. The agent should
|
|
302
302
|
// re-call session-name on a later turn (post-flush) if the chosen
|
|
303
|
-
// title needs to land
|
|
304
|
-
// (gated by message usefulness), and a re-call overrides it.
|
|
303
|
+
// title needs to land.
|
|
305
304
|
const text = affected > 0
|
|
306
305
|
? `Session named: "${params.name}"`
|
|
307
306
|
: `Session not yet bound — Conversation will be created at the first completed turn. Re-call session-name on a later turn to apply this title.`;
|