@rubytech/create-maxy-code 0.1.44 → 0.1.47

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 (155) hide show
  1. package/dist/index.js +8 -16
  2. package/package.json +1 -1
  3. package/payload/platform/lib/admins-write/dist/index.d.ts +0 -1
  4. package/payload/platform/lib/admins-write/dist/index.d.ts.map +1 -1
  5. package/payload/platform/lib/admins-write/dist/index.js +2 -5
  6. package/payload/platform/lib/admins-write/dist/index.js.map +1 -1
  7. package/payload/platform/lib/admins-write/src/index.ts +2 -8
  8. package/payload/platform/lib/graph-mcp/dist/index.js +1 -4
  9. package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
  10. package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts +7 -6
  11. package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts.map +1 -1
  12. package/payload/platform/lib/graph-mcp/dist/schema-cache.js +7 -6
  13. package/payload/platform/lib/graph-mcp/dist/schema-cache.js.map +1 -1
  14. package/payload/platform/lib/graph-mcp/src/index.ts +1 -4
  15. package/payload/platform/lib/graph-mcp/src/schema-cache.ts +7 -6
  16. package/payload/platform/lib/graph-search/dist/index.d.ts +5 -5
  17. package/payload/platform/lib/graph-search/dist/index.js +4 -4
  18. package/payload/platform/lib/graph-search/src/__tests__/fulltext-coverage.test.ts +6 -7
  19. package/payload/platform/lib/graph-search/src/index.ts +5 -5
  20. package/payload/platform/lib/graph-trash/dist/index.d.ts +8 -12
  21. package/payload/platform/lib/graph-trash/dist/index.d.ts.map +1 -1
  22. package/payload/platform/lib/graph-trash/dist/index.js +8 -12
  23. package/payload/platform/lib/graph-trash/dist/index.js.map +1 -1
  24. package/payload/platform/lib/graph-trash/src/index.ts +8 -12
  25. package/payload/platform/lib/graph-write/dist/index.d.ts +8 -9
  26. package/payload/platform/lib/graph-write/dist/index.d.ts.map +1 -1
  27. package/payload/platform/lib/graph-write/dist/index.js +9 -10
  28. package/payload/platform/lib/graph-write/dist/index.js.map +1 -1
  29. package/payload/platform/lib/graph-write/src/index.ts +9 -10
  30. package/payload/platform/lib/mcp-eager/dist/index.d.ts +4 -4
  31. package/payload/platform/lib/mcp-eager/dist/index.js +4 -4
  32. package/payload/platform/lib/mcp-eager/src/index.ts +4 -4
  33. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  34. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +138 -18
  35. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +31 -67
  36. package/payload/platform/plugins/cloudflare/scripts/__tests__/tunnel-ingress.test.ts +2 -2
  37. package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.ts +7 -9
  38. package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +10 -12
  39. package/payload/platform/plugins/cloudflare/scripts/tunnel-ingress.ts +3 -3
  40. package/payload/platform/plugins/docs/references/deployment.md +0 -14
  41. package/payload/platform/plugins/docs/references/platform.md +6 -4
  42. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  43. package/payload/platform/plugins/docs/references/troubleshooting.md +5 -5
  44. package/payload/platform/plugins/loop/PLUGIN.md +67 -12
  45. package/payload/platform/plugins/loop/mcp/dist/index.js +42 -16
  46. package/payload/platform/plugins/loop/mcp/dist/index.js.map +1 -1
  47. package/payload/platform/plugins/loop/mcp/dist/tools/auto-responder.d.ts +12 -0
  48. package/payload/platform/plugins/loop/mcp/dist/tools/auto-responder.d.ts.map +1 -0
  49. package/payload/platform/plugins/loop/mcp/dist/tools/auto-responder.js +22 -0
  50. package/payload/platform/plugins/loop/mcp/dist/tools/auto-responder.js.map +1 -0
  51. package/payload/platform/plugins/loop/mcp/dist/tools/marketing-enquiry.d.ts +0 -7
  52. package/payload/platform/plugins/loop/mcp/dist/tools/marketing-enquiry.d.ts.map +1 -1
  53. package/payload/platform/plugins/loop/mcp/dist/tools/marketing-enquiry.js +4 -38
  54. package/payload/platform/plugins/loop/mcp/dist/tools/marketing-enquiry.js.map +1 -1
  55. package/payload/platform/plugins/loop/mcp/dist/tools/property-intent.d.ts +33 -0
  56. package/payload/platform/plugins/loop/mcp/dist/tools/property-intent.d.ts.map +1 -0
  57. package/payload/platform/plugins/loop/mcp/dist/tools/property-intent.js +19 -0
  58. package/payload/platform/plugins/loop/mcp/dist/tools/property-intent.js.map +1 -0
  59. package/payload/platform/plugins/loop/mcp/dist/tools/property-listed.d.ts +0 -1
  60. package/payload/platform/plugins/loop/mcp/dist/tools/property-listed.d.ts.map +1 -1
  61. package/payload/platform/plugins/loop/mcp/dist/tools/property-listed.js +1 -7
  62. package/payload/platform/plugins/loop/mcp/dist/tools/property-listed.js.map +1 -1
  63. package/payload/platform/plugins/loop/mcp/dist/tools/property-sold.d.ts +9 -0
  64. package/payload/platform/plugins/loop/mcp/dist/tools/property-sold.d.ts.map +1 -0
  65. package/payload/platform/plugins/loop/mcp/dist/tools/property-sold.js +19 -0
  66. package/payload/platform/plugins/loop/mcp/dist/tools/property-sold.js.map +1 -0
  67. package/payload/platform/plugins/loop/mcp/src/__tests__/new-tool-shape.test.ts +99 -0
  68. package/payload/platform/plugins/loop/mcp/src/__tests__/swagger-write-coverage.test.ts +12 -11
  69. package/payload/platform/plugins/loop/mcp/src/index.ts +68 -17
  70. package/payload/platform/plugins/loop/mcp/src/tools/auto-responder.ts +80 -0
  71. package/payload/platform/plugins/loop/mcp/src/tools/marketing-enquiry.ts +14 -100
  72. package/payload/platform/plugins/loop/mcp/src/tools/property-intent.ts +84 -0
  73. package/payload/platform/plugins/loop/mcp/src/tools/property-listed.ts +1 -8
  74. package/payload/platform/plugins/loop/mcp/src/tools/property-sold.ts +67 -0
  75. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/llm-classifier.test.js +4 -3
  76. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/llm-classifier.test.js.map +1 -1
  77. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.d.ts.map +1 -1
  78. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.js +7 -12
  79. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.js.map +1 -1
  80. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-not-applicable.test.js +2 -2
  81. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.js +4 -4
  82. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.js.map +1 -1
  83. package/payload/platform/plugins/tasks/mcp/dist/tools/session-name.d.ts.map +1 -1
  84. package/payload/platform/plugins/tasks/mcp/dist/tools/session-name.js +8 -9
  85. package/payload/platform/plugins/tasks/mcp/dist/tools/session-name.js.map +1 -1
  86. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-heal.d.ts +2 -2
  87. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-heal.js +2 -2
  88. package/payload/platform/scripts/admin-persist-audit.ts +4 -10
  89. package/payload/platform/scripts/conversation-id-allowlist.txt +0 -1
  90. package/payload/platform/scripts/lib/resolve-account-dir.sh +0 -2
  91. package/payload/platform/scripts/resume-tunnel.sh +2 -9
  92. package/payload/platform/scripts/seed-neo4j.sh +26 -104
  93. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  94. package/payload/platform/services/claude-session-manager/dist/http-server.js +67 -5
  95. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  96. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  97. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +13 -0
  98. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  99. package/payload/platform/templates/agents/admin/IDENTITY.md +12 -12
  100. package/payload/platform/templates/agents/admin/SOUL.md +23 -1
  101. package/payload/premium-plugins/real-agent/agents/buyer-enquiry/SOUL.md +10 -0
  102. package/payload/premium-plugins/real-agent/agents/compliance.md +1 -1
  103. package/payload/premium-plugins/real-agent/agents/negotiator.md +1 -1
  104. package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
  105. package/payload/premium-plugins/real-agent/plugins/loop/PLUGIN.md +67 -12
  106. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js +42 -16
  107. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js.map +1 -1
  108. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/auto-responder.d.ts +12 -0
  109. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/auto-responder.d.ts.map +1 -0
  110. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/auto-responder.js +22 -0
  111. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/auto-responder.js.map +1 -0
  112. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-enquiry.d.ts +0 -7
  113. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-enquiry.d.ts.map +1 -1
  114. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-enquiry.js +4 -38
  115. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-enquiry.js.map +1 -1
  116. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-intent.d.ts +33 -0
  117. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-intent.d.ts.map +1 -0
  118. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-intent.js +19 -0
  119. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-intent.js.map +1 -0
  120. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-listed.d.ts +0 -1
  121. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-listed.d.ts.map +1 -1
  122. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-listed.js +1 -7
  123. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-listed.js.map +1 -1
  124. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-sold.d.ts +9 -0
  125. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-sold.d.ts.map +1 -0
  126. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-sold.js +19 -0
  127. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-sold.js.map +1 -0
  128. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/__tests__/new-tool-shape.test.ts +99 -0
  129. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/__tests__/swagger-write-coverage.test.ts +12 -11
  130. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/index.ts +68 -17
  131. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/auto-responder.ts +80 -0
  132. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/marketing-enquiry.ts +14 -100
  133. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/property-intent.ts +84 -0
  134. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/property-listed.ts +1 -8
  135. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/property-sold.ts +67 -0
  136. package/payload/server/public/assets/admin-SyUHjvNs.js +216 -0
  137. package/payload/server/public/assets/{data-CU9uH2JJ.js → data-DwQ7ZEyy.js} +1 -1
  138. package/payload/server/public/assets/{graph-BI7yVwmT.js → graph-CwZkD6GX.js} +1 -1
  139. package/payload/server/public/assets/graph-labels-CVkmOHhw.js +1 -0
  140. package/payload/server/public/assets/page-CQ7ELqGs.js +1 -0
  141. package/payload/server/public/assets/page-SOCqJGlV.js +50 -0
  142. package/payload/server/public/data.html +3 -3
  143. package/payload/server/public/graph.html +3 -3
  144. package/payload/server/public/index.html +4 -4
  145. package/payload/server/server.js +9 -46
  146. package/payload/platform/plugins/docs/references/migration-guide.md +0 -90
  147. package/payload/platform/plugins/loop/mcp/src/tools/property-request.ts +0 -42
  148. package/payload/platform/scripts/component-knowledgedoc-backfill.ts +0 -214
  149. package/payload/platform/scripts/migrate-import.sh +0 -437
  150. package/payload/platform/scripts/taskmaster-export.sh +0 -388
  151. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/property-request.ts +0 -42
  152. package/payload/server/public/assets/admin-GOZURixP.js +0 -216
  153. package/payload/server/public/assets/graph-labels-bI0uSt_-.js +0 -1
  154. package/payload/server/public/assets/page-BaRMnC96.js +0 -1
  155. package/payload/server/public/assets/page-DKdUMAhI.js +0 -50
