@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.
Files changed (22) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +122 -20
  3. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +40 -78
  4. package/payload/platform/plugins/docs/references/platform.md +5 -3
  5. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  6. package/payload/platform/services/claude-session-manager/dist/http-server.js +18 -2
  7. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  8. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +5 -0
  9. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  10. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +17 -0
  11. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  12. package/payload/platform/templates/agents/admin/IDENTITY.md +3 -11
  13. package/payload/server/public/assets/{admin-GOZURixP.js → admin-DVZcLuYu.js} +2 -2
  14. package/payload/server/public/assets/{data-CU9uH2JJ.js → data-BxpOR0eM.js} +1 -1
  15. package/payload/server/public/assets/{graph-BI7yVwmT.js → graph-CrmxRSeb.js} +1 -1
  16. package/payload/server/public/assets/graph-labels-C8A6BmtP.js +1 -0
  17. package/payload/server/public/assets/{page-DKdUMAhI.js → page-D5wAeFU0.js} +1 -1
  18. package/payload/server/public/assets/{page-BaRMnC96.js → page-DR5NU6S0.js} +1 -1
  19. package/payload/server/public/data.html +3 -3
  20. package/payload/server/public/graph.html +3 -3
  21. package/payload/server/public/index.html +4 -4
  22. package/payload/server/public/assets/graph-labels-bI0uSt_-.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -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 attempt + `turn-completed-graph-write
11
- # fired conversationId=<8> tail-bytes=<n> ms=<n>` log line.
12
- #
13
- # POST is observed by binding the hook's MANAGER_PORT to a tiny netcat
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 → no fire (rc=0, stderr empty)"
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 attempt + log line ---------------------
91
- # Use a deliberately closed port; curl returns non-zero, which is fine —
92
- # the hook fires fire-and-forget and ignores curl's exit. We assert on
93
- # the log line and exit code only.
94
- MANAGER_PORT=1
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
- elif ! echo "$HOOK_STDERR" | grep -q "^turn-completed-graph-write fired conversationId=abcd1234 tail-bytes="; then
100
- fail "case-5 expected fired log line, got: $HOOK_STDERR"
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-5 admin foreground → fired log line emitted"
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
- # background PTY against `specialists:database-operator`. The hidden PTY is
4
- # the only writer to the Neo4j graph; the admin agent stays focused on the
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 094 redesign):
8
- # 1. /spawn (no stdinPayload) — manager awaits its watcher's PID-file
9
- # event before returning. Response carries the recorder's intrinsic
10
- # sessionId.
11
- # 2. POST /<recorder-sessionId>/input body is the database-operator
12
- # prompt followed by the filtered JSONL tail of the just-completed
13
- # turn (user / assistant / tool-call records only; queue-operation,
14
- # auto_mode, deferred_tools_delta, hook_*, last-prompt, skill_listing,
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. Read its session_id and ask the manager
41
- # to SIGTERM the recorder so it does not idle in the active recents list
42
- # after its single turn completes. Keep the JSONL it's the audit trail.
43
- if [ -t 0 ]; then
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
- # Spawn the recorder PTY. Manager blocks on its watcher's PID-file event
139
- # and returns the recorder's intrinsic sessionId in the response body.
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 json, sys
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
- # Compose the input message: prompt prefix + filtered transcript JSON.
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 hidden Claude Code session with the just-completed turn as input. The recorder decides what's worth keeping and writes through the wrapped graph writers; 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.
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,CA0tBjD"}
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 → row-removed when the row is fully gone, row-archived
403
- // when the PID file went away but the JSONL survives.
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`);