@rubytech/create-maxy-code 0.1.45 → 0.1.49

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 (158) hide show
  1. package/dist/__tests__/premium-mcp-discover.test.js +85 -0
  2. package/dist/index.js +25 -16
  3. package/dist/lib/premium-mcp-discover.js +34 -0
  4. package/package.json +1 -1
  5. package/payload/platform/lib/admins-write/dist/index.d.ts +0 -1
  6. package/payload/platform/lib/admins-write/dist/index.d.ts.map +1 -1
  7. package/payload/platform/lib/admins-write/dist/index.js +2 -5
  8. package/payload/platform/lib/admins-write/dist/index.js.map +1 -1
  9. package/payload/platform/lib/admins-write/src/index.ts +2 -8
  10. package/payload/platform/lib/graph-mcp/dist/index.js +1 -4
  11. package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
  12. package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts +7 -6
  13. package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts.map +1 -1
  14. package/payload/platform/lib/graph-mcp/dist/schema-cache.js +7 -6
  15. package/payload/platform/lib/graph-mcp/dist/schema-cache.js.map +1 -1
  16. package/payload/platform/lib/graph-mcp/src/index.ts +1 -4
  17. package/payload/platform/lib/graph-mcp/src/schema-cache.ts +7 -6
  18. package/payload/platform/lib/graph-search/dist/index.d.ts +5 -5
  19. package/payload/platform/lib/graph-search/dist/index.js +4 -4
  20. package/payload/platform/lib/graph-search/src/__tests__/fulltext-coverage.test.ts +6 -7
  21. package/payload/platform/lib/graph-search/src/index.ts +5 -5
  22. package/payload/platform/lib/graph-trash/dist/index.d.ts +8 -12
  23. package/payload/platform/lib/graph-trash/dist/index.d.ts.map +1 -1
  24. package/payload/platform/lib/graph-trash/dist/index.js +8 -12
  25. package/payload/platform/lib/graph-trash/dist/index.js.map +1 -1
  26. package/payload/platform/lib/graph-trash/src/index.ts +8 -12
  27. package/payload/platform/lib/graph-write/dist/index.d.ts +8 -9
  28. package/payload/platform/lib/graph-write/dist/index.d.ts.map +1 -1
  29. package/payload/platform/lib/graph-write/dist/index.js +9 -10
  30. package/payload/platform/lib/graph-write/dist/index.js.map +1 -1
  31. package/payload/platform/lib/graph-write/src/index.ts +9 -10
  32. package/payload/platform/lib/mcp-eager/dist/index.d.ts +4 -4
  33. package/payload/platform/lib/mcp-eager/dist/index.js +4 -4
  34. package/payload/platform/lib/mcp-eager/src/index.ts +4 -4
  35. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  36. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +56 -38
  37. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +42 -40
  38. package/payload/platform/plugins/cloudflare/scripts/__tests__/tunnel-ingress.test.ts +2 -2
  39. package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.ts +7 -9
  40. package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +10 -12
  41. package/payload/platform/plugins/cloudflare/scripts/tunnel-ingress.ts +3 -3
  42. package/payload/platform/plugins/docs/references/deployment.md +2 -14
  43. package/payload/platform/plugins/docs/references/platform.md +2 -2
  44. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  45. package/payload/platform/plugins/docs/references/troubleshooting.md +5 -5
  46. package/payload/platform/plugins/loop/PLUGIN.md +67 -12
  47. package/payload/platform/plugins/loop/mcp/dist/index.js +42 -16
  48. package/payload/platform/plugins/loop/mcp/dist/index.js.map +1 -1
  49. package/payload/platform/plugins/loop/mcp/dist/tools/auto-responder.d.ts +12 -0
  50. package/payload/platform/plugins/loop/mcp/dist/tools/auto-responder.d.ts.map +1 -0
  51. package/payload/platform/plugins/loop/mcp/dist/tools/auto-responder.js +22 -0
  52. package/payload/platform/plugins/loop/mcp/dist/tools/auto-responder.js.map +1 -0
  53. package/payload/platform/plugins/loop/mcp/dist/tools/marketing-enquiry.d.ts +0 -7
  54. package/payload/platform/plugins/loop/mcp/dist/tools/marketing-enquiry.d.ts.map +1 -1
  55. package/payload/platform/plugins/loop/mcp/dist/tools/marketing-enquiry.js +4 -38
  56. package/payload/platform/plugins/loop/mcp/dist/tools/marketing-enquiry.js.map +1 -1
  57. package/payload/platform/plugins/loop/mcp/dist/tools/property-intent.d.ts +33 -0
  58. package/payload/platform/plugins/loop/mcp/dist/tools/property-intent.d.ts.map +1 -0
  59. package/payload/platform/plugins/loop/mcp/dist/tools/property-intent.js +19 -0
  60. package/payload/platform/plugins/loop/mcp/dist/tools/property-intent.js.map +1 -0
  61. package/payload/platform/plugins/loop/mcp/dist/tools/property-listed.d.ts +0 -1
  62. package/payload/platform/plugins/loop/mcp/dist/tools/property-listed.d.ts.map +1 -1
  63. package/payload/platform/plugins/loop/mcp/dist/tools/property-listed.js +1 -7
  64. package/payload/platform/plugins/loop/mcp/dist/tools/property-listed.js.map +1 -1
  65. package/payload/platform/plugins/loop/mcp/dist/tools/property-sold.d.ts +9 -0
  66. package/payload/platform/plugins/loop/mcp/dist/tools/property-sold.d.ts.map +1 -0
  67. package/payload/platform/plugins/loop/mcp/dist/tools/property-sold.js +19 -0
  68. package/payload/platform/plugins/loop/mcp/dist/tools/property-sold.js.map +1 -0
  69. package/payload/platform/plugins/loop/mcp/src/__tests__/new-tool-shape.test.ts +99 -0
  70. package/payload/platform/plugins/loop/mcp/src/__tests__/swagger-write-coverage.test.ts +12 -11
  71. package/payload/platform/plugins/loop/mcp/src/index.ts +68 -17
  72. package/payload/platform/plugins/loop/mcp/src/tools/auto-responder.ts +80 -0
  73. package/payload/platform/plugins/loop/mcp/src/tools/marketing-enquiry.ts +14 -100
  74. package/payload/platform/plugins/loop/mcp/src/tools/property-intent.ts +84 -0
  75. package/payload/platform/plugins/loop/mcp/src/tools/property-listed.ts +1 -8
  76. package/payload/platform/plugins/loop/mcp/src/tools/property-sold.ts +67 -0
  77. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/llm-classifier.test.js +4 -3
  78. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/llm-classifier.test.js.map +1 -1
  79. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.d.ts.map +1 -1
  80. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.js +7 -12
  81. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.js.map +1 -1
  82. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-not-applicable.test.js +2 -2
  83. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.js +4 -4
  84. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.js.map +1 -1
  85. package/payload/platform/plugins/tasks/mcp/dist/tools/session-name.d.ts.map +1 -1
  86. package/payload/platform/plugins/tasks/mcp/dist/tools/session-name.js +8 -9
  87. package/payload/platform/plugins/tasks/mcp/dist/tools/session-name.js.map +1 -1
  88. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-heal.d.ts +2 -2
  89. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-heal.js +2 -2
  90. package/payload/platform/scripts/admin-persist-audit.ts +4 -10
  91. package/payload/platform/scripts/conversation-id-allowlist.txt +0 -1
  92. package/payload/platform/scripts/lib/resolve-account-dir.sh +0 -2
  93. package/payload/platform/scripts/resume-tunnel.sh +2 -9
  94. package/payload/platform/scripts/seed-neo4j.sh +26 -104
  95. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  96. package/payload/platform/services/claude-session-manager/dist/http-server.js +53 -7
  97. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  98. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +0 -5
  99. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  100. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +13 -17
  101. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  102. package/payload/platform/templates/agents/admin/IDENTITY.md +10 -2
  103. package/payload/platform/templates/agents/admin/SOUL.md +23 -1
  104. package/payload/premium-plugins/real-agent/agents/buyer-enquiry/SOUL.md +10 -0
  105. package/payload/premium-plugins/real-agent/agents/compliance.md +1 -1
  106. package/payload/premium-plugins/real-agent/agents/negotiator.md +1 -1
  107. package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
  108. package/payload/premium-plugins/real-agent/plugins/loop/PLUGIN.md +67 -12
  109. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js +42 -16
  110. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js.map +1 -1
  111. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/auto-responder.d.ts +12 -0
  112. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/auto-responder.d.ts.map +1 -0
  113. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/auto-responder.js +22 -0
  114. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/auto-responder.js.map +1 -0
  115. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-enquiry.d.ts +0 -7
  116. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-enquiry.d.ts.map +1 -1
  117. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-enquiry.js +4 -38
  118. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-enquiry.js.map +1 -1
  119. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-intent.d.ts +33 -0
  120. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-intent.d.ts.map +1 -0
  121. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-intent.js +19 -0
  122. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-intent.js.map +1 -0
  123. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-listed.d.ts +0 -1
  124. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-listed.d.ts.map +1 -1
  125. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-listed.js +1 -7
  126. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-listed.js.map +1 -1
  127. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-sold.d.ts +9 -0
  128. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-sold.d.ts.map +1 -0
  129. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-sold.js +19 -0
  130. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-sold.js.map +1 -0
  131. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/__tests__/new-tool-shape.test.ts +99 -0
  132. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/__tests__/swagger-write-coverage.test.ts +12 -11
  133. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/index.ts +68 -17
  134. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/auto-responder.ts +80 -0
  135. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/marketing-enquiry.ts +14 -100
  136. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/property-intent.ts +84 -0
  137. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/property-listed.ts +1 -8
  138. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/property-sold.ts +67 -0
  139. package/payload/server/public/assets/admin-DBuCWSPS.js +216 -0
  140. package/payload/server/public/assets/{data-BxpOR0eM.js → data-BeblX4m4.js} +1 -1
  141. package/payload/server/public/assets/{graph-CrmxRSeb.js → graph-ZuBiYril.js} +1 -1
  142. package/payload/server/public/assets/graph-labels-CnbJR6cp.js +1 -0
  143. package/payload/server/public/assets/page-DDyny08O.js +50 -0
  144. package/payload/server/public/assets/page-nAY64ID7.js +1 -0
  145. package/payload/server/public/data.html +3 -3
  146. package/payload/server/public/graph.html +3 -3
  147. package/payload/server/public/index.html +4 -4
  148. package/payload/server/server.js +9 -46
  149. package/payload/platform/plugins/docs/references/migration-guide.md +0 -90
  150. package/payload/platform/plugins/loop/mcp/src/tools/property-request.ts +0 -42
  151. package/payload/platform/scripts/component-knowledgedoc-backfill.ts +0 -214
  152. package/payload/platform/scripts/migrate-import.sh +0 -437
  153. package/payload/platform/scripts/taskmaster-export.sh +0 -388
  154. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/property-request.ts +0 -42
  155. package/payload/server/public/assets/admin-DVZcLuYu.js +0 -216
  156. package/payload/server/public/assets/graph-labels-C8A6BmtP.js +0 -1
  157. package/payload/server/public/assets/page-D5wAeFU0.js +0 -50
  158. package/payload/server/public/assets/page-DR5NU6S0.js +0 -1