@@ -1,27 +1,24 @@
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 database-operator session against the conversation transcript.
4
+ # The hidden recorder is the only writer to the Neo4j graph; the admin agent
5
+ # stays focused on 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 114 redesign — replaces Task 106's `--print` + stdin-EOT
8
+ # shape, which is incompatible with the OAuth subscription the manager runs
9
+ # on):
10
+ # 1. /spawn with `hidden=true, specialist=database-operator` — the manager
11
+ # produces a structurally normal admin specialist session (same argv,
12
+ # same OAuth path) and returns its sessionId.
13
+ # 2. POST /<sessionId>/input with `<instruction>\n\nConversation transcript:\n<filtered JSON>`.
14
+ # 3. The manager's recorder-auto-archive subscriber stops the PTY as soon
15
+ # as the recorder's JSONL contains an assistant message with
16
+ # `stop_reason === "end_turn"`. The hook does not need to follow up.
21
17
  #
22
18
  # Gating:
23
19
  # - MAXY_SESSION_ROLE must equal "admin"
24
- # - MAXY_HIDDEN_SPAWN must equal "0" (skip recursive fires)
20
+ # - MAXY_HIDDEN_SPAWN must equal "0" (the recorder PTY itself runs with
21
+ # MAXY_HIDDEN_SPAWN=1; the guard prevents accidental recursion).
25
22
  #
