@rubytech/create-maxy-code 0.1.44 → 0.1.45
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/hooks/__tests__/turn-completed-graph-write.test.sh +122 -20
- package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +40 -78
- package/payload/platform/plugins/docs/references/platform.md +5 -3
- 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 +18 -2
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +5 -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 +17 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +3 -11
- package/payload/server/public/assets/{admin-GOZURixP.js → admin-DVZcLuYu.js} +2 -2
- package/payload/server/public/assets/{data-CU9uH2JJ.js → data-BxpOR0eM.js} +1 -1
- package/payload/server/public/assets/{graph-BI7yVwmT.js → graph-CrmxRSeb.js} +1 -1
- package/payload/server/public/assets/graph-labels-C8A6BmtP.js +1 -0
- package/payload/server/public/assets/{page-DKdUMAhI.js → page-D5wAeFU0.js} +1 -1
- package/payload/server/public/assets/{page-BaRMnC96.js → page-DR5NU6S0.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/public/assets/graph-labels-bI0uSt_-.js +0 -1
package/package.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
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.
|
|
3
|
+
# per completed admin-agent turn (Task 106 redesign: headless one-shot).
|
|
4
4
|
#
|
|
5
5
|
# Behaviour verified:
|
|
6
6
|
# 1. MAXY_SESSION_ROLE!=admin → exit 0, no POST attempt, no log line.
|
|
7
|
-
# 2. MAXY_HIDDEN_SPAWN=1 → exit 0, no POST attempt, no log line
|
|
7
|
+
# 2. MAXY_HIDDEN_SPAWN=1 → exit 0, no POST attempt, no log line
|
|
8
|
+
# (the Stop-hook self-stop path was removed in Task 106; the recorder
|
|
9
|
+
# now exits when its stdin closes, not via a /<id>/stop POST).
|
|
8
10
|
# 3. Empty stdin → exit 0 silently.
|
|
9
11
|
# 4. Missing transcript_path → exit 0 silently.
|
|
10
|
-
# 5. Happy path → POST
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# listener that records the request body. Each case uses a separate port
|
|
15
|
-
# so concurrent execution does not interfere.
|
|
12
|
+
# 5. Happy path → ONE POST to /spawn whose body contains
|
|
13
|
+
# `"stdinPayload"`, ZERO POSTs to /<id>/input, and the new
|
|
14
|
+
# `[recorder] spawn-with-stdin sessionId=<8> bytes=<n> filtered-from-records=<n>`
|
|
15
|
+
# log line.
|
|
16
16
|
|
|
17
17
|
set -u
|
|
18
18
|
|
|
@@ -23,7 +23,14 @@ if [[ ! -x "$HOOK" ]]; then
|
|
|
23
23
|
fi
|
|
24
24
|
|
|
25
25
|
TMPFILES=()
|
|
26
|
+
LISTENER_PIDS=()
|
|
26
27
|
cleanup_test_state() {
|
|
28
|
+
for pid in "${LISTENER_PIDS[@]:-}"; do
|
|
29
|
+
if [[ -n "$pid" ]]; then
|
|
30
|
+
kill "$pid" 2>/dev/null || true
|
|
31
|
+
wait "$pid" 2>/dev/null || true
|
|
32
|
+
fi
|
|
33
|
+
done
|
|
27
34
|
for f in "${TMPFILES[@]:-}"; do
|
|
28
35
|
[[ -n "$f" ]] && rm -f "$f" 2>/dev/null || true
|
|
29
36
|
done
|
|
@@ -35,7 +42,6 @@ FAIL=0
|
|
|
35
42
|
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
|
|
36
43
|
fail() { echo "FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
|
|
37
44
|
|
|
38
|
-
# Helper: run hook with a stdin envelope + env, capture stderr + exit code.
|
|
39
45
|
run_hook() {
|
|
40
46
|
local role="$1"; local hidden="$2"; local stdin_json="$3"
|
|
41
47
|
local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
|
|
@@ -65,11 +71,11 @@ elif [[ -n "$HOOK_STDERR" ]]; then fail "case-1 expected empty stderr, got: $HOO
|
|
|
65
71
|
else pass "case-1 role=public → no fire (rc=0, stderr empty)"
|
|
66
72
|
fi
|
|
67
73
|
|
|
68
|
-
# --- Case 2: hidden=1 → no fire
|
|
74
|
+
# --- Case 2: hidden=1 → no fire (Task 106 removed self-stop) -----------
|
|
69
75
|
run_hook "admin" "1" "$ENVELOPE"
|
|
70
76
|
if [[ "$HOOK_RC" -ne 0 ]]; then fail "case-2 expected rc=0 got=$HOOK_RC"
|
|
71
|
-
elif [[ -n "$HOOK_STDERR" ]]; then fail "case-2 expected empty stderr, got: $HOOK_STDERR"
|
|
72
|
-
else pass "case-2 hidden=1 →
|
|
77
|
+
elif [[ -n "$HOOK_STDERR" ]]; then fail "case-2 expected empty stderr (no self-stop POST in Task 106), got: $HOOK_STDERR"
|
|
78
|
+
else pass "case-2 hidden=1 → silent (no self-stop POST)"
|
|
73
79
|
fi
|
|
74
80
|
|
|
75
81
|
# --- Case 3: empty stdin → silent --------------------------------------
|
|
@@ -87,19 +93,115 @@ elif [[ -n "$HOOK_STDERR" ]]; then fail "case-4 expected empty stderr, got: $HOO
|
|
|
87
93
|
else pass "case-4 missing transcript_path → silent (rc=0)"
|
|
88
94
|
fi
|
|
89
95
|
|
|
90
|
-
# --- Case 5: happy path → POST
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
|
|
96
|
+
# --- Case 5: happy path → /spawn POST carries stdinPayload, NO /input POST
|
|
97
|
+
# A tiny python HTTP listener records every request body/path. It accepts
|
|
98
|
+
# /spawn and returns a JSON body containing a recorder sessionId. If the
|
|
99
|
+
# hook makes a second POST to /<id>/input the listener records that path
|
|
100
|
+
# too — the test fails if any /input path appears in the request log.
|
|
101
|
+
REQ_LOG=$(mktemp); TMPFILES+=("$REQ_LOG")
|
|
102
|
+
LISTENER_PORT=39406
|
|
103
|
+
python3 - "$LISTENER_PORT" "$REQ_LOG" <<'PY' &
|
|
104
|
+
import sys, http.server, json
|
|
105
|
+
port = int(sys.argv[1])
|
|
106
|
+
log_path = sys.argv[2]
|
|
107
|
+
class H(http.server.BaseHTTPRequestHandler):
|
|
108
|
+
def log_message(self, *a, **k): pass
|
|
109
|
+
def do_POST(self):
|
|
110
|
+
n = int(self.headers.get('Content-Length','0') or 0)
|
|
111
|
+
body = self.rfile.read(n).decode('utf-8','replace')
|
|
112
|
+
with open(log_path, 'a', encoding='utf-8') as f:
|
|
113
|
+
f.write(self.path + '\t' + body + '\n')
|
|
114
|
+
self.send_response(200)
|
|
115
|
+
self.send_header('Content-Type','application/json')
|
|
116
|
+
self.end_headers()
|
|
117
|
+
self.wfile.write(json.dumps({"sessionId":"rec00001-feedfeed"}).encode('utf-8'))
|
|
118
|
+
http.server.HTTPServer(('127.0.0.1', port), H).serve_forever()
|
|
119
|
+
PY
|
|
120
|
+
LISTENER_PIDS+=("$!")
|
|
121
|
+
|
|
122
|
+
# Wait up to 2s for the listener to bind.
|
|
123
|
+
for _ in $(seq 1 20); do
|
|
124
|
+
if curl -sS --max-time 1 -X POST "http://127.0.0.1:${LISTENER_PORT}/ping" -d '{}' >/dev/null 2>&1; then
|
|
125
|
+
break
|
|
126
|
+
fi
|
|
127
|
+
sleep 0.1
|
|
128
|
+
done
|
|
129
|
+
|
|
130
|
+
# Clear the request log of the ping write.
|
|
131
|
+
: > "$REQ_LOG"
|
|
132
|
+
|
|
133
|
+
MANAGER_PORT="$LISTENER_PORT"
|
|
95
134
|
run_hook "admin" "0" "$ENVELOPE"
|
|
96
135
|
unset MANAGER_PORT
|
|
136
|
+
|
|
137
|
+
# Give the listener time to flush.
|
|
138
|
+
sleep 0.1
|
|
139
|
+
|
|
97
140
|
if [[ "$HOOK_RC" -ne 0 ]]; then
|
|
98
141
|
fail "case-5 expected rc=0 got=$HOOK_RC stderr=$HOOK_STDERR"
|
|
99
|
-
|
|
100
|
-
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
# Assert: spawn-with-stdin log line emitted.
|
|
145
|
+
if ! echo "$HOOK_STDERR" | grep -qE '^\[recorder\] spawn-with-stdin sessionId=rec00001 bytes=[0-9]+ filtered-from-records=[0-9]+'; then
|
|
146
|
+
fail "case-5 expected spawn-with-stdin log line, got: $HOOK_STDERR"
|
|
147
|
+
else
|
|
148
|
+
pass "case-5a spawn-with-stdin log line emitted"
|
|
149
|
+
fi
|
|
150
|
+
|
|
151
|
+
# Assert: no legacy input-posted log line.
|
|
152
|
+
if echo "$HOOK_STDERR" | grep -q 'input-posted'; then
|
|
153
|
+
fail "case-5b legacy [recorder] input-posted line must not be emitted in Task 106"
|
|
154
|
+
else
|
|
155
|
+
pass "case-5b legacy input-posted log line absent"
|
|
156
|
+
fi
|
|
157
|
+
|
|
158
|
+
# Assert: listener saw exactly one /spawn POST whose body contains
|
|
159
|
+
# stdinPayload, and ZERO /<id>/input POSTs.
|
|
160
|
+
SPAWN_LINES=$(grep -c '^/spawn ' "$REQ_LOG" || true)
|
|
161
|
+
INPUT_LINES=$(grep -cE '^/[A-Za-z0-9_-]+/input ' "$REQ_LOG" || true)
|
|
162
|
+
STOP_LINES=$(grep -cE '^/[A-Za-z0-9_-]+/stop ' "$REQ_LOG" || true)
|
|
163
|
+
if [[ "$SPAWN_LINES" -ne 1 ]]; then
|
|
164
|
+
fail "case-5c expected exactly 1 /spawn POST, got $SPAWN_LINES (log: $(cat "$REQ_LOG"))"
|
|
165
|
+
else
|
|
166
|
+
pass "case-5c exactly one /spawn POST observed"
|
|
167
|
+
fi
|
|
168
|
+
if [[ "$INPUT_LINES" -ne 0 ]]; then
|
|
169
|
+
fail "case-5d expected ZERO /<id>/input POSTs, got $INPUT_LINES"
|
|
170
|
+
else
|
|
171
|
+
pass "case-5d zero /<id>/input POSTs"
|
|
172
|
+
fi
|
|
173
|
+
if [[ "$STOP_LINES" -ne 0 ]]; then
|
|
174
|
+
fail "case-5e expected ZERO /<id>/stop POSTs, got $STOP_LINES"
|
|
175
|
+
else
|
|
176
|
+
pass "case-5e zero /<id>/stop POSTs"
|
|
177
|
+
fi
|
|
178
|
+
|
|
179
|
+
# Assert: the /spawn body contains a stdinPayload field with non-empty value.
|
|
180
|
+
SPAWN_BODY=$(grep '^/spawn ' "$REQ_LOG" | head -1 | cut -f2-)
|
|
181
|
+
if [[ -z "$SPAWN_BODY" ]]; then
|
|
182
|
+
fail "case-5f /spawn body was empty"
|
|
183
|
+
else
|
|
184
|
+
HAS_STDIN=$(printf '%s' "$SPAWN_BODY" | python3 -c '
|
|
185
|
+
import sys, json
|
|
186
|
+
try:
|
|
187
|
+
b = json.load(sys.stdin)
|
|
188
|
+
sp = b.get("stdinPayload")
|
|
189
|
+
print("yes" if isinstance(sp, str) and len(sp) > 0 else "no")
|
|
190
|
+
except Exception:
|
|
191
|
+
print("parse-fail")
|
|
192
|
+
' 2>/dev/null)
|
|
193
|
+
if [[ "$HAS_STDIN" != "yes" ]]; then
|
|
194
|
+
fail "case-5f /spawn body must carry non-empty stdinPayload, got: $SPAWN_BODY"
|
|
195
|
+
else
|
|
196
|
+
pass "case-5f /spawn body carries non-empty stdinPayload"
|
|
197
|
+
fi
|
|
198
|
+
fi
|
|
199
|
+
|
|
200
|
+
# Assert: final fired log line still emitted.
|
|
201
|
+
if ! echo "$HOOK_STDERR" | grep -qE '^turn-completed-graph-write fired conversationId=abcd1234 recorderSessionId=rec00001 filtered=[0-9]+ ms=[0-9]+'; then
|
|
202
|
+
fail "case-5g expected fired summary log line, got: $HOOK_STDERR"
|
|
101
203
|
else
|
|
102
|
-
pass "case-
|
|
204
|
+
pass "case-5g fired summary log line emitted"
|
|
103
205
|
fi
|
|
104
206
|
|
|
105
207
|
# --- Summary ------------------------------------------------------------
|
|
@@ -1,27 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Stop hook — fires on every completed admin-agent turn and dispatches one
|
|
3
|
-
#
|
|
4
|
-
# the only writer to the Neo4j graph; the admin agent stays focused on
|
|
5
|
-
# operator's request.
|
|
3
|
+
# headless one-shot PTY against `specialists:database-operator`. The hidden
|
|
4
|
+
# PTY is the only writer to the Neo4j graph; the admin agent stays focused on
|
|
5
|
+
# the operator's request.
|
|
6
6
|
#
|
|
7
|
-
# Pipeline (Task
|
|
8
|
-
# 1. /spawn
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# mcp_instructions_delta, permission-mode are dropped).
|
|
16
|
-
# 3. When the recorder's Stop hook fires, this hook runs again INSIDE
|
|
17
|
-
# the recorder PTY; the MAXY_HIDDEN_SPAWN gate exits early so no
|
|
18
|
-
# recursion. The recorder's PTY exits naturally; its Stop is then
|
|
19
|
-
# driven by POST /<sessionId>/stop from the same hook invocation
|
|
20
|
-
# that posted /input.
|
|
7
|
+
# Pipeline (Task 106 redesign):
|
|
8
|
+
# 1. /spawn with `stdinPayload` — manager appends `--print` to argv when
|
|
9
|
+
# hidden=true && specialist is set, then writes the payload + EOT to the
|
|
10
|
+
# PTY's stdin immediately after spawn. Claude consumes the turn, writes
|
|
11
|
+
# to the graph via MCP tools, prints to stdout, and exits.
|
|
12
|
+
# 2. The fs-watcher observes the PID-file delete the moment claude exits
|
|
13
|
+
# and archives the row — no follow-up /input POST, no Stop hook signal,
|
|
14
|
+
# no 120s reaper safety-net catch.
|
|
21
15
|
#
|
|
22
16
|
# Gating:
|
|
23
17
|
# - MAXY_SESSION_ROLE must equal "admin"
|
|
24
|
-
# - MAXY_HIDDEN_SPAWN must equal "0" (skip recursive fires
|
|
18
|
+
# - MAXY_HIDDEN_SPAWN must equal "0" (skip recursive fires — the recorder
|
|
19
|
+
# runs with MAXY_HIDDEN_SPAWN=1 and `--print`, so it never invokes Stop
|
|
20
|
+
# hooks itself, but the guard stays as a belt-and-braces backstop).
|
|
25
21
|
#
|
|
26
22
|
# Input: Claude Code's Stop hook stdin shape
|
|
27
23
|
# { "session_id": "<intrinsic>", "transcript_path": "<jsonl path>", ... }
|
|
@@ -37,32 +33,10 @@ if [ "${MAXY_SESSION_ROLE:-}" != "admin" ]; then
|
|
|
37
33
|
exit 0
|
|
38
34
|
fi
|
|
39
35
|
if [ "${MAXY_HIDDEN_SPAWN:-0}" = "1" ]; then
|
|
40
|
-
# Recorder PTY's own Stop hook
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
exit 0
|
|
45
|
-
fi
|
|
46
|
-
REC_INPUT=$(cat)
|
|
47
|
-
if [ -z "$REC_INPUT" ]; then
|
|
48
|
-
exit 0
|
|
49
|
-
fi
|
|
50
|
-
REC_SESSION_ID=$(printf '%s' "$REC_INPUT" | python3 -c '
|
|
51
|
-
import sys, json
|
|
52
|
-
try:
|
|
53
|
-
d = json.load(sys.stdin)
|
|
54
|
-
print(d.get("session_id", "") or "")
|
|
55
|
-
except Exception:
|
|
56
|
-
print("")
|
|
57
|
-
' 2>/dev/null)
|
|
58
|
-
if [ -z "$REC_SESSION_ID" ]; then
|
|
59
|
-
exit 0
|
|
60
|
-
fi
|
|
61
|
-
REC_PORT="${CLAUDE_SESSION_MANAGER_PORT:-19400}"
|
|
62
|
-
curl -sS -X POST \
|
|
63
|
-
--max-time 5 \
|
|
64
|
-
"http://127.0.0.1:${REC_PORT}/${REC_SESSION_ID}/stop" >/dev/null 2>&1
|
|
65
|
-
echo "[recorder] self-stop sessionId=${REC_SESSION_ID:0:8}" >&2
|
|
36
|
+
# Recorder PTY's own Stop hook would land here if claude --print ever
|
|
37
|
+
# emitted one. The lifecycle is now stdin-close → process-exit, so no
|
|
38
|
+
# self-stop POST is needed; exit silently to prevent accidental recursion
|
|
39
|
+
# into the admin branch below.
|
|
66
40
|
exit 0
|
|
67
41
|
fi
|
|
68
42
|
|
|
@@ -111,9 +85,6 @@ def keep(rec):
|
|
|
111
85
|
return False
|
|
112
86
|
|
|
113
87
|
path = sys.argv[1]
|
|
114
|
-
# Read whole file; admin transcripts are bounded by Claude Code's own
|
|
115
|
-
# compaction so the read cost is reasonable. Take the last 200 records
|
|
116
|
-
# from the kept set so a long transcript doesn't blow the prompt budget.
|
|
117
88
|
out = []
|
|
118
89
|
try:
|
|
119
90
|
with open(path, 'r', encoding='utf-8') as f:
|
|
@@ -135,19 +106,36 @@ FILTERED_COUNT=$(printf '%s' "$FILTERED_TAIL" | python3 -c 'import sys,json; d=j
|
|
|
135
106
|
MANAGER_PORT="${CLAUDE_SESSION_MANAGER_PORT:-19400}"
|
|
136
107
|
MANAGER_BASE="http://127.0.0.1:${MANAGER_PORT}"
|
|
137
108
|
|
|
138
|
-
#
|
|
139
|
-
#
|
|
109
|
+
# Compose the recorder prompt + bundle it into the /spawn body's
|
|
110
|
+
# `stdinPayload`. The manager appends `--print` to the claude argv and
|
|
111
|
+
# writes (payload + EOT) to the PTY's stdin; claude consumes one turn and
|
|
112
|
+
# exits, the fs-watcher archives the row.
|
|
140
113
|
SPAWN_BODY=$(python3 -c '
|
|
141
|
-
import
|
|
114
|
+
import sys, json
|
|
115
|
+
sid = sys.argv[1]
|
|
116
|
+
tail_json = sys.argv[2]
|
|
117
|
+
prompt = (
|
|
118
|
+
"You are the database-operator specialist. Record the following completed turn "
|
|
119
|
+
"into the Neo4j graph. The transcript is a JSON array of filtered records "
|
|
120
|
+
"from the admin agent (user prompts, assistant responses, tool calls). "
|
|
121
|
+
f"Conversation id: {sid}.\n\nTranscript:\n{tail_json}\n"
|
|
122
|
+
)
|
|
142
123
|
body = {
|
|
143
124
|
"senderId": "turn-recorder",
|
|
144
125
|
"role": "admin",
|
|
145
126
|
"channel": "browser",
|
|
146
127
|
"hidden": True,
|
|
147
128
|
"specialist": "database-operator",
|
|
129
|
+
"stdinPayload": prompt,
|
|
148
130
|
}
|
|
149
131
|
print(json.dumps(body))
|
|
150
|
-
')
|
|
132
|
+
' "$ADMIN_SESSION_ID" "$FILTERED_TAIL" 2>/dev/null)
|
|
133
|
+
|
|
134
|
+
if [ -z "$SPAWN_BODY" ]; then
|
|
135
|
+
exit 0
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
PAYLOAD_BYTES=$(printf '%s' "$SPAWN_BODY" | python3 -c 'import sys,json; b=json.load(sys.stdin); print(len(b.get("stdinPayload","").encode("utf-8")))' 2>/dev/null || echo 0)
|
|
151
139
|
|
|
152
140
|
SPAWN_RES=$(curl -sS -X POST \
|
|
153
141
|
-H 'Content-Type: application/json' \
|
|
@@ -169,33 +157,7 @@ if [ -z "$RECORDER_SESSION_ID" ]; then
|
|
|
169
157
|
exit 0
|
|
170
158
|
fi
|
|
171
159
|
|
|
172
|
-
|
|
173
|
-
INPUT_BODY=$(python3 -c '
|
|
174
|
-
import sys, json
|
|
175
|
-
sid = sys.argv[1]
|
|
176
|
-
tail_json = sys.argv[2]
|
|
177
|
-
prompt = (
|
|
178
|
-
"You are the database-operator specialist. Record the following completed turn "
|
|
179
|
-
"into the Neo4j graph. The transcript is a JSON array of filtered records "
|
|
180
|
-
"from the admin agent (user prompts, assistant responses, tool calls). "
|
|
181
|
-
f"Conversation id: {sid}.\n\nTranscript:\n{tail_json}\n"
|
|
182
|
-
)
|
|
183
|
-
print(json.dumps({"text": prompt}))
|
|
184
|
-
' "$ADMIN_SESSION_ID" "$FILTERED_TAIL" 2>/dev/null)
|
|
185
|
-
|
|
186
|
-
if [ -z "$INPUT_BODY" ]; then
|
|
187
|
-
exit 0
|
|
188
|
-
fi
|
|
189
|
-
|
|
190
|
-
INPUT_BYTES=$(printf '%s' "$INPUT_BODY" | wc -c | tr -d ' ')
|
|
191
|
-
|
|
192
|
-
curl -sS -X POST \
|
|
193
|
-
-H 'Content-Type: application/json' \
|
|
194
|
-
--max-time 5 \
|
|
195
|
-
--data "$INPUT_BODY" \
|
|
196
|
-
"${MANAGER_BASE}/${RECORDER_SESSION_ID}/input" >/dev/null 2>&1
|
|
197
|
-
|
|
198
|
-
echo "[recorder] input-posted sessionId=${RECORDER_SESSION_ID:0:8} bytes=${INPUT_BYTES} filtered-from-records=${FILTERED_COUNT}" >&2
|
|
160
|
+
echo "[recorder] spawn-with-stdin sessionId=${RECORDER_SESSION_ID:0:8} bytes=${PAYLOAD_BYTES} filtered-from-records=${FILTERED_COUNT}" >&2
|
|
199
161
|
|
|
200
162
|
END_MS=$(python3 -c 'import time; print(int(time.time()*1000))')
|
|
201
163
|
ELAPSED=$((END_MS - START_MS))
|
|
@@ -52,7 +52,7 @@ Roles are installed during setup and listed when {{productName}} introduces itse
|
|
|
52
52
|
|
|
53
53
|
{{productName}} maintains a graph database (Neo4j) of everything you've told it. People, conversations, preferences, and context are stored as connected nodes. When you ask {{productName}} something, it searches this graph to retrieve relevant context before responding.
|
|
54
54
|
|
|
55
|
-
**The recording loop.** A background recorder watches every completed admin-agent turn. Once your turn finishes and the reply is on screen, a Stop hook fires the `database-operator` specialist as a
|
|
55
|
+
**The recording loop.** A background recorder watches every completed admin-agent turn. Once your turn finishes and the reply is on screen, a Stop hook fires the `database-operator` specialist as a single-shot headless Claude Code session: the filtered transcript is written to the recorder's stdin and stdin is closed, so claude processes one turn, writes through the wrapped graph writers, and exits. The row archives the moment claude exits — no long-lived hidden PTY. The admin agent itself does not write to the graph. The recorder runs on a cheap fast model so the recording adds nothing to your perceived latency, and its session never appears in the sidebar or anywhere on the operator UI. To disable it for an account, remove the `Stop` hook entry from that account's `~/.<brand>/.claude/settings.json`; there is no toggle.
|
|
56
56
|
|
|
57
57
|
The memory graph is stored on your Pi. It never leaves your network.
|
|
58
58
|
|
|
@@ -74,18 +74,20 @@ The admin interface is a three-pane layout: a sidebar on the left with navigatio
|
|
|
74
74
|
|
|
75
75
|
Page titles are brand-aware: the browser tab shows your product name (e.g. `Real Agent` instead of `Maxy`) on every shell — chat, graph, and data — so a non-default brand never leaks the default name in tab strips or browser history.
|
|
76
76
|
|
|
77
|
-
**Session lifecycle and reconcile model.** The sidebar Sessions list is driven by a single Server-Sent Events feed at `/api/admin/claude-sessions/events`. The session manager watches the two directories Claude Code writes (`${CLAUDE_CONFIG_DIR}/sessions/<pid>.json` for live state, `${CLAUDE_CONFIG_DIR}/projects/<slug>/<sid>.jsonl` for transcripts) and emits `row-created`, `row-updated`, `row-archived`, or `row-removed` deltas to every connected browser tab. On connect the manager replays the current row index so a freshly-opened tab catches up without polling, then streams deltas as files change on disk. Two open tabs see the same list within ~300ms of any spawn, status flip, or exit; no refresh button required for state to be current. The legacy `/list` fetch and `useAdminSessions` hook stay mounted to serve the ConversationsModal and the post-action reconcile path in `session-actions`, but the sidebar's visible rows come from the row store, not from `/list`. Each EventSource open emits `[admin-events] client-connected ip=<…> seeded-rows=<n>` server-side and `[admin-ui] session-row-store connected events-received=<n>` in the browser console; transport drops log `[admin-ui] session-row-store reconnect trigger=<auto|manual> attempt=<n> delay-ms=<n>` until the EventSource reattaches. The small dot at the right edge of the Active/Archived/All segmented control is the live-updates indicator: sage when the SSE feed is connected, grey when the feed has dropped. The grey state is an actionable button — clicking it cancels any pending backoff and re-opens the feed immediately, with the click logged as `trigger=manual` so manual retries are distinguishable from automatic ones in the console.
|
|
77
|
+
**Session lifecycle and reconcile model.** The sidebar Sessions list is driven by a single Server-Sent Events feed at `/api/admin/claude-sessions/events`. The session manager watches the two directories Claude Code writes (`${CLAUDE_CONFIG_DIR}/sessions/<pid>.json` for live state, `${CLAUDE_CONFIG_DIR}/projects/<slug>/<sid>.jsonl` for transcripts) and emits `row-created`, `row-updated`, `row-archived`, or `row-removed` deltas to every connected browser tab. Three real delete shapes map to deltas — there is no fourth: PID file gone with JSONL surviving demotes the row to `row-archived`; PID file gone with no JSONL ever written (the per-turn recorder case) emits `row-removed` against the unindexed sessionId; a JSONL deletion against an already-unindexed row also emits `row-removed`. The recorder branch is what reconciles transient hidden spawns — without it, ghost rows persist after the recorder exits. On connect the manager replays the current row index so a freshly-opened tab catches up without polling, then streams deltas as files change on disk. Two open tabs see the same list within ~300ms of any spawn, status flip, or exit; no refresh button required for state to be current. The legacy `/list` fetch and `useAdminSessions` hook stay mounted to serve the ConversationsModal and the post-action reconcile path in `session-actions`, but the sidebar's visible rows come from the row store, not from `/list`. Each EventSource open emits `[admin-events] client-connected ip=<…> seeded-rows=<n>` server-side and `[admin-ui] session-row-store connected events-received=<n>` in the browser console; transport drops log `[admin-ui] session-row-store reconnect trigger=<auto|manual> attempt=<n> delay-ms=<n>` until the EventSource reattaches. The small dot at the right edge of the Active/Archived/All segmented control is the live-updates indicator: sage when the SSE feed is connected, grey when the feed has dropped. The grey state is an actionable button — clicking it cancels any pending backoff and re-opens the feed immediately, with the click logged as `trigger=manual` so manual retries are distinguishable from automatic ones in the console.
|
|
78
78
|
|
|
79
79
|
The row feed sits behind `requireAdminSession` like every other admin route, so the URL must carry `?session_key=<cacheKey>` — `EventSource` cannot send custom headers, so the query string is the only viable transport. Every admin URL (fetch and EventSource alike) routes through the shared `appendAdminSessionKey(url, cacheKey)` helper exported from `app/lib/useAdminFetch.ts`, which is the single source of truth for the convention; no caller constructs the query string by hand. On a 4xx rejection the browser-side store probes the same URL once per reconnect (suppressed after a successful `open`, capped at one fetch per attempt) and logs `[admin-ui] session-row-store sse-error status=<n> code=<code> attempt=<n>`. The `code` field uses the closed `AdminSessionRejectCode` taxonomy (`session-missing | session-not-registered | session-expired-age | grant-expired`, plus a default `unknown` bucket) that mirrors the server-side rejection emitted by `requireAdminSession`, so a single grep correlates client and server timelines on the same code.
|
|
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.
|
|
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.
|
|
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
|
|
|
87
87
|
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.
|
|
88
88
|
|
|
89
|
+
**Admin URL hygiene: `?sessionId=<id>` is retained only while `/meta` returns 200.** The shell hydrates `selectedSessionId` from the query-string on mount so a banner-click redirect can re-open a session. The first `/meta 404` (the session has been deleted out from under the slug) strips the query-string via `history.replaceState`, clears the selection, and emits `[admin-ui] stale-session-slug-stripped sessionId=<8-prefix> trigger=meta-404`. A reload from the dead URL therefore starts at base instead of re-resolving a 404.
|
|
90
|
+
|
|
89
91
|
The Data search panel ranks results by combining vector similarity with keyword (BM25) matching. Each row shows a one-line score breakdown — `vector 0.NN · bm25 0.NN · combined 0.NN` — so you can tell whether a row surfaced because of meaning, exact-keyword match, or both. A bm25 column of `0.00` across every row means your search term wasn't in the keyword index, so ranking fell back to pure vector similarity (this can produce surprising results — the breakdown tells you when to interpret with caution). Above the result list, a chip row shows the unique types in your current results — click one to filter, click again to clear. Click any row to jump straight to that node's neighbourhood in the Graph; from the artefact pane the graph opens alongside chat, from the standalone Data page it opens in place.
|
|
90
92
|
|
|
91
93
|
## Software Update and Cloudflare Setup
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAyBA,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;AA8E3D,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;AA0DD,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAyBA,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;AA8E3D,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;AA0DD,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,CA4uBjD"}
|
|
@@ -265,6 +265,9 @@ export function buildHttpApp(deps) {
|
|
|
265
265
|
const specialist = typeof body.specialist === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.specialist)
|
|
266
266
|
? body.specialist
|
|
267
267
|
: undefined;
|
|
268
|
+
const stdinPayload = typeof body.stdinPayload === 'string' && body.stdinPayload.length > 0
|
|
269
|
+
? body.stdinPayload
|
|
270
|
+
: undefined;
|
|
268
271
|
const aboutOwnerShape = body.aboutOwner == null ? 'absent' : (typeof body.aboutOwner === 'object' ? 'object' : typeof body.aboutOwner);
|
|
269
272
|
const dormantPluginsShape = Array.isArray(body.dormantPlugins)
|
|
270
273
|
? `array(${body.dormantPlugins.length})`
|
|
@@ -316,6 +319,7 @@ export function buildHttpApp(deps) {
|
|
|
316
319
|
specialistDomains,
|
|
317
320
|
hidden,
|
|
318
321
|
specialist,
|
|
322
|
+
stdinPayload,
|
|
319
323
|
});
|
|
320
324
|
if (!result.ok) {
|
|
321
325
|
if ('rejected' in result) {
|
|
@@ -399,8 +403,10 @@ export function buildHttpApp(deps) {
|
|
|
399
403
|
// the four SSE event kinds. Mapping:
|
|
400
404
|
// create → row-created
|
|
401
405
|
// modify → row-updated (state may have flipped — see archive)
|
|
402
|
-
// delete →
|
|
403
|
-
//
|
|
406
|
+
// delete → three real cases:
|
|
407
|
+
// pid + row present → row-archived (PID gone, JSONL survives)
|
|
408
|
+
// pid + row null → row-removed (PID gone, no JSONL ever existed)
|
|
409
|
+
// jsonl + row null → row-removed (JSONL deleted, row unindexed)
|
|
404
410
|
const unsubscribe = deps.watcher.subscribe((event) => {
|
|
405
411
|
if (!active)
|
|
406
412
|
return;
|
|
@@ -416,6 +422,16 @@ export function buildHttpApp(deps) {
|
|
|
416
422
|
void out.write(`event: row-archived\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
417
423
|
return;
|
|
418
424
|
}
|
|
425
|
+
// Recorder case: PID file disappeared before any JSONL was ever
|
|
426
|
+
// written. Row is unindexed at the manager. Without this branch
|
|
427
|
+
// the row falls through `if (!event.row) return` below and the
|
|
428
|
+
// client sidebar keeps showing a session that no longer exists.
|
|
429
|
+
if (event.kind === 'delete' && event.source === 'pid' && !event.row) {
|
|
430
|
+
subscribersCount += 1;
|
|
431
|
+
deps.logger(`[admin-events] emit event=row-removed sessionId=${event.sessionId} subscribers=1`);
|
|
432
|
+
void out.write(`event: row-removed\ndata: ${JSON.stringify({ sessionId: event.sessionId })}\n\n`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
419
435
|
if (event.kind === 'delete' && event.source === 'jsonl' && !event.row) {
|
|
420
436
|
subscribersCount += 1;
|
|
421
437
|
deps.logger(`[admin-events] emit event=row-removed sessionId=${event.sessionId} subscribers=1`);
|