@@ -1,17 +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 (Task 106 redesign: headless one-shot).
3
+ # per completed admin-agent turn (Task 114 redesign: normal admin session +
4
+ # /input POST, replacing Task 106's `--print` + stdin-EOT shape which is
5
+ # incompatible with OAuth).
4
6
  #
5
7
  # Behaviour verified:
6
8
  # 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
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).
9
+ # 2. MAXY_HIDDEN_SPAWN=1 → exit 0, no POST attempt, no log line.
10
10
  # 3. Empty stdin → exit 0 silently.
11
11
  # 4. Missing transcript_path → exit 0 silently.
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>`
12
+ # 5. Happy path → exactly ONE POST to /spawn (no stdinPayload
13
+ # field) and exactly ONE POST to /<id>/input whose `text` carries the
14
+ # Task 114 instruction string and the filtered transcript, plus the
15
+ # `[turn-recorder] spawn-with-input sessionId=<8> bytes=<n> filtered-from-records=<n> input-http=200`
15
16
  # log line.
16
17
 
17
18
  set -u
@@ -71,11 +72,11 @@ elif [[ -n "$HOOK_STDERR" ]]; then fail "case-1 expected empty stderr, got: $HOO
71
72
  else pass "case-1 role=public → no fire (rc=0, stderr empty)"
72
73
  fi
73
74
 
74
- # --- Case 2: hidden=1 → no fire (Task 106 removed self-stop) -----------
75
+ # --- Case 2: hidden=1 → no fire ----------------------------------------
75
76
  run_hook "admin" "1" "$ENVELOPE"
76
77
  if [[ "$HOOK_RC" -ne 0 ]]; then fail "case-2 expected rc=0 got=$HOOK_RC"
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)"
78
+ elif [[ -n "$HOOK_STDERR" ]]; then fail "case-2 expected empty stderr, got: $HOOK_STDERR"
79
+ else pass "case-2 hidden=1 → silent"
79
80
  fi
80
81
 
81
82
  # --- Case 3: empty stdin → silent --------------------------------------
@@ -93,11 +94,7 @@ elif [[ -n "$HOOK_STDERR" ]]; then fail "case-4 expected empty stderr, got: $HOO
93
94
  else pass "case-4 missing transcript_path → silent (rc=0)"
94
95
  fi
95
96
 
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.
97
+ # --- Case 5: happy path → one /spawn (no stdinPayload) + one /<id>/input
101
98
  REQ_LOG=$(mktemp); TMPFILES+=("$REQ_LOG")
102
99
  LISTENER_PORT=39406
103
100
  python3 - "$LISTENER_PORT" "$REQ_LOG" <<'PY' &
@@ -114,7 +111,10 @@ class H(http.server.BaseHTTPRequestHandler):
114
111
  self.send_response(200)
115
112
  self.send_header('Content-Type','application/json')
116
113
  self.end_headers()
117
- self.wfile.write(json.dumps({"sessionId":"rec00001-feedfeed"}).encode('utf-8'))
114
+ if self.path == '/spawn':
115
+ self.wfile.write(json.dumps({"sessionId":"rec00001-feedfeed"}).encode('utf-8'))
116
+ else:
117
+ self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
118
118
  http.server.HTTPServer(('127.0.0.1', port), H).serve_forever()
119
119
  PY
120
120
  LISTENER_PIDS+=("$!")
@@ -141,22 +141,21 @@ if [[ "$HOOK_RC" -ne 0 ]]; then
141
141
  fail "case-5 expected rc=0 got=$HOOK_RC stderr=$HOOK_STDERR"
142
142
  fi
143
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"
144
+ # Assert: spawn-with-input log line emitted.
145
+ if ! echo "$HOOK_STDERR" | grep -qE '^\[turn-recorder\] spawn-with-input sessionId=rec00001 bytes=[0-9]+ filtered-from-records=[0-9]+ input-http=[0-9]+'; then
146
+ fail "case-5a expected spawn-with-input log line, got: $HOOK_STDERR"
147
147
  else
148
- pass "case-5a spawn-with-stdin log line emitted"
148
+ pass "case-5a spawn-with-input log line emitted"
149
149
  fi
150
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"
151
+ # Assert: no legacy spawn-with-stdin log line.
152
+ if echo "$HOOK_STDERR" | grep -q 'spawn-with-stdin'; then
153
+ fail "case-5b legacy spawn-with-stdin line must not be emitted in Task 114"
154
154
  else
155
- pass "case-5b legacy input-posted log line absent"
155
+ pass "case-5b legacy spawn-with-stdin log line absent"
156
156
  fi
157
157
 
158
- # Assert: listener saw exactly one /spawn POST whose body contains
159
- # stdinPayload, and ZERO /<id>/input POSTs.
158
+ # Assert: listener saw exactly one /spawn POST and exactly one /<id>/input POST.
160
159
  SPAWN_LINES=$(grep -c '^/spawn ' "$REQ_LOG" || true)
161
160
  INPUT_LINES=$(grep -cE '^/[A-Za-z0-9_-]+/input ' "$REQ_LOG" || true)
162
161
  STOP_LINES=$(grep -cE '^/[A-Za-z0-9_-]+/stop ' "$REQ_LOG" || true)
@@ -165,18 +164,18 @@ if [[ "$SPAWN_LINES" -ne 1 ]]; then
165
164
  else
166
165
  pass "case-5c exactly one /spawn POST observed"
167
166
  fi
168
- if [[ "$INPUT_LINES" -ne 0 ]]; then
169
- fail "case-5d expected ZERO /<id>/input POSTs, got $INPUT_LINES"
167
+ if [[ "$INPUT_LINES" -ne 1 ]]; then
168
+ fail "case-5d expected exactly 1 /<id>/input POST, got $INPUT_LINES"
170
169
  else
171
- pass "case-5d zero /<id>/input POSTs"
170
+ pass "case-5d exactly one /<id>/input POST observed"
172
171
  fi
173
172
  if [[ "$STOP_LINES" -ne 0 ]]; then
174
- fail "case-5e expected ZERO /<id>/stop POSTs, got $STOP_LINES"
173
+ fail "case-5e expected ZERO /<id>/stop POSTs from the hook (auto-archive is in-manager), got $STOP_LINES"
175
174
  else
176
175
  pass "case-5e zero /<id>/stop POSTs"
177
176
  fi
178
177
 
179
- # Assert: the /spawn body contains a stdinPayload field with non-empty value.
178
+ # Assert: the /spawn body has no stdinPayload field.
180
179
  SPAWN_BODY=$(grep '^/spawn ' "$REQ_LOG" | head -1 | cut -f2-)
181
180
  if [[ -z "$SPAWN_BODY" ]]; then
182
181
  fail "case-5f /spawn body was empty"
@@ -185,23 +184,42 @@ else
185
184
  import sys, json
186
185
  try:
187
186
  b = json.load(sys.stdin)
188
- sp = b.get("stdinPayload")
189
- print("yes" if isinstance(sp, str) and len(sp) > 0 else "no")
187
+ print("yes" if "stdinPayload" in b else "no")
190
188
  except Exception:
191
189
  print("parse-fail")
192
190
  ' 2>/dev/null)
193
- if [[ "$HAS_STDIN" != "yes" ]]; then
194
- fail "case-5f /spawn body must carry non-empty stdinPayload, got: $SPAWN_BODY"
191
+ if [[ "$HAS_STDIN" != "no" ]]; then
192
+ fail "case-5f /spawn body must NOT carry stdinPayload, got: $SPAWN_BODY"
195
193
  else
196
- pass "case-5f /spawn body carries non-empty stdinPayload"
194
+ pass "case-5f /spawn body has no stdinPayload field"
197
195
  fi
198
196
  fi
199
197
 
198
+ # Assert: the /input body's `text` carries the Task 114 instruction and the
199
+ # transcript section header.
200
+ INPUT_BODY=$(grep -E '^/[A-Za-z0-9_-]+/input ' "$REQ_LOG" | head -1 | cut -f2-)
201
+ INPUT_OK=$(printf '%s' "$INPUT_BODY" | python3 -c '
202
+ import sys, json
203
+ try:
204
+ b = json.load(sys.stdin)
205
+ t = b.get("text", "")
206
+ has_instr = "update the graph with any missing information or intent that can be derived from this conversation" in t
207
+ has_tx = "Conversation transcript:" in t
208
+ print("yes" if (has_instr and has_tx) else "no")
209
+ except Exception:
210
+ print("parse-fail")
211
+ ' 2>/dev/null)
212
+ if [[ "$INPUT_OK" != "yes" ]]; then
213
+ fail "case-5g /input body missing instruction or transcript header: $INPUT_BODY"
214
+ else
215
+ pass "case-5g /input body carries Task 114 instruction + transcript"
216
+ fi
217
+
200
218
  # Assert: final fired log line still emitted.
201
219
  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"
220
+ fail "case-5h expected fired summary log line, got: $HOOK_STDERR"
203
221
  else
204
- pass "case-5g fired summary log line emitted"
222
+ pass "case-5h fired summary log line emitted"
205
223
  fi
206
224
 
207
225
  # --- Summary ------------------------------------------------------------
@@ -1,23 +1,24 @@
1
1
  #!/usr/bin/env bash
2
2
  # Stop hook — fires on every completed admin-agent turn and dispatches one
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.
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 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.
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.
15
17
  #
16
18
  # Gating:
17
19
  # - MAXY_SESSION_ROLE must equal "admin"
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).
20
+ # - MAXY_HIDDEN_SPAWN must equal "0" (the recorder PTY itself runs with
21
+ # MAXY_HIDDEN_SPAWN=1; the guard prevents accidental recursion).
21
22
  #
22
23
  # Input: Claude Code's Stop hook stdin shape
23
24
  # { "session_id": "<intrinsic>", "transcript_path": "<jsonl path>", ... }
@@ -33,10 +34,6 @@ if [ "${MAXY_SESSION_ROLE:-}" != "admin" ]; then
33
34
  exit 0
34
35
  fi
35
36
  if [ "${MAXY_HIDDEN_SPAWN:-0}" = "1" ]; then
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.
40
37
  exit 0
41
38
  fi
42
39
 
@@ -106,36 +103,17 @@ FILTERED_COUNT=$(printf '%s' "$FILTERED_TAIL" | python3 -c 'import sys,json; d=j
106
103
  MANAGER_PORT="${CLAUDE_SESSION_MANAGER_PORT:-19400}"
107
104
  MANAGER_BASE="http://127.0.0.1:${MANAGER_PORT}"
108
105
 
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.
113
106
  SPAWN_BODY=$(python3 -c '
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
- )
107
+ import json
123
108
  body = {
124
109
  "senderId": "turn-recorder",
125
110
  "role": "admin",
126
111
  "channel": "browser",
127
112
  "hidden": True,
128
113
  "specialist": "database-operator",
129
- "stdinPayload": prompt,
130
114
  }
131
115
  print(json.dumps(body))
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)
116
+ ' 2>/dev/null)
139
117
 
140
118
  SPAWN_RES=$(curl -sS -X POST \
141
119
  -H 'Content-Type: application/json' \
@@ -157,7 +135,31 @@ if [ -z "$RECORDER_SESSION_ID" ]; then
157
135
  exit 0
158
136
  fi
159
137
 
160
- echo "[recorder] spawn-with-stdin sessionId=${RECORDER_SESSION_ID:0:8} bytes=${PAYLOAD_BYTES} filtered-from-records=${FILTERED_COUNT}" >&2
138
+ # Compose the input prompt and POST it to /<sessionId>/input. The instruction
139
+ # string is fixed (Task 114). The transcript is appended as a JSON array so
140
+ # the recorder reads it as a single user turn.
141
+ INPUT_BODY=$(python3 -c '
142
+ import sys, json
143
+ sid = sys.argv[1]
144
+ tail_json = sys.argv[2]
145
+ instruction = "update the graph with any missing information or intent that can be derived from this conversation"
146
+ text = (
147
+ f"{instruction}\n\n"
148
+ f"Conversation id: {sid}\n"
149
+ f"Conversation transcript:\n{tail_json}\n"
150
+ )
151
+ print(json.dumps({"text": text}))
152
+ ' "$ADMIN_SESSION_ID" "$FILTERED_TAIL" 2>/dev/null)
153
+
154
+ INPUT_BYTES=$(printf '%s' "$INPUT_BODY" | python3 -c 'import sys,json; b=json.load(sys.stdin); print(len(b.get("text","").encode("utf-8")))' 2>/dev/null || echo 0)
155
+
156
+ INPUT_HTTP=$(curl -sS -o /tmp/turn-recorder-input.out -w '%{http_code}' -X POST \
157
+ -H 'Content-Type: application/json' \
158
+ --max-time 10 \
159
+ --data "$INPUT_BODY" \
160
+ "${MANAGER_BASE}/${RECORDER_SESSION_ID}/input" 2>/dev/null || echo 000)
161
+
162
+ 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
161
163
 
162
164
  END_MS=$(python3 -c 'import time; print(int(time.time()*1000))')
163
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.
@@ -241,6 +227,8 @@ Skills, agents, hooks, and commands directories at the plugin root are auto-disc
241
227
 
242
228
  **Diagnostic path** — `grep "\[plugin-install\]" ~/.<brand>/logs/install-*.log | tail -50`; compare row count against `cat brand.json | jq '.externalPlugins | length'` plus the on-disk plugin count under `<INSTALL_DIR>/platform/plugins/` and `<INSTALL_DIR>/premium-plugins/`.
243
229
 
230
+ **Premium MCP dependency install** — Premium-plugin MCP servers ship `dist/` + `package.json` in the bundle but not `node_modules` (npm pack strips them, same as `server/`). `buildPlatform()` discovers every `<INSTALL_DIR>/premium-plugins/<bundle>/plugins/<plugin>/mcp/package.json` and runs `npm install --omit=dev` there, wiping any prior `node_modules` first. The summary log line `[install] premium-mcp-install dirs=<n>` is emitted before the loop runs, so `dirs=0` is itself a regression signal when a brand ships premium plugins.
231
+
244
232
  ## Running multiple brands on one device
245
233
 
246
234
  A single Pi or laptop can host more than one brand (for example Maxy and Real Agent) side by side. Each brand runs as its own service on its own port, with its own install directory and its own data. Installing one brand does not touch the other.
@@ -70,11 +70,11 @@ 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. 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.
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
 
@@ -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.