26
23
  # Input: Claude Code's Stop hook stdin shape
27
24
  # { "session_id": "<intrinsic>", "transcript_path": "<jsonl path>", ... }
@@ -37,32 +34,6 @@ if [ "${MAXY_SESSION_ROLE:-}" != "admin" ]; then
37
34
  exit 0
38
35
  fi
39
36
  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
66
37
  exit 0
67
38
  fi
68
39
 
@@ -111,9 +82,6 @@ def keep(rec):
111
82
  return False
112
83
 
113
84
  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
85
  out = []
118
86
  try:
119
87
  with open(path, 'r', encoding='utf-8') as f:
@@ -135,10 +103,8 @@ FILTERED_COUNT=$(printf '%s' "$FILTERED_TAIL" | python3 -c 'import sys,json; d=j
135
103
  MANAGER_PORT="${CLAUDE_SESSION_MANAGER_PORT:-19400}"
136
104
  MANAGER_BASE="http://127.0.0.1:${MANAGER_PORT}"
137
105
 
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.
140
106
  SPAWN_BODY=$(python3 -c '
141
- import json, sys
107
+ import json
142
108
  body = {
143
109
  "senderId": "turn-recorder",
144
110
  "role": "admin",
@@ -147,7 +113,7 @@ body = {
147
113
  "specialist": "database-operator",
148
114
  }
149
115
  print(json.dumps(body))
150
- ')
116
+ ' 2>/dev/null)
151
117
 
152
118
  SPAWN_RES=$(curl -sS -X POST \
153
119
  -H 'Content-Type: application/json' \
@@ -169,33 +135,31 @@ if [ -z "$RECORDER_SESSION_ID" ]; then
169
135
  exit 0
170
136
  fi
171
137
 
172
- # Compose the input message: prompt prefix + filtered transcript JSON.
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.
173
141
  INPUT_BODY=$(python3 -c '
174
142
  import sys, json
175
143
  sid = sys.argv[1]
176
144
  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"
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"
182
150
  )
183
- print(json.dumps({"text": prompt}))
151
+ print(json.dumps({"text": text}))
184
152
  ' "$ADMIN_SESSION_ID" "$FILTERED_TAIL" 2>/dev/null)
185
153
 
186
- if [ -z "$INPUT_BODY" ]; then
187
- exit 0
188
- fi
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)
189
155
 
190
- INPUT_BYTES=$(printf '%s' "$INPUT_BODY" | wc -c | tr -d ' ')
191
-
192
- curl -sS -X POST \
156
+ INPUT_HTTP=$(curl -sS -o /tmp/turn-recorder-input.out -w '%{http_code}' -X POST \
193
157
  -H 'Content-Type: application/json' \
194
- --max-time 5 \
158
+ --max-time 10 \
195
159
  --data "$INPUT_BODY" \
196
- "${MANAGER_BASE}/${RECORDER_SESSION_ID}/input" >/dev/null 2>&1
160
+ "${MANAGER_BASE}/${RECORDER_SESSION_ID}/input" 2>/dev/null || echo 000)
197
161
 
198
- echo "[recorder] input-posted sessionId=${RECORDER_SESSION_ID:0:8} bytes=${INPUT_BYTES} filtered-from-records=${FILTERED_COUNT}" >&2
162
+ 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
199
163
 
200
164
  END_MS=$(python3 -c 'import time; print(int(time.time()*1000))')
201
165
  ELAPSED=$((END_MS - START_MS))
@@ -23,7 +23,7 @@ import {
23
23
  // renderConfigYml
24
24
  // ---------------------------------------------------------------------------
25
25
 
26
- test("renderConfigYml: HTTPS-only matches pre-Task-009 shape byte-for-byte", () => {
26
+ test("renderConfigYml: HTTPS-only matches expected shape byte-for-byte", () => {
27
27
  const spec: IngressSpec = {
28
28
  tunnelId: "abc-123",
29
29
  credentialsPath: "/home/admin/.maxy/cloudflared/abc-123.json",
@@ -99,7 +99,7 @@ test("renderConfigYml: empty string hostnames are treated as absent", () => {
99
99
  // renderTunnelState
100
100
  // ---------------------------------------------------------------------------
101
101
 
102
- test("renderTunnelState: pre-Task-009 fields preserved when ssh/smb absent", () => {
102
+ test("renderTunnelState: base fields preserved when ssh/smb absent", () => {
103
103
  const state: TunnelState = {
104
104
  tunnelId: "abc",
105
105
  tunnelName: "maxy",
@@ -55,11 +55,10 @@ import { fileURLToPath } from "node:url";
55
55
  // source-of-truth contract:
56
56
  // Runtime: brand.json `cdpPort` at `${MAXY_PLATFORM_ROOT}/config/brand.json`
57
57
  // is authoritative. Wrapper exports MAXY_PLATFORM_ROOT and BRAND. Missing
58
- // env / file / field → loud-fail with one of three named reasons. The
59
- // pre-Task-954 silent CDP-port default made every non-Maxy brand fail
60
- // `cdp-unreachable` because the helper hit Maxy's port instead of the
61
- // brand's / NEO4J_URI sets the precedent that runtime config
62
- // never falls back silently.
58
+ // env / file / field → loud-fail with one of three named reasons. A
59
+ // silent CDP-port default would make every non-default brand fail
60
+ // `cdp-unreachable`; runtime config never falls back silently
61
+ // (NEO4J_URI sets the same precedent).
63
62
  //
64
63
  // Test overrides: when BOTH `LIST_CF_DOMAINS_CDP_HOST` and
65
64
  // `LIST_CF_DOMAINS_CDP_PORT` are set, they win over brand.json. The
@@ -531,10 +530,9 @@ async function dumpHtml(
531
530
  // truncated mid-document. The false positive (outerHTML exactly at the
532
531
  // ceiling) is a rounding coincidence on the order of 1-in-5M and is
533
532
  // acceptable — a loud truncation signal with a ~10^-7 false-positive
534
- // rate is strictly better than silent truncation (the pre-Task-615
535
- // failure mode at the 100 KB ceiling, which silently broke every scrape
536
- // for five months). A downstream investigator seeing `truncated=true`
537
- // can re-scrape with a larger cap or `outerHTML` unsliced.
533
+ // rate is strictly better than silent truncation. A downstream
534
+ // investigator seeing `truncated=true` can re-scrape with a larger
535
+ // cap or `outerHTML` unsliced.
538
536
  const truncated = htmlStr.length >= OUTER_HTML_CAPTURE_MAX_CHARS;
539
537
  return { path: dumpPath, truncated };
540
538
  } catch (err) {
@@ -358,8 +358,8 @@ if [ ! -f "${CFG_DIR}/cert.pem" ]; then
358
358
  SPAWN_STDERR="$(tr '\n' ' ' < "${CHROMIUM_SPAWN_ERR}" | head -c 300 || echo unavailable)"
359
359
  rm -f "${CHROMIUM_SPAWN_ERR}"
360
360
  # Loud-fail rather than fire-and-forget fallback: a systemd-run failure
361
- # is the same class as the pre-Task-982 orphan (no teardown handle).
362
- # Operator should see the bus-not-running / linger-not-enabled cause.
361
+ # leaves no teardown handle. Operator should see the bus-not-running
362
+ # / linger-not-enabled cause.
363
363
  phase_line setup-tunnel step=browser-spawn result=error \
364
364
  reason=systemd-run-failed exit="${SPAWN_RC}" stderr="${SPAWN_STDERR}" \
365
365
  unit="${CHROMIUM_UNIT}"
@@ -456,15 +456,13 @@ fi
456
456
  # --------------------------------------------------------------------------
457
457
  # Step 2+3: Resolve the tunnel identity from operator input.
458
458
  #
459
- # Pre-Task-886 the script derived TUNNEL_NAME locally as "${BRAND}-$(hostname
460
- # -s)" and reused-or-created. That breaks the same doctrine as pre-Task-589
461
- # zone selection: the local hostname has no authority over which tunnel the
462
- # operator's logged-in Cloudflare account holds. A renamed device produced a
463
- # new tunnel; existing CNAMEs continued to point at the old one; the chat
464
- # said "Done. tunnel=maxy-maxytest" while the operator's `maxytest` hostname
465
- # kept resolving via a stale orphan.
459
+ # The local hostname has no authority over which tunnel the operator's
460
+ # logged-in Cloudflare account holds. Deriving TUNNEL_NAME locally (e.g.
461
+ # "${BRAND}-$(hostname -s)") and reusing-or-creating it produces a new
462
+ # tunnel when the device is renamed while existing CNAMEs continue to
463
+ # point at the old one.
466
464
  #
467
- # New contract: the form (rendered via /api/admin/cloudflare/tunnels list) is
465
+ # Contract: the form (rendered via /api/admin/cloudflare/tunnels list) is
468
466
  # the source of truth. The endpoint passes exactly one of:
469
467
  # TUNNEL_ID — operator selected an existing tunnel from the list
470
468
  # TUNNEL_NAME — operator typed a name to create a new tunnel
@@ -505,8 +503,8 @@ if [ -n "${TUNNEL_ID:-}" ]; then
505
503
  TUNNEL_SOURCE="operator-selected"
506
504
  else
507
505
  # Operator-create branch. Refuse to silently reuse an existing tunnel of
508
- # the same name — that recreates the pre-Task-886 silent-collision bug.
509
- # The operator should have picked it from the list.
506
+ # the same name — silent reuse hides which tunnel CNAMEs actually point
507
+ # at. The operator should have picked it from the list.
510
508
  EXISTING_ID="$(cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel list --output json 2>/dev/null \
511
509
  | jq -r --arg N "${TUNNEL_NAME}" '.[]? | select(.name == $N) | .id' | head -1)"
512
510
  if [ -n "${EXISTING_ID}" ] && [ "${EXISTING_ID}" != "null" ]; then
@@ -111,9 +111,9 @@ export function renderConfigYml(spec: IngressSpec): string {
111
111
 
112
112
  /**
113
113
  * Render the tunnel.state JSON, preserving SSH/SMB hostnames as additive
114
- * fields so re-runs can rehydrate them. The shape stays a superset of the
115
- * pre-Task-009 JSON (tunnelId, tunnelName, domain, configPath,
116
- * credentialsPath) — consumers that don't know about ssh/smb keep working.
114
+ * fields so re-runs can rehydrate them. The base shape (tunnelId,
115
+ * tunnelName, domain, configPath, credentialsPath) is a superset that
116
+ * consumers without ssh/smb knowledge continue to read correctly.
117
117
  */
118
118
  export function renderTunnelState(state: TunnelState): string {
119
119
  const out: Record<string, string> = {
@@ -163,24 +163,10 @@ Each installed brand runs two per-brand `--user` systemd units (earlier platform
163
163
  - `{hostname}.service` — the admin + public HTTP server on `127.0.0.1:19201` (public port + 1). Restarted by the upgrade flow; short downtime is expected during steps 8→11 of an upgrade. An earlier fix: the unit carries two port env vars — `PORT=<public>` (canonical public port, read by the upgrade detector) and `MAXY_UI_INTERNAL_PORT=<public+1>` (the port maxy-ui actually binds).
164
164
  - `{hostname}-edge.service` — the always-on public listener on the configured port (default 19200). Reverse-proxies HTTP to the main brand service and handles `/websockify` (VNC) WebSocket upgrades locally. An earlier fix: also hosts `/api/admin/actions/*` and `/api/admin/version*` — the Software Update modal's own routes — so the log stream survives the brand service's restart window. Does NOT restart during an upgrade — the browser WebSocket stays connected by construction.
165
165
 
166
- **Port-drift recovery.** Devices upgraded between Tasks 647 and 666 may have drifted +1 on every upgrade because the pre-Task-666 installer wrote `Environment=PORT=<internal>` into `{hostname}.service` and the upgrade reader correctly treated `PORT=` as public. The first post-Task-666 install detects this (comparing maxy's PORT against the edge's EDGE_PORT) and emits a one-shot loud log: `[port-recovery] detected drift maxy=<X> edge=<Y> — pinning at <Y>`. Subsequent upgrades are silent. If your Cloudflare tunnel was pointing at a drifted port, the ingress `config.yml` still needs a one-time manual fix: `sed -i 's|localhost:<old>|localhost:<current>|' ~/.{configDir}/cloudflared/config.yml && cloudflared tunnel ingress validate`. {{productName}} never rewrites cloudflared config programmatically.
167
-
168
166
  Upgrade and Cloudflare setup run as detached actions: `systemd-run --user` transient units per invocation with stdout+stderr persisted to `~/.maxy/logs/actions/<actionId>.log` and streamed to the UI via SSE. No boot-time service file exists for these.
169
167
 
170
168
  If an action looks stuck, read `~/.maxy/logs/actions/<actionId>.log` directly for the full output, or `journalctl --user --identifier=maxy-action-<actionId>` for systemd's record.
171
169
 
172
- **Pre-Task-662 / pre-Task-664 upgrade** — devices that ran an installer have legacy shared `maxy-edge.service` / `maxy-ttyd.service` units; devices that ran have per-brand `{hostname}-ttyd.service` units plus a pinned `/usr/local/bin/ttyd` binary. Neither is removed automatically — do this cleanup once per device before re-running any installer:
173
-
174
- ```bash
175
- systemctl --user stop maxy-edge maxy-ttyd realagent-ttyd 2>/dev/null || true
176
- systemctl --user disable maxy-edge maxy-ttyd realagent-ttyd 2>/dev/null || true
177
- rm -f ~/.config/systemd/user/maxy-edge.service \
178
- ~/.config/systemd/user/maxy-ttyd.service \
179
- ~/.config/systemd/user/realagent-ttyd.service
180
- sudo rm -f /usr/local/bin/ttyd
181
- systemctl --user daemon-reload
182
- ```
183
-
184
170
  ## Linux laptops: snap-confined Chromium replacement
185
171
 
186
172
  On Ubuntu 24.04 (Noble) the system Chromium binary at `/usr/bin/chromium` is a symlink into the snap. Snap's AppArmor profile denies writes to hidden directories under your home folder, so the per-brand Chromium profile at `~/.{brand}/chromium-profile/` is unwritable and the VNC browser never starts. Pi installs (Debian Bookworm) are unaffected because Bookworm ships a real `.deb` chromium.
@@ -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
 
@@ -70,22 +70,24 @@ There is no dashboard, no settings panel, no menus. Everything is done through c
70
70
 
71
71
  The chat input auto-grows as you type — it expands to fit your message and shrinks back when you delete text. You can also drag the resize handle above the input to set a custom height.
72
72
 
73
- The admin interface is a three-pane layout: a sidebar on the left with navigation (Sessions, People, Agents, Projects, Tasks, Artefacts) and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu, holding the surface side-by-side with the conversation so the chat stays live while you work in it. At the very top of the sidebar — above the nav rows — a borderless row holds two controls: a "+ New session" button on the left that spawns a fresh Claude Code session, and a Mode trigger on the right showing the current permission mode (Ask, Accept edits, Plan, or Auto). The sidebar's vertical order is: new-session strip first, then the nav (Sessions, People, Agents, Projects, Tasks, Artefacts), then the sessions list, then the footer. Both controls render as plain text-plus-icon affordances with no surrounding rectangle. The "+ New session" button is a text-width hit target — its clickable area is exactly the icon plus label, not the whole row — and shows no hover fill; the only hover feedback is the pointer cursor. The Mode trigger is pushed flush to the right edge of the row. Clicking the Mode trigger opens a popover downward from the row whose header reads "Mode" and lists the four permission modes with the current selection check-marked. The sidebar's nav rows swap the list view in place: Sessions shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). Each recent session row carries a three-state indicator: three pulsing dots when the session is busy (currently processing a turn), a solid sage dot when it is idle (live PTY waiting for input), and a hollow ring when it is archived (PTY exited, JSONL on disk for audit). The list itself splits into three views via a segmented control above the rows: **Active** shows every live session, **Archived** shows every JSONL on disk whose PTY has exited, and **All** shows both. The view choice persists across reloads. An "Include subagents" toggle inside the Active view surfaces specialist spawns (the database-operator recorder, premium-plugin agents, anything spawned with a `--agent` flag) which are hidden by default so the list reflects what you started directly. Each row also carries a small uppercase badge — `admin` for operator-driven sessions, the specialist name (for example `db-op`) for background work — so the source of any row is unambiguous at a glance. The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list, because the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable: type in the document and changes save automatically; specialist agent templates are read-only because they ship with Maxy and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood; clicking a second project swaps the focus rather than stacking on top. The chat and artefact divider is drag-resizable: drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat and artefact min-width floors. Your chosen width is remembered across reloads. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On every viewport the chat header reads left to right as a triptych: a dedicated sidebar toggle (the panel-right icon, which swaps to panel-right-open when the sidebar is showing), the brand mark next to the title in the centre, and the menu burger on the right. This header toggle is the sole sidebar-toggle button; the sidebar itself no longer carries a duplicate. Tap the sidebar toggle to show or hide the sidebar: on phones (<720px) it slides the drawer in or out, on wider screens it collapses or expands the sidebar column. The brand mark in the centre is decorative; clicks go through the dedicated toggle so the affordance is unambiguous. The drawer animation only fires on tap (220ms slide in or out); resizing your window across the 720px boundary snaps the layout without animation, so you never see a half-open flash. At ≤640px the session metadata pane stacks each label above its value instead of the desktop two-column grid, and the row of action buttons (Open in new tab / Open JSONL / Rename / Pin / Archive / End or Purge) collapses behind a single Actions trigger that opens a popover upward from the foot of the pane. Breakpoint summary: >1280px = full sidebar + chat + artefact pane (drag-resizable divider); 1280px→1080px = sidebar narrows; 1080px→820px = artefact pane hides (Browser/Data/Graph open as full-window pages instead); 820px→720px = sidebar collapses to 56px icon rail; ≤720px = sidebar becomes off-canvas drawer (vertical stack of nav, recents list, foot, the same shape as the desktop sidebar, just on top of the chat instead of beside it).
73
+ The admin interface is a three-pane layout: a sidebar on the left with navigation (Sessions, People, Agents, Projects, Tasks, Artefacts) and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu, holding the surface side-by-side with the conversation so the chat stays live while you work in it. At the very top of the sidebar — above the nav rows — a borderless row holds two controls: a "+ New session" button on the left that spawns a fresh Claude Code session, and a Mode trigger on the right showing the current permission mode (Ask, Accept edits, Plan, or Auto). The sidebar's vertical order is: new-session strip first, then the nav (Sessions, People, Agents, Projects, Tasks, Artefacts), then the sessions list, then the footer. Both controls render as plain text-plus-icon affordances with no surrounding rectangle. The "+ New session" button is a text-width hit target — its clickable area is exactly the icon plus label, not the whole row — and shows no hover fill; the only hover feedback is the pointer cursor. The Mode trigger is pushed flush to the right edge of the row. Clicking the Mode trigger opens a popover downward from the row whose header reads "Mode" and lists the four permission modes with the current selection check-marked. The sidebar's nav rows swap the list view in place: Sessions shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). Each recent session row carries a three-state indicator: three pulsing dots when the session is busy (currently processing a turn), a solid sage dot when it is idle (live PTY waiting for input), and a hollow ring when it is archived (PTY exited, JSONL on disk for audit). The list itself splits into three views via a segmented control above the rows: **Active** shows every live session, **Archived** shows every JSONL on disk whose PTY has exited, and **All** shows both. The view choice persists across reloads. An "Include subagents" toggle inside the Active view surfaces specialist spawns (the database-operator recorder, premium-plugin agents, anything spawned with a `--agent` flag) which are hidden by default so the list reflects what you started directly. Each row also carries a small uppercase badge — `admin` for operator-driven sessions, the specialist name (for example `db-op`) for background work — so the source of any row is unambiguous at a glance. The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list, because the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable: type in the document and changes save automatically; specialist agent templates are read-only because they ship with Maxy and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood; clicking a second project swaps the focus rather than stacking on top. The sidebar's right edge is drag-resizable on every admin page (Sessions root, Graph, and Data): drag the handle to widen or narrow the sidebar, and your chosen width is remembered across reloads and shared across all three pages. The drag handle is mounted by each AdminShell consumer rather than by AdminShell itself, so any new admin route must include `<SidebarSplitter />` as a direct child of its `<AdminShell>` to pick up the shared width. The chat and artefact divider is also drag-resizable: drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat and artefact min-width floors. Your chosen width is remembered across reloads. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On every viewport the chat header reads left to right as a triptych: a dedicated sidebar toggle (the panel-right icon, which swaps to panel-right-open when the sidebar is showing), the brand mark next to the title in the centre, and the menu burger on the right. This header toggle is the sole sidebar-toggle button; the sidebar itself no longer carries a duplicate. Tap the sidebar toggle to show or hide the sidebar: on phones (<720px) it slides the drawer in or out, on wider screens it collapses or expands the sidebar column. The brand mark in the centre is decorative; clicks go through the dedicated toggle so the affordance is unambiguous. The drawer animation only fires on tap (220ms slide in or out); resizing your window across the 720px boundary snaps the layout without animation, so you never see a half-open flash. At ≤640px the session metadata pane stacks each label above its value instead of the desktop two-column grid, and the row of action buttons (Open in new tab / Open JSONL / Rename / Pin / Archive / End or Purge) collapses behind a single Actions trigger that opens a popover upward from the foot of the pane. Breakpoint summary: >1280px = full sidebar + chat + artefact pane (drag-resizable divider); 1280px→1080px = sidebar narrows; 1080px→820px = artefact pane hides (Browser/Data/Graph open as full-window pages instead); 820px→720px = sidebar collapses to 56px icon rail; ≤720px = sidebar becomes off-canvas drawer (vertical stack of nav, recents list, foot, the same shape as the desktop sidebar, just on top of the chat instead of beside it).
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. The refresh icon at the top of the Sessions list is the operator-recoverable reconcile path against any SSE gap: it fetches `/api/admin/claude-sessions` and passes the authoritative id set to the row store, which evicts any indexed row that the server no longer reports. SSE replay only re-asserts currently-indexed rows and never emits `row-removed` for a row that vanished while disconnected, so without this manual surface a stale row can persist until the operator reloads the tab. Each click logs `[admin-ui] session-row-store reconcile evicted=<n> kept=<n>` when at least one row is evicted, and is silent otherwise.
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
@@ -73,7 +73,7 @@ Brand decides which premium plugins ship. Maxy installs ship only `platform/plug
73
73
  | Plugin | Type | What it does | Public agent |
74
74
  |--------|------|-------------|-------------|
75
75
  | `teaching` | Skills | Interactive tutoring, lesson planning, and study pack generation from your knowledge base | Yes — all 3 skills serve students and parents |
76
- | `real-agent` | Bundle (10 sub-plugins) | UK estate agency skills — sales, listings, vendor management, buyer management, lead generation, coaching, business operations, teaching, Loop CRM, and property brochures. 3 specialist roles (negotiator, valuer, compliance) | 4 sub-plugins (estate-sales, buyers, estate-coaching, estate-teaching) |
76
+ | `real-agent` | Bundle (10 sub-plugins) | UK estate agency skills — sales, listings, vendor management, buyer management, lead generation, coaching, business operations, teaching, Loop CRM (five value pillars: auto-respond, viewing lifecycle, pipeline mining, listings prospecting, maintenance & preferences), and property brochures. 3 specialist roles (negotiator, valuer, compliance) | 4 sub-plugins (estate-sales, buyers, estate-coaching, estate-teaching) |
77
77
  | `writer-craft` | Skills + Agent | Manuscript review and writing craft — story architecture, reader engagement, prose craft, editorial practice, and multi-level review | No — writing craft serves the author |
78
78
 
79
79
  **How it works:** Every boot {{productName}} delivers the brand's premium plugins from staging into `platform/plugins/` and stamps `enabledPlugins` against what is actually on disk. No conversation needed — the brand's full set is active from the first turn after install. Updates and reinstalls re-deliver from staging.
@@ -178,7 +178,7 @@ If the initial Cloudflare login fails during setup, {{productName}} will fall ba
178
178
 
179
179
  **Continuous `err-class=econnrefused-coldstart` for >30 s past the last `[edge] listening` line** indicates the upstream never binds — the upgrade or boot has stalled. Recover via `sudo systemctl --user status maxy.service` and check the action runner log per the next section. Permanent-failure UI escalation (turning the holding page into an error after N seconds) is intentionally deferred.
180
180
 
181
- **The literal string `maxy-ui` should never appear in `edge.log` or in any user-visible 502 body**, regardless of brand. If it does, the edge is running pre-Task-795 code — re-bundle and re-publish.
181
+ **The literal string `maxy-ui` should never appear in `edge.log` or in any user-visible 502 body**, regardless of brand. If it does, the edge is running stale code — re-bundle and re-publish.
182
182
 
183
183
  **Verifying the holding page locally:** `curl -sS -H 'Accept: text/html' http://127.0.0.1:<EDGE_PORT>/` while `maxy.service` is stopped should return HTML containing the brand `productName`. The `Accept: text/html` header is required — non-html clients (default `curl`, `fetch`, XHR) get the branded plain-text 502 instead, so the holding page's own `/api/health` polls don't break themselves during cold-start.
184
184
 
@@ -262,7 +262,7 @@ Failure modes:
262
262
 
263
263
  ## Software Update click shows an error instead of opening the terminal
264
264
 
265
- > **Stale content replaced the VNC-terminal surface with byte-stream xterm.js over `/ttyd`.** The VNC launch-upgrade path described below no longer exists. First-line diagnostic for the new surface: `sudo systemctl --user status maxy-ttyd` plus `sudo grep 'ttyd-proxy' ~/.maxy/logs/edge-boot.log | tail -20`. Failure mode signals: `ttyd-ws-upgrade accepted` with no `ttyd-proxy-open` → `maxy-ttyd.service` is down; `ttyd-proxy-open` with no `ttyd-proxy-chunk dir=upstream→client` → ttyd/tmux is not attaching a PTY. Full rewrite tracked. The section below is kept only as a historical reference for devices still on pre-Task-657 bundles.
265
+ > **First-line diagnostic for the byte-stream xterm.js terminal surface:** `sudo systemctl --user status maxy-ttyd` plus `sudo grep 'ttyd-proxy' ~/.maxy/logs/edge-boot.log | tail -20`. Failure mode signals: `ttyd-ws-upgrade accepted` with no `ttyd-proxy-open` → `maxy-ttyd.service` is down; `ttyd-proxy-open` with no `ttyd-proxy-chunk dir=upstream→client` → ttyd/tmux is not attaching a PTY.
266
266
 
267
267
  **Symptom:** You clicked **Upgrade** in the Software Update modal, but instead of the VNC terminal overlay appearing, the modal shows a red error row like:
268
268
 
@@ -337,7 +337,7 @@ sudo grep 'started.*reason=upgrade' ~/.maxy/logs/terminal-launch.log | tail -3
337
337
  # Expected shape: started pid=<N> display=:99 cmd="/usr/bin/xterm... -e bash -c 'npx -y @rubytech/create-maxy@latest; exec bash'" transport=vnc windowPresent=true reason=upgrade
338
338
  ```
339
339
 
340
- If the `cmd=` field does not contain `-e bash -c`, re-run the installer — the vnc.sh on the device is pre-Task-643. If the command IS logged correctly but nothing is running, open the VNC overlay and type `history | tail` inside the shell — if the npx line is there, it ran and exited (check `~/.maxy/logs/install-*.log` for the exit status).
340
+ If the `cmd=` field does not contain `-e bash -c`, re-run the installer — the vnc.sh on the device is stale. If the command IS logged correctly but nothing is running, open the VNC overlay and type `history | tail` inside the shell — if the npx line is there, it ran and exited (check `~/.maxy/logs/install-*.log` for the exit status).
341
341
 
342
342
  ---
343
343
 
@@ -533,9 +533,9 @@ sudo systemctl --user start maxy-ui
533
533
 
534
534
  ## Admin DevTools console floods with `onboarding-banner-mount` or `sessions-poll` lines
535
535
 
536
- **Symptom (pre-Task-050).** Open DevTools on the admin shell at `/` with `onboardingComplete=false`, leave the page idle for a minute, then scroll back through the console. You would see thousands of `[admin-ui] onboarding-banner-mount onboardingComplete=false` lines (one per AdminShell render, roughly 40/min driven by the 3s sessions poll) and no per-tick poll telemetry at all.
536
+ **Regression symptom.** Open DevTools on the admin shell at `/` with `onboardingComplete=false`, leave the page idle for a minute, then scroll back through the console. Thousands of `[admin-ui] onboarding-banner-mount onboardingComplete=false` lines (one per AdminShell render, ~40/min driven by the 3s sessions poll) with no per-tick poll telemetry indicates the banner-mount log has regressed back into the render body.
537
537
 
538
- **Steady-state invariants at `/` (post-Task-067):**
538
+ **Steady-state invariants at `/`:**
539
539
 
540
540
  - `grep -c '\[admin-ui\] onboarding-banner-mount' ~/.maxy/logs/admin-ui-console.log` equals page-load count plus onboarding-flip count, not the render count. Sustained climb at idle means the banner mount log regressed back into the render body (fix).
541
541
  - `grep -c '\[admin-ui\] sessions-poll' ~/.maxy/logs/admin-ui-console.log` over a 60-minute idle window equals zero. The hook no longer installs a `setInterval`; every `sessions-poll` line is operator-triggered (initial mount, refresh button, post-mutation refetch). One or more lines during operator idle means `setInterval` was reinstated.
@@ -18,7 +18,13 @@ tools:
18
18
  publicAllowlist: false
19
19
  - name: loop-property-listed
20
20
  publicAllowlist: false
21
- - name: loop-property-request
21
+ - name: loop-property-sold
22
+ publicAllowlist: false
23
+ - name: loop-property-viewing
24
+ publicAllowlist: false
25
+ - name: loop-property-callback
26
+ publicAllowlist: false
27
+ - name: loop-property-information
22
28
  publicAllowlist: false
23
29
  - name: loop-viewing-search
24
30
  publicAllowlist: false
@@ -44,6 +50,8 @@ tools:
44
50
  publicAllowlist: false
45
51
  - name: loop-marketing-enquiry
46
52
  publicAllowlist: false
53
+ - name: loop-auto-responder
54
+ publicAllowlist: false
47
55
  - name: loop-customer-preferences
48
56
  publicAllowlist: false
49
57
  - name: loop-supplier
@@ -112,20 +120,67 @@ Never include API key values in responses. Identify teams by name only. When the
112
120
 
113
121
  ## When to use these tools
114
122
 
115
- Use Loop tools when the user asks about CRM data: properties, people, viewings, feedback, team info, marketing matches, customer preferences, or supplier operations.
123
+ Loop covers five value pillars with API surface. Each pillar has both read and write triggers — use reads to surface what's already in Loop unprompted, use writes when the operator's intent is to record a decision. See `.docs/loop-value-extraction.md` for the verbatim Loop value claims and swagger endpoint citations behind each pillar.
124
+
125
+ ### Pillar 1 — Auto-respond to portal enquiries (Loop's stated 40% efficiency lever)
126
+
127
+ **Read triggers** — operator says "what enquiries came in", "any new enquiries", "did we get anything from Rightmove":
128
+ - `loop-auto-responder` (action=get) — fetch enquiry questions + applicant details
129
+
130
+ **Write triggers** — operator says "answer the enquiry", "send the auto-responder reply", "refer it to <team>":
131
+ - `loop-auto-responder` (action=answers) — submit applicant answers
132
+ - `loop-auto-responder` (action=details) — update enquiry record
133
+ - `loop-auto-responder` (action=refer) — refer enquiry to another team
134
+ - `loop-marketing-enquiry` — submit a seller-side enquiry
135
+
136
+ ### Pillar 2 — Viewing lifecycle (book → feedback → notes)
137
+
138
+ **Read triggers** — "what viewings are coming up", "any viewings without feedback yet", "did we hear back from <name>":
139
+ - `loop-viewing-search`, `loop-viewing-detail`, `loop-feedback-get`
140
+
141
+ **Write triggers** — "book a viewing", "the viewer said X", "the seller wants Y", "add a note":
142
+ - `loop-viewing-create` — book a viewing
143
+ - `loop-viewing-update` — record buyer/renter feedback, seller/landlord feedback, or a note
144
+ - `loop-feedback-submit` — explicit feedback submission against a viewing
145
+
146
+ ### Pillar 3 — Pipeline mining (unconverted enquiries, stale matches)
147
+
148
+ **Read triggers** — "who haven't we followed up", "any matches we haven't actioned", "find people looking in <area>", "show me buyers":
149
+ - `loop-people-search`, `loop-people-detail`
150
+ - `loop-marketing-match`, `loop-marketing-match-batch`
151
+
152
+ **Write triggers** — "request a viewing for the match", "ask them to call", "send them more info":
153
+ - `loop-marketing-match-request`
154
+
155
+ ### Pillar 4 — Listings prospecting (canvassing + competitive sweep)
156
+
157
+ **Read triggers** — "what have we sold on <street>", "what's listed on Rightmove", "give me canvassing material for <postcode>":
158
+ - `loop-property-sold` — exchanged/completed properties by channel (canvassing source)
159
+ - `loop-property-listed` — currently active listings by channel
160
+ - `loop-property-search`, `loop-property-detail`
161
+
162
+ **Write triggers** — "request a viewing", "ask them to call back", "request a brochure":
163
+ - `loop-property-viewing` — viewing request against a specific property
164
+ - `loop-property-callback` — callback request
165
+ - `loop-property-information` — information/brochure request
166
+
167
+ ### Pillar 5 — Maintenance closeout and customer preferences
168
+
169
+ **Read triggers** — "what does <person> want", "any open maintenance jobs", "any quotes outstanding":
170
+ - `loop-customer-preferences` (action=read)
171
+ - `loop-supplier` (action=maintenance-jobs, maintenance-quotes, board-jobs)
172
+
173
+ **Write triggers** — "save what they're looking for", "mark the job complete", "submit a quote":
174
+ - `loop-customer-preferences` (action=write)
175
+ - `loop-supplier` (action=maintenance-complete, board-complete, maintenance-submit-quote)
176
+
177
+ ### Team-key prerequisite
116
178
 
117
- **Write triggersinvoke writes (not reads) when the user says:**
118
- - "submit", "send", "log", "record" → `loop-feedback-submit`, `loop-marketing-enquiry`, `loop-property-request`
119
- - "create", "book", "schedule", "set up" a viewing → `loop-viewing-create`
120
- - "add a note", "leave feedback", "record what they said" → `loop-viewing-update`
121
- - "request a callback", "request more info", "ask for a viewing" → `loop-property-request` or `loop-marketing-match-request`
122
- - "update preferences", "save what they're looking for" → `loop-customer-preferences` (action=write)
123
- - "mark complete", "close out", "finished the job" → `loop-supplier` (action=maintenance-complete or board-complete)
124
- - "submit a quote" → `loop-supplier` (action=maintenance-submit-quote)
179
+ 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"]`).
125
180
 
126
- Default to writes when the user's intent is to *change* data in Loop, not just look it up. Reads return information; writes record the user's decision in Loop.
181
+ ### What Loop markets without an API surface (do not promise these)
127
182
 
128
- Write operations require specifying which team to act through. If no teams are registered, suggest using `loop-key-register` to add team API keys. If a write fails with a permission error (`Team "X" does not have <group> permission`), the registered key was scoped to fewer than 8 endpoint groups — re-register with the full permissions array (`["properties","people","viewings","feedback","team","marketing","customer","supplier"]`).
183
+ Loop's marketing pages also list Tasks/Reminders, full Diary beyond `team/availability`, Market Data (distressed properties, instructed sellers), Inspections & Certificates, and Client Accounts. Loop's public V2 API does not expose these Real Agent cannot do them. See `.docs/loop-api-gaps.md` for the full inventory.
129
184
 
130
185
  ## Cross-cutting skills hosted here
131
186