@rubytech/create-maxy-code 0.1.58 → 0.1.60

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 (182) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +30 -19
  3. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +35 -90
  4. package/payload/platform/plugins/docs/references/admin-session.md +17 -16
  5. package/payload/platform/plugins/docs/references/platform.md +3 -3
  6. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  7. package/payload/platform/plugins/loop/PLUGIN.md +13 -1
  8. package/payload/platform/plugins/loop/mcp/dist/index.js +8 -7
  9. package/payload/platform/plugins/loop/mcp/dist/index.js.map +1 -1
  10. package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.d.ts +3 -0
  11. package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  12. package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.js +48 -0
  13. package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.js.map +1 -0
  14. package/payload/platform/plugins/loop/mcp/dist/lib/key-store.d.ts +22 -0
  15. package/payload/platform/plugins/loop/mcp/dist/lib/key-store.d.ts.map +1 -0
  16. package/payload/platform/plugins/loop/mcp/dist/lib/key-store.js +165 -0
  17. package/payload/platform/plugins/loop/mcp/dist/lib/key-store.js.map +1 -0
  18. package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.d.ts +1 -1
  19. package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.d.ts.map +1 -1
  20. package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.js +4 -39
  21. package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.js.map +1 -1
  22. package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.d.ts.map +1 -1
  23. package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.js +5 -15
  24. package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.js.map +1 -1
  25. package/payload/platform/plugins/loop/mcp/dist/tools/key-register.d.ts.map +1 -1
  26. package/payload/platform/plugins/loop/mcp/dist/tools/key-register.js +11 -28
  27. package/payload/platform/plugins/loop/mcp/dist/tools/key-register.js.map +1 -1
  28. package/payload/platform/plugins/loop/mcp/src/index.ts +10 -7
  29. package/payload/platform/plugins/loop/mcp/src/lib/file-crypto.ts +54 -0
  30. package/payload/platform/plugins/loop/mcp/src/lib/key-store.ts +221 -0
  31. package/payload/platform/plugins/loop/mcp/src/lib/loop-api.ts +5 -42
  32. package/payload/platform/plugins/loop/mcp/src/tools/key-deregister.ts +5 -20
  33. package/payload/platform/plugins/loop/mcp/src/tools/key-register.ts +14 -39
  34. package/payload/platform/plugins/preval/.claude-plugin/plugin.json +8 -0
  35. package/payload/platform/plugins/preval/PLUGIN.md +39 -0
  36. package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +78 -0
  37. package/payload/platform/plugins/preval/skills/property-preval/references/render.py +265 -0
  38. package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +143 -0
  39. package/payload/platform/plugins/preval/skills/property-preval/references/template.html +253 -0
  40. package/payload/platform/plugins/property-data/.claude-plugin/plugin.json +1 -1
  41. package/payload/platform/plugins/property-data/PLUGIN.md +60 -2
  42. package/payload/platform/plugins/property-data/mcp/dist/index.js +95 -12
  43. package/payload/platform/plugins/property-data/mcp/dist/index.js.map +1 -1
  44. package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.d.ts +3 -0
  45. package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  46. package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.js +49 -0
  47. package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.js.map +1 -0
  48. package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.d.ts +15 -0
  49. package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.d.ts.map +1 -0
  50. package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.js +135 -0
  51. package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.js.map +1 -0
  52. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts +0 -1
  53. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts.map +1 -1
  54. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.js +11 -22
  55. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.js.map +1 -1
  56. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.d.ts +10 -0
  57. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.d.ts.map +1 -1
  58. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.js +15 -2
  59. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.js.map +1 -1
  60. package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.d.ts.map +1 -1
  61. package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.js +5 -15
  62. package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.js.map +1 -1
  63. package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.d.ts.map +1 -1
  64. package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.js +30 -23
  65. package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.js.map +1 -1
  66. package/payload/platform/plugins/property-data/mcp/src/__tests__/endpoints.test.ts +252 -0
  67. package/payload/platform/plugins/property-data/mcp/src/__tests__/schema-parity.test.ts +16 -2
  68. package/payload/platform/plugins/property-data/mcp/src/index.ts +171 -11
  69. package/payload/platform/plugins/property-data/mcp/src/lib/file-crypto.ts +55 -0
  70. package/payload/platform/plugins/property-data/mcp/src/lib/key-store.ts +181 -0
  71. package/payload/platform/plugins/property-data/mcp/src/lib/propertydata-api.ts +9 -24
  72. package/payload/platform/plugins/property-data/mcp/src/tools/endpoints.ts +22 -2
  73. package/payload/platform/plugins/property-data/mcp/src/tools/key-deregister.ts +5 -17
  74. package/payload/platform/plugins/property-data/mcp/src/tools/key-register.ts +29 -33
  75. package/payload/platform/plugins/property-data/skills/property-data/SKILL.md +19 -0
  76. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  77. package/payload/platform/services/claude-session-manager/dist/http-server.js +25 -260
  78. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  79. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +3 -7
  80. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
  81. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +6 -32
  82. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
  83. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +6 -24
  84. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  85. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +18 -91
  86. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  87. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
  88. package/payload/platform/services/claude-session-manager/dist/reaper.js +0 -11
  89. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
  90. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +0 -21
  91. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  92. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  93. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  94. package/payload/platform/templates/agents/admin/SOUL.md +1 -1
  95. package/payload/premium-plugins/real-agent/BUNDLE.md +5 -3
  96. package/payload/premium-plugins/real-agent/agents/negotiator.md +1 -1
  97. package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
  98. package/payload/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json +5 -0
  99. package/payload/premium-plugins/real-agent/plugins/loop/PLUGIN.md +13 -1
  100. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js +8 -7
  101. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js.map +1 -1
  102. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.d.ts +3 -0
  103. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  104. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.js +48 -0
  105. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.js.map +1 -0
  106. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.d.ts +22 -0
  107. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.d.ts.map +1 -0
  108. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.js +165 -0
  109. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.js.map +1 -0
  110. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.d.ts +1 -1
  111. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.d.ts.map +1 -1
  112. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.js +4 -39
  113. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.js.map +1 -1
  114. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.d.ts.map +1 -1
  115. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.js +5 -15
  116. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.js.map +1 -1
  117. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.d.ts.map +1 -1
  118. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.js +11 -28
  119. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.js.map +1 -1
  120. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/index.ts +10 -7
  121. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/file-crypto.ts +54 -0
  122. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/key-store.ts +221 -0
  123. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/loop-api.ts +5 -42
  124. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/key-deregister.ts +5 -20
  125. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/key-register.ts +14 -39
  126. package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +8 -0
  127. package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +39 -0
  128. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +78 -0
  129. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +265 -0
  130. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +143 -0
  131. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +253 -0
  132. package/payload/premium-plugins/real-agent/plugins/property-data/.claude-plugin/plugin.json +1 -1
  133. package/payload/premium-plugins/real-agent/plugins/property-data/PLUGIN.md +60 -2
  134. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js +95 -12
  135. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js.map +1 -1
  136. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.d.ts +3 -0
  137. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  138. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.js +49 -0
  139. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.js.map +1 -0
  140. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.d.ts +15 -0
  141. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.d.ts.map +1 -0
  142. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.js +135 -0
  143. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.js.map +1 -0
  144. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts +0 -1
  145. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts.map +1 -1
  146. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.js +11 -22
  147. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.js.map +1 -1
  148. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.d.ts +10 -0
  149. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.d.ts.map +1 -1
  150. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.js +15 -2
  151. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.js.map +1 -1
  152. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.d.ts.map +1 -1
  153. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.js +5 -15
  154. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.js.map +1 -1
  155. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.d.ts.map +1 -1
  156. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.js +30 -23
  157. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.js.map +1 -1
  158. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/__tests__/endpoints.test.ts +252 -0
  159. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/__tests__/schema-parity.test.ts +16 -2
  160. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/index.ts +171 -11
  161. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/file-crypto.ts +55 -0
  162. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/key-store.ts +181 -0
  163. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/propertydata-api.ts +9 -24
  164. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/endpoints.ts +22 -2
  165. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/key-deregister.ts +5 -17
  166. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/key-register.ts +29 -33
  167. package/payload/premium-plugins/real-agent/plugins/property-data/skills/property-data/SKILL.md +19 -0
  168. package/payload/server/public/assets/{admin-2ZJmzJ_g.js → admin-Bp-BjBCX.js} +51 -51
  169. package/payload/server/public/assets/{data-DlmoKxmN.js → data-BGUAGVkV.js} +1 -1
  170. package/payload/server/public/assets/{graph-BVvcFSEQ.js → graph-MvYxZOBF.js} +1 -1
  171. package/payload/server/public/assets/{graph-labels-cCvgu38Y.js → graph-labels-D865qb3K.js} +1 -1
  172. package/payload/server/public/assets/{page-BnAxPkmg.js → page--hOVRrgN.js} +1 -1
  173. package/payload/server/public/assets/{page-BnaN3u86.js → page-C2b1nlOc.js} +1 -1
  174. package/payload/server/public/data.html +3 -3
  175. package/payload/server/public/graph.html +3 -3
  176. package/payload/server/public/index.html +4 -4
  177. package/payload/server/server.js +18 -83
  178. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts +0 -51
  179. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts.map +0 -1
  180. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js +0 -196
  181. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js.map +0 -1
  182. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/package.json +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -85,12 +85,12 @@ PY
85
85
  }
86
86
 
87
87
  run_hook() {
88
- local role="$1"; local is_recorder="$2"; local stdin_json="$3"
88
+ local role="$1"; local specialist="$2"; local stdin_json="$3"
89
89
  local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
90
90
  local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
91
91
  printf '%s' "$stdin_json" | \
92
92
  MAXY_SESSION_ROLE="$role" \
93
- MAXY_IS_RECORDER="$is_recorder" \
93
+ MAXY_SPECIALIST="$specialist" \
94
94
  MAXY_UI_INTERNAL_PORT="$LISTENER_PORT" \
95
95
  bash "$HOOK" >"$stdout_file" 2>"$stderr_file"
96
96
  HOOK_RC=$?
@@ -131,7 +131,7 @@ print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
131
131
 
132
132
  # --- Case 1: role != admin → trigger-skipped reason=role-not-admin -----
133
133
  : > "$REQ_LOG"
134
- run_hook "public" "0" "$ENVELOPE"
134
+ run_hook "public" "" "$ENVELOPE"
135
135
  [[ "$HOOK_RC" -eq 0 ]] || fail "case-1 rc=$HOOK_RC"
136
136
  [[ -z "$HOOK_STDERR" ]] || fail "case-1 stderr must be empty, got: $HOOK_STDERR"
137
137
  if ingest_lines | grep -qE '^trigger-skipped sessionId=.* reason=role-not-admin$'; then
@@ -145,20 +145,20 @@ else
145
145
  pass "case-1 no recorder-spawn POST"
146
146
  fi
147
147
 
148
- # --- Case 2: MAXY_IS_RECORDER=1 → trigger-skipped reason=is-recorder ----
148
+ # --- Case 2: MAXY_SPECIALIST=database-operator → trigger-skipped is-recorder ----
149
149
  : > "$REQ_LOG"
150
- run_hook "admin" "1" "$ENVELOPE"
150
+ run_hook "admin" "database-operator" "$ENVELOPE"
151
151
  [[ "$HOOK_RC" -eq 0 ]] || fail "case-2 rc=$HOOK_RC"
152
152
  [[ -z "$HOOK_STDERR" ]] || fail "case-2 stderr must be empty, got: $HOOK_STDERR"
153
153
  if ingest_lines | grep -qE '^trigger-skipped sessionId=.* reason=is-recorder$'; then
154
- pass "case-2 MAXY_IS_RECORDER=1 → trigger-skipped reason=is-recorder emitted"
154
+ pass "case-2 MAXY_SPECIALIST=database-operator → trigger-skipped reason=is-recorder emitted"
155
155
  else
156
156
  fail "case-2 expected trigger-skipped is-recorder, got: $(ingest_lines)"
157
157
  fi
158
158
 
159
159
  # --- Case 3: empty stdin → trigger-skipped reason=empty-stdin ------------
160
160
  : > "$REQ_LOG"
161
- run_hook "admin" "0" ""
161
+ run_hook "admin" "" ""
162
162
  [[ "$HOOK_RC" -eq 0 ]] || fail "case-3 rc=$HOOK_RC"
163
163
  if ingest_lines | grep -qE '^trigger-skipped sessionId=.* reason=empty-stdin$'; then
164
164
  pass "case-3 empty stdin → trigger-skipped reason=empty-stdin emitted"
@@ -169,7 +169,7 @@ fi
169
169
  # --- Case 4: missing transcript_path → trigger-skipped missing-transcript -
170
170
  : > "$REQ_LOG"
171
171
  BAD_ENVELOPE=$(python3 -c 'import json,sys; print(json.dumps({"session_id": sys.argv[1]}))' "$OP_ID")
172
- run_hook "admin" "0" "$BAD_ENVELOPE"
172
+ run_hook "admin" "" "$BAD_ENVELOPE"
173
173
  [[ "$HOOK_RC" -eq 0 ]] || fail "case-4 rc=$HOOK_RC"
174
174
  if ingest_lines | grep -qE "^trigger-skipped sessionId=${OP_ID} reason=missing-transcript$"; then
175
175
  pass "case-4 missing transcript → trigger-skipped reason=missing-transcript emitted"
@@ -179,7 +179,7 @@ fi
179
179
 
180
180
  # --- Case 5: happy path -------------------------------------------------
181
181
  : > "$REQ_LOG"
182
- run_hook "admin" "0" "$ENVELOPE"
182
+ run_hook "admin" "" "$ENVELOPE"
183
183
  [[ "$HOOK_RC" -eq 0 ]] || fail "case-5 rc=$HOOK_RC stderr=$HOOK_STDERR"
184
184
  [[ -z "$HOOK_STDERR" ]] || fail "case-5 stderr must be empty, got: $HOOK_STDERR"
185
185
 
@@ -200,8 +200,10 @@ else
200
200
  fail "case-5b expected exactly 1 recorder-spawn POST, got $RECORDER_LINES"
201
201
  fi
202
202
 
203
- # 5c. recorder-spawn body has originatingSessionId == operator id; initialMessage
204
- # equals "<instruction>\n\n<last-turn-text>" verbatim. No transcript prose.
203
+ # 5c. Sidebar-shape body with three overrides (specialist, model,
204
+ # initialMessage); adminSessionId carries the operator's session id as
205
+ # the loopback-bypass lookup key. No senderId on the wire (the wrapper
206
+ # resolves it from the manager's /meta).
205
207
  RECORDER_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
206
208
  BODY_OK=$(printf '%s' "$RECORDER_BODY" | python3 -c '
207
209
  import sys, json
@@ -212,30 +214,39 @@ try:
212
214
  b = json.load(sys.stdin)
213
215
  msg = b.get("initialMessage") or ""
214
216
  conds = (
215
- # Task 134: no synthetic senderId marker — loopback bypass is
216
- # method+path-scoped; the body only carries the recorder discriminators.
217
217
  "senderId" not in b,
218
218
  b.get("specialist") == "database-operator",
219
- b.get("originatingSessionId") == op,
219
+ b.get("model") == "haiku",
220
+ b.get("adminSessionId") == op,
221
+ b.get("channel") == "browser",
220
222
  msg == INSTRUCTION + "\n\n" + EXPECTED_LAST,
221
223
  "Conversation id:" not in msg,
222
224
  "Conversation transcript:" not in msg,
225
+ "originatingSessionId" not in b,
223
226
  )
224
227
  print("yes" if all(conds) else "no:"+repr(conds)+"|msg="+repr(msg))
225
228
  except Exception as e:
226
229
  print("parse-fail:"+str(e))
227
230
  ' "$OP_ID" 2>/dev/null)
228
231
  if [[ "$BODY_OK" == "yes" ]]; then
229
- pass "case-5c recorder-spawn body carries originatingSessionId + verbatim instruction + last-turn text"
232
+ pass "case-5c body is Sidebar-shape with three overrides (specialist, model, initialMessage)"
230
233
  else
231
234
  fail "case-5c body shape wrong ($BODY_OK): $RECORDER_BODY"
232
235
  fi
233
236
 
234
- # 5d. no legacy stderr emissions
237
+ # 5d. exactly ONE `spawn-request` log-ingest line
238
+ SPAWN_REQ_COUNT=$(ingest_lines | grep -cE "^spawn-request sessionId=${OP_ID} specialist=database-operator initialMessageBytes=[0-9]+$" || true)
239
+ if [[ "$SPAWN_REQ_COUNT" -eq 1 ]]; then
240
+ pass "case-5d exactly one spawn-request line emitted"
241
+ else
242
+ fail "case-5d expected 1 spawn-request, got $SPAWN_REQ_COUNT (lines: $(ingest_lines))"
243
+ fi
244
+
245
+ # 5e. no legacy stderr emissions
235
246
  if echo "$HOOK_STDERR" | grep -qE 'spawn-with-input|spawn-with-stdin|turn-completed-graph-write fired'; then
236
- fail "case-5d legacy stderr line emitted: $HOOK_STDERR"
247
+ fail "case-5e legacy stderr line emitted: $HOOK_STDERR"
237
248
  else
238
- pass "case-5d no legacy stderr emissions"
249
+ pass "case-5e no legacy stderr emissions"
239
250
  fi
240
251
 
241
252
  # --- Case 6 (Task 131): transcript has no assistant/user text → ---------
@@ -248,7 +259,7 @@ import json, sys
248
259
  print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
249
260
  ' "$OP_ID" "$EMPTY_TRANSCRIPT")
250
261
  : > "$REQ_LOG"
251
- run_hook "admin" "0" "$EMPTY_ENVELOPE"
262
+ run_hook "admin" "" "$EMPTY_ENVELOPE"
252
263
  [[ "$HOOK_RC" -eq 0 ]] || fail "case-6 rc=$HOOK_RC"
253
264
  if ingest_lines | grep -qE "^trigger-skipped sessionId=${OP_ID} reason=last-turn-empty$"; then
254
265
  pass "case-6 empty last-turn text → trigger-skipped reason=last-turn-empty emitted"
@@ -4,41 +4,26 @@
4
4
  # text. The recorder is the only writer to the Neo4j graph; the admin agent
5
5
  # stays focused on the operator's request.
6
6
  #
7
- # Task 134 — the four-step per-turn process is:
8
- # 1. New admin session is spawned the SAME way a Sidebar "New session"
9
- # click spawns one — same route (`POST /api/admin/claude-sessions`,
10
- # the one Sidebar hits), same payload schema. The wrapper bypasses
11
- # cookie auth on this exact path when the request originates from
12
- # 127.0.0.1; the body carries `originatingSessionId` and
13
- # `specialist: 'database-operator'`, and the wrapper looks up the
14
- # operator's senderId from `/<originatingSessionId>/meta`. The
15
- # Task-122-era `/recorder-spawn` sibling route is gone.
16
- # 2. The spawned session's specialist is `database-operator`.
17
- # 3. The initial message is the OPERATOR'S LAST TURN, prefixed with the
18
- # literal instruction
19
- # "update the graph with any new or missing information or intent"
20
- # followed by a blank line. No transcript tail, no `Conversation id:`
21
- # prose — both removed in Task 131. The recorder reads only this one
22
- # most-recent turn.
23
- # 4. When the recorder's turn ends (`stop_reason:"end_turn"` lands in
24
- # the recorder JSONL), the manager posts `/<sid>/stop`, claude exits,
25
- # the fs-watcher observes the PID-file removal, and the row leaves
26
- # the index. No reaper involvement on the success path.
7
+ # Task 147 — the recorder spawn is byte-for-byte equivalent to a Sidebar
8
+ # "New session" body, with three overrides:
9
+ # - specialist: "database-operator"
10
+ # - model: "haiku"
11
+ # - initialMessage: "update the graph with any new or missing information
12
+ # or intent\n\n<last-turn-text>"
13
+ # It POSTs to the SAME route the Sidebar uses
14
+ # (`POST /api/admin/claude-sessions`). The wrapper accepts the loopback
15
+ # request without a cookie, resolves the operator's `senderId` from the
16
+ # manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn
17
+ # body. No recorder-only carving on the manager side.
27
18
  #
28
- # Task 129 every emit goes through the platform's `/api/admin/log-ingest`
29
- # endpoint (loopback-only). Hook stderr is silent on the success path.
30
- # Every line carries the operator's `sessionId=<op>` so one grep against
31
- # `server.log` returns the whole chain. The `originatingSessionId` is
32
- # posted as a top-level field of the spawn body; the wrapper reads it,
33
- # resolves the operator's account from it, and forwards a Sidebar-shape
34
- # spawn body to the manager.
35
- #
36
- # Gating (emits a `trigger-skipped` line, no longer a silent exit 0):
37
- # - MAXY_SESSION_ROLE must equal "admin" → reason=role-not-admin
38
- # - MAXY_IS_RECORDER must NOT equal "1" → reason=is-recorder
39
- # - Stop-hook stdin must be non-empty → reason=empty-stdin
40
- # - transcript_path must exist on disk → reason=missing-transcript
41
- # - a most-recent assistant or user turn → reason=last-turn-empty
19
+ # Gating (emits a `trigger-skipped` line via `/api/admin/log-ingest`;
20
+ # stderr stays silent on the success path):
21
+ # - MAXY_SESSION_ROLE must equal "admin" → reason=role-not-admin
22
+ # - MAXY_SPECIALIST must NOT equal → reason=is-recorder
23
+ # "database-operator" (recursion guard)
24
+ # - Stop-hook stdin must be non-empty → reason=empty-stdin
25
+ # - transcript_path must exist on disk → reason=missing-transcript
26
+ # - a most-recent assistant or user turn → reason=last-turn-empty
42
27
  # must be extractable from the JSONL
43
28
  #
44
29
  # Input: Claude Code's Stop hook stdin shape
@@ -46,12 +31,6 @@
46
31
 
47
32
  set -uo pipefail
48
33
 
49
- # UI listener port is stamped on the claude-session-manager systemd unit
50
- # as `Environment=MAXY_UI_INTERNAL_PORT=<port>` (Task 134). Absence is a
51
- # structural defect — historically the hook fell back to 19199 with no
52
- # listener on most devices and silently swallowed connection-refused. Now
53
- # it short-circuits with a named diagnostic to stderr (the only sink the
54
- # log-ingest endpoint itself depends on this var to reach).
55
34
  UI_PORT="${MAXY_UI_INTERNAL_PORT:-}"
56
35
  if [ -z "$UI_PORT" ]; then
57
36
  echo "[turn-recorder] spawn-failed sessionId=unknown reason=missing-env env=MAXY_UI_INTERNAL_PORT" >&2
@@ -60,10 +39,6 @@ fi
60
39
  UI_BASE="http://127.0.0.1:${UI_PORT}"
61
40
  LOG_INGEST_URL="${UI_BASE}/api/admin/log-ingest"
62
41
 
63
- # Emit one log line via the platform's log-ingest endpoint. Best-effort:
64
- # a failed POST does not break the hook — but the absence of the line is
65
- # itself a system-level defect per Task 129 §157. We do NOT fall back to
66
- # stderr, because stderr never reaches server.log.
67
42
  emit_log() {
68
43
  local line="$1"
69
44
  curl -sS -o /dev/null -X POST \
@@ -76,8 +51,6 @@ print(json.dumps({"tag": "turn-recorder", "level": "info", "line": sys.argv[1]})
76
51
  "$LOG_INGEST_URL" 2>/dev/null || true
77
52
  }
78
53
 
79
- # Parse stdin first so trigger-skipped lines can carry the operator
80
- # session id (when present). Stop-hook stdin is required.
81
54
  INPUT=""
82
55
  if [ -t 0 ]; then
83
56
  INPUT=""
@@ -98,20 +71,14 @@ except Exception:
98
71
  ADMIN_SESSION_ID="${PARSED%% *}"
99
72
  TRANSCRIPT_PATH="${PARSED#* }"
100
73
 
101
- # Gate ordering — each gate gets a distinct trigger-skipped reason so the
102
- # operator-ID-keyed grep can tell why a given turn did not record. Role
103
- # gate fires first because non-admin invocations may have no operator id
104
- # at all (the field is empty for public/recorder).
105
74
  if [ "${MAXY_SESSION_ROLE:-}" != "admin" ]; then
106
75
  emit_log "trigger-skipped sessionId=${ADMIN_SESSION_ID:-unknown} reason=role-not-admin"
107
76
  exit 0
108
77
  fi
109
- # Task 131 the recorder gate is now MAXY_IS_RECORDER (set to '1' by the
110
- # pty-spawner whenever a spawn carries `originatingSessionId`). The prior
111
- # MAXY_HIDDEN_SPAWN gate conflated UI visibility with recorder identity;
112
- # with hidden=false on recorder spawns now, the dedicated env var is the
113
- # only correct discriminator against hook-recursion.
114
- if [ "${MAXY_IS_RECORDER:-0}" = "1" ]; then
78
+ # Recursion guard. The recorder spawn carries
79
+ # `specialist=database-operator`; pty-spawner stamps MAXY_SPECIALIST on the
80
+ # PTY env. Skip recording the recorder's own turn.
81
+ if [ "${MAXY_SPECIALIST:-}" = "database-operator" ]; then
115
82
  emit_log "trigger-skipped sessionId=${ADMIN_SESSION_ID:-unknown} reason=is-recorder"
116
83
  exit 0
117
84
  fi
@@ -124,14 +91,7 @@ if [ -z "$ADMIN_SESSION_ID" ] || [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIP
124
91
  exit 0
125
92
  fi
126
93
 
127
- # Extract the operator's most-recent turn text from the JSONL. The single
128
- # rule (no fallbacks beyond the assistant→user pair) is:
129
- # - the most-recent `assistant` record whose message.content yields any
130
- # extractable text, OR
131
- # - if no assistant record produces text, the most-recent `user` record
132
- # that produces text.
133
- # Tool-call blocks, system records, and ephemeral admin-state records are
134
- # never the candidate. Empty output → reason=last-turn-empty.
94
+ # Extract the operator's most-recent turn text from the JSONL.
135
95
  LAST_TURN_TEXT=$(python3 - "$TRANSCRIPT_PATH" <<'PY'
136
96
  import sys, json
137
97
 
@@ -179,10 +139,6 @@ PY
179
139
  LAST_TURN_BYTES=$(printf '%s' "$LAST_TURN_TEXT" | wc -c | tr -d ' ')
180
140
  TRANSCRIPT_BYTES=$(wc -c <"$TRANSCRIPT_PATH" 2>/dev/null | tr -d ' ' || echo 0)
181
141
 
182
- # Step 0 — trigger line. First link in the chain the operator-ID-keyed
183
- # grep returns. Task 131: replaced `filteredRecords=<n>` with
184
- # `lastTurnBytes=<n>` because the hook no longer ships a filtered
185
- # transcript tail.
186
142
  emit_log "trigger sessionId=${ADMIN_SESSION_ID} turnIndex=0 transcriptBytes=${TRANSCRIPT_BYTES} lastTurnBytes=${LAST_TURN_BYTES}"
187
143
 
188
144
  if [ -z "$LAST_TURN_TEXT" ]; then
@@ -190,17 +146,10 @@ if [ -z "$LAST_TURN_TEXT" ]; then
190
146
  exit 0
191
147
  fi
192
148
 
193
- # Compose the recorder spawn body. Task 134:
194
- # - `initialMessage` = literal instruction + "\n\n" + last-turn text.
195
- # Verbatim instruction wording (no trailing qualifier, includes "new").
196
- # - `originatingSessionId` carries the operator's session id; the
197
- # wrapper looks up the operator's real senderId from
198
- # `/<originatingSessionId>/meta` and forwards a Sidebar-shape spawn
199
- # body to the manager. No synthetic `senderId: 'turn-recorder'`
200
- # marker — the loopback bypass is method+path-scoped.
201
- # - `specialist: 'database-operator'` discriminates the recorder spawn
202
- # from a vanilla Sidebar click.
203
- # - No `Conversation id:` prose; no transcript-tail JSON.
149
+ # Compose the Sidebar-shape spawn body. Three overrides relative to a
150
+ # plain Sidebar "New session" click: specialist, model, initialMessage.
151
+ # `adminSessionId` is the loopback-bypass key the wrapper uses to resolve
152
+ # the operator's senderId from the manager's /<sid>/meta.
204
153
  SPAWN_BODY=$(python3 -c '
205
154
  import sys, json
206
155
  sid = sys.argv[1]
@@ -208,13 +157,18 @@ last_turn = sys.argv[2]
208
157
  instruction = "update the graph with any new or missing information or intent"
209
158
  text = instruction + "\n\n" + last_turn
210
159
  body = {
160
+ "adminSessionId": sid,
161
+ "channel": "browser",
211
162
  "specialist": "database-operator",
163
+ "model": "haiku",
212
164
  "initialMessage": text,
213
- "originatingSessionId": sid,
214
165
  }
215
166
  print(json.dumps(body))
216
167
  ' "$ADMIN_SESSION_ID" "$LAST_TURN_TEXT" 2>/dev/null)
217
168
 
169
+ INITIAL_BYTES=$(printf '%s\n\n%s' "update the graph with any new or missing information or intent" "$LAST_TURN_TEXT" | wc -c | tr -d ' ')
170
+ emit_log "spawn-request sessionId=${ADMIN_SESSION_ID} specialist=database-operator initialMessageBytes=${INITIAL_BYTES}"
171
+
218
172
  SPAWN_RES_FILE=$(mktemp)
219
173
  SPAWN_HTTP=$(curl -sS -o "$SPAWN_RES_FILE" -w '%{http_code}' -X POST \
220
174
  -H 'Content-Type: application/json' \
@@ -222,21 +176,12 @@ SPAWN_HTTP=$(curl -sS -o "$SPAWN_RES_FILE" -w '%{http_code}' -X POST \
222
176
  --data "$SPAWN_BODY" \
223
177
  "${UI_BASE}/api/admin/claude-sessions" 2>/dev/null || echo 000)
224
178
 
225
- # The wrapper emits the named `spawn-request` / `spawn-success` /
226
- # `input-delivered` lines itself. The hook only emits the hook-side
227
- # failure variant — `loopback-http` — and even then only when the wrapper
228
- # returned a non-2xx that is NOT one of the preflight reasons (those are
229
- # logged by the manager before the wrapper sees them).
230
179
  RES_BODY=$(cat "$SPAWN_RES_FILE" 2>/dev/null || true)
231
180
  rm -f "$SPAWN_RES_FILE"
232
181
 
233
182
  if [ "$SPAWN_HTTP" -lt 200 ] || [ "$SPAWN_HTTP" -ge 300 ]; then
234
183
  ERR_SHORT=$(printf '%s' "$RES_BODY" | tr -d '\r\n' | cut -c1-200)
235
- if printf '%s' "$RES_BODY" | grep -q '"error":"tool-surface-missing"\|"error":"missing-originating-session-id"\|"error":"originating-session-id-required"\|"error":"originating-session-not-found"'; then
236
- : # preflight or lookup already named the failure
237
- else
238
- emit_log "spawn-failed sessionId=${ADMIN_SESSION_ID} reason=loopback-http http=${SPAWN_HTTP} err=$(python3 -c 'import sys,json; print(json.dumps(sys.argv[1]))' "${ERR_SHORT}")"
239
- fi
184
+ emit_log "spawn-failed sessionId=${ADMIN_SESSION_ID} reason=loopback-http http=${SPAWN_HTTP} err=$(python3 -c 'import sys,json; print(json.dumps(sys.argv[1]))' "${ERR_SHORT}")"
240
185
  fi
241
186
 
242
187
  exit 0
@@ -77,32 +77,33 @@ grep '\[client-acquire\]' ~/.${brand}/logs/claude-agent-stream-*.log
77
77
 
78
78
  The Hono route `POST /api/admin/claude-sessions` at [`platform/ui/server/routes/admin/claude-sessions.ts`](../../../ui/server/routes/admin/claude-sessions.ts) is the single canonical entry point for any caller — UI click handler or programmatic — that needs to open an admin PTY session with a first user prompt. Direct calls to the session-manager's `/spawn` and `/:id/input` are not allowed; the wrapper owns the spawn-then-input ordering, the per-spawn enrichment (`aboutOwner`, `dormantPlugins`, `activePlugins`, `specialistDomains`, `tunnelUrl`), and the `senderId` resolution from the admin session cookie. The session manager binds to `127.0.0.1` only so the wrapper is the sole authorised caller; any ad-hoc spawn that bypasses it duplicates this contract and is a bug.
79
79
 
80
- **Body schema.** `{channel?, permissionMode?, initialMessage?}` for operator-driven UI spawns (cookie path — `senderId` resolved from the admin session cookie, `hidden: false`, no `specialist`). The recorder hook posts the loopback variant: `{specialist:'database-operator', initialMessage, originatingSessionId, channel?}` no synthetic `senderId` marker. The wrapper rejects nothing structurally — string fields are coerced or defaulted (`channel` → `'browser'`, `permissionMode` → undefined, `initialMessage` → null if empty/whitespace) and the session manager validates the rest downstream. The loopback branch refuses the request with `400 originating-session-id-required` if `originatingSessionId` is missing or non-UUID, and `404 originating-session-not-found` if the manager has no StoredSession for it.
80
+ **Body schema.** `{channel?, permissionMode?, initialMessage?, specialist?, model?}` for the cookie path (Sidebar — `senderId` resolved from the admin session cookie, `hidden: false`). The Stop-hook loopback caller adds `adminSessionId` and uses three overrides: `specialist: 'database-operator'`, `model: 'haiku'`, plus `initialMessage`. The wrapper rejects nothing structurally on the cookie path — string fields are coerced or defaulted (`channel` → `'browser'`, `permissionMode` → undefined, `initialMessage` → null if empty/whitespace). The loopback path refuses with `400 admin-session-id-required` if `adminSessionId` is missing or non-UUID, and `404 admin-session-not-found` if the manager has no StoredSession for it.
81
81
 
82
- **Auth surfaces.** Two: `cookie` (Sidebar — `requireAdminSession` validates `session_key` from query/body/multipart and resolves `senderId` from the in-memory session store) and `loopback` (recorder hook — `requireAdminSessionUnlessLoopbackSpawn` skips cookie auth when method is `POST`, path is exactly `/api/admin/claude-sessions`, the TCP peer is `127.0.0.1`/`::1`, **and** the request carries no `x-forwarded-for` header; the handler then looks up the operator's `senderId` from `GET {managerBase}/<originatingSessionId>/meta`). The XFF check distinguishes a real in-process recorder hook from a browser request that traversed `maxy-edge`'s reverse proxy and arrived on loopback with `peer=127.0.0.1` same idiom `requestIsTlsTerminated` uses to disambiguate proxied vs direct TLS. Without the XFF gate, every edge-proxied "+ New session" click would mis-fire the bypass and 400 with `originating-session-id-required` (Task 143). Same trust boundary the claude-session-manager itself relies on — both bind 127.0.0.1 only. The Task-122-era `POST /recorder-spawn` sibling route is gone (Task 134). One route, one shape, two surfaces.
82
+ **Auth surfaces.** Two: `cookie` (Sidebar — `requireAdminSession` validates `session_key` and resolves `senderId` from the in-memory session store) and `loopback` (Stop hook — `requireAdminSessionOrLoopback` skips cookie auth when method is `POST`, path is exactly `/api/admin/claude-sessions`, and the TCP peer is `127.0.0.1`/`::1`; the handler then resolves the operator's `senderId` from `GET {managerBase}/<adminSessionId>/meta`). Both the manager and the UI server bind to `127.0.0.1` only for internal traffic, so the trust boundary is reachable only from a process running on the device. Task 147 one route, one body shape (the loopback variant differs only by overrides), no `/recorder-spawn` sibling, no XFF disambiguation.
83
83
 
84
84
  **Forwarded endpoints.** Two upstream calls per spawn-with-message: `POST {managerBase}/spawn` (enriched body) returns `{sessionId}`; when `initialMessage` is set, the wrapper then issues `POST {managerBase}/<sessionId>/input { text: <initialMessage> }` synchronously so the PTY has its first turn stimulus before the operator types. The HTTP response streams the spawn upstream body straight through.
85
85
 
86
- **Callers.** `Sidebar.tsx`'s "+ New session" click handler (sends the current ISO-8601 UTC timestamp as `initialMessage` see `platform.md` "Spawn lifecycle: PID-file driven"); the turn-recorder hook (loopback path — see "Turn-recorder" below); future programmatic spawns. Every caller routes through this wrapper. The on-the-wire signal that the contract held is the `[claude-session-manager:wrapper] spawn-request-in surface=<cookie|loopback>` log line followed by `forward-spawn-done`.
86
+ **Callers.** `Sidebar.tsx`'s "+ New session" click handler (sends no `initialMessage` Task 146 made Claude Code's own label canonical, so no UI-side stimulus is injected); the turn-recorder hook (loopback path — see "Turn-recorder" below); future programmatic spawns. Every caller routes through this wrapper. The on-the-wire signal that the contract held is the `[claude-session-manager:wrapper] spawn-request-in surface=<cookie|loopback>` log line followed by `forward-spawn-done`.
87
87
 
88
- ## Turn-recorder: the four-step per-turn process
88
+ ## Turn-recorder: a Sidebar-equivalent spawn with three overrides
89
89
 
90
- After every completed operator admin turn, the Stop hook `platform/plugins/admin/hooks/turn-completed-graph-write.sh` dispatches one headless `database-operator` session to record any new or missing information from that turn into the Neo4j graph. Exactly four things happen any deviation is a fault (2026-05-18 doctrine fix).
90
+ After every completed operator admin turn, the Stop hook `platform/plugins/admin/hooks/turn-completed-graph-write.sh` dispatches one headless `database-operator` session that writes any new or missing information from the turn into the Neo4j graph. Task 147 the recorder spawn is byte-for-byte equivalent to a Sidebar "New session" body, with three overrides:
91
91
 
92
- 1. **A new admin session is spawned the SAME way a Sidebar "New session" click spawns one.** The hook POSTs to `POST /api/admin/claude-sessions` — the exact same route the Sidebar uses. The Hono wrapper bypasses cookie auth via `requireAdminSessionUnlessLoopbackSpawn` on this exact method+path when the TCP peer is `127.0.0.1` **and** the request carries no `x-forwarded-for` header (the recorder is in-process; the edge proxy unconditionally stamps XFF on every browser request — Task 143). The body the hook sends carries only `{specialist:'database-operator', initialMessage, originatingSessionId, channel:'browser'}` — no synthetic `senderId` marker (Task 134 removed it; the loopback bypass is method+path-scoped). The wrapper resolves the operator's real `senderId` from `originatingSessionId` via `GET <managerBase>/<originatingSessionId>/meta`, then forwards a Sidebar-shape body to the manager: real operator `senderId`, `hidden: false`, `channel: 'browser'`, `role: 'admin'`, plus `specialist` and `originatingSessionId`. `aboutOwner` resolves against the operator's account; the recorder appears in the operator's sidebar as a sibling session of the originating one. `userId` is not exposed via `/meta` and is left undefined — `aboutOwner` falls through to senderId-only resolution, which returns the operator's account profile and is adequate for the recorder's single-turn lifecycle.
93
- 2. **The spawned session's specialist is `database-operator`.**
94
- 3. **The initial message is the operator's last turn, prefixed by a literal instruction.** Format:
95
- ```
96
- update the graph with any new or missing information or intent
92
+ - `specialist: 'database-operator'`
93
+ - `model: 'haiku'`
94
+ - `initialMessage: "update the graph with any new or missing information or intent\n\n<last-turn-text>"`
97
95
 
98
- <last-turn-text>
99
- ```
100
- The instruction wording is verbatim, with no trailing qualifier. `<last-turn-text>` is the most recent `assistant` record's text content from the operator JSONL, or — if no assistant record yields text — the most recent `user` record's text. Tool-call blocks, system records, and ephemeral admin-state records are never the candidate. If neither yields text, the hook emits `trigger-skipped reason=last-turn-empty` and no recorder is spawned. No transcript tail. No `Conversation id:` prose. The structural carrier of the operator session id is the spawn-body field `originatingSessionId`, not anything inside the message.
101
- 4. **On end_turn the recorder's PID file is removed.** When the recorder's JSONL gains an assistant message with `stop_reason: "end_turn"`, the manager posts `/<recorder-sid>/stop`, claude exits, the fs-watcher observes the PID-file removal and drops the row from the index. The reaper never fires on the success path; it exists only as the safety net for recorders that error without exiting.
96
+ There is no recorder-only carving on the manager side: no preflight, no tool-surface gate, no `originatingSessionId` discriminator, no `MAXY_IS_RECORDER` env stamp, no recorder-only log surfaces. The PTY's own first-use error is the contract for tool absence.
102
97
 
103
- **Recorder discriminator inside the manager.** Recorder detection (auto-archive, reaper bypass, recorder-tally accumulation, the `MAXY_IS_RECORDER` env stamp for the Stop hook's recursion gate) keys on `originatingSessionId` presence the one field set only for recorder spawns. The legacy `senderId === 'turn-recorder'` discriminator was retired in 2026-05-18 doctrine fix because the wrapper now resolves the operator's accountId for Sidebar-shape parity. The recorder's spawn env carries `MAXY_IS_RECORDER=1` (alongside `MAXY_SESSION_ROLE=admin` and `MAXY_HIDDEN_SPAWN=0`); the Stop hook gates on the dedicated `MAXY_IS_RECORDER` variable to break what would otherwise be an infinite recursion (the recorder's own end-of-turn re-firing the Stop hook).
98
+ **Spawn body.** The hook POSTs `{adminSessionId, channel: 'browser', specialist: 'database-operator', model: 'haiku', initialMessage}` to `POST /api/admin/claude-sessions`. `adminSessionId` is the loopback-bypass key the wrapper resolves the operator's real `senderId` from `GET <managerBase>/<adminSessionId>/meta` and forwards a Sidebar-shape body to the manager.
104
99
 
105
- **Observability.** Every recorder line carries `sessionId=<operator-sid>` so `grep -F "<op>" ~/.<brand>/logs/server.log` returns the four-step chain. The `trigger` line carries `lastTurnBytes=<n>` (2026-05-18 doctrine fix; replaces the prior `filteredRecords=<n>` from when the hook shipped a filtered transcript tail). Failure-mode names: `trigger-skipped reason=…` enumerates `role-not-admin | is-recorder | empty-stdin | missing-transcript | last-turn-empty`. The wrapper's preflight rejections emit `[turn-recorder] spawn-failed sessionId=<op> reason=…` with `missing-originating-session-id | originating-session-not-found | manager-spawn`.
100
+ **`<last-turn-text>`** is the most recent `assistant` record's text content from the operator JSONL, or if no assistant record yields text the most recent `user` record's text. Tool-call blocks, system records, and ephemeral admin-state records are never the candidate. If neither yields text, the hook emits `trigger-skipped reason=last-turn-empty` and no recorder is spawned.
101
+
102
+ **End-of-turn auto-archive.** When any session whose specialist is `database-operator` gains an assistant message with `stop_reason: "end_turn"` in its JSONL, the manager posts `/<sid>/stop` against itself, claude exits, the fs-watcher observes the PID-file removal and drops the row from the index. The auto-archive log line is `auto-archive sessionId=<sid> specialist=database-operator reason=end-turn …`.
103
+
104
+ **Hook recursion gate.** `pty-spawner` stamps `MAXY_SPECIALIST=<specialist>` on every PTY env. The Stop hook short-circuits when `MAXY_SPECIALIST=database-operator` so the recorder's own end-of-turn does not re-fire the hook.
105
+
106
+ **Observability.** The hook emits exactly two lines per operator turn via `/api/admin/log-ingest`: `[turn-recorder] trigger sessionId=<op> turnIndex=0 transcriptBytes=<n> lastTurnBytes=<n>` and `[turn-recorder] spawn-request sessionId=<op> specialist=database-operator initialMessageBytes=<n>`. The remainder of the recorder lifecycle is covered by the Sidebar's existing lines (`pty-spawned`, JSONL events, `auto-archive`). Failure-mode names: `trigger-skipped reason=…` enumerates `role-not-admin | is-recorder | empty-stdin | missing-transcript | last-turn-empty`. Hook-side spawn errors emit `[turn-recorder] spawn-failed reason=loopback-http http=<code>`.
106
107
 
107
108
  ## Session identifiers (Task 135)
108
109
 
@@ -80,11 +80,11 @@ The row feed sits behind `requireAdminSession` like every other admin route, so
80
80
 
81
81
  The trade-off is a longer-lived connection per tab: the manager's per-process subscriber count rises with open tabs, and the SSE channel must survive proxy idle timeouts. The manager emits a 25-second keep-alive comment line on every connection (ignored by EventSource consumers, refreshes the proxy clock) and the browser-side store force-closes-and-reconnects on transport errors with exponential backoff capped at 30s.
82
82
 
83
- **Spawn lifecycle: PID-file driven.** Clicking "+ New session" spawns the PTY and waits for Claude Code's PID file to appear at `${CLAUDE_CONFIG_DIR}/sessions/<pid>.json`. The PID file lands at process init (for `entrypoint: cli` spawns) and carries the intrinsic `sessionId`, `bridgeSessionId`, `agent`, and `status` directly. The manager's filesystem watcher reports the create event; the spawn response includes the canonical `sessionId` from that file. URL capture still runs in parallel to populate the operator-facing iframe URL, but it no longer gates readiness. The JSONL transcript is written on the first operator turn (true on 2.1.143 and 2.1.128); the watcher fires a separate event for that, and `/list`, `/meta`, `/log` resolve any of four ids — `sessionId`, `bridgeSessionId`, `bridgeSuffix`, or numeric `pid` — to the same row. Every fresh spawn also injects a first user turn so the agent has the stimulus the CLI needs to emit its first reply: the Sidebar's POST body carries `initialMessage` set to a brand-stamped session label of the form `{{productName}} Session DD/MMM/YY HH:MM:SS` (local device time, 24-hour clock, three-letter month, two-digit year — e.g. `Maxy Session 18/May/26 13:15:45` or `Real Agent Session 18/May/26 13:15:45`), computed at click-time on the client. The server forwards it as `POST /<id>/input { text }` immediately after the spawn returns. The label is non-directive descriptive metadata — the agent reads it as "begin", not as a directive to do something — preserving the Task 104 minimum-information-opener invariant. The wrapper at `platform/ui/server/routes/admin/claude-sessions.ts` is the single canonical entry point for any programmatic admin spawn-with-prompt — see `admin-session.md` "Spawn-with-initialMessage wrapper" and `internals.md` "Programmatic spawn entry point". Resume flows are unaffected (the prior transcript is the stimulus). Out of scope here: the *content* of the agent's reply, which is owned by the orchestrator's greeting rules.
83
+ **Spawn lifecycle: PID-file driven.** Clicking "+ New session" spawns the PTY and waits for Claude Code's PID file to appear at `${CLAUDE_CONFIG_DIR}/sessions/<pid>.json`. The PID file lands at process init (for `entrypoint: cli` spawns) and carries the intrinsic `sessionId`, `bridgeSessionId`, `agent`, and `status` directly. The manager's filesystem watcher reports the create event; the spawn response includes the canonical `sessionId` from that file. URL capture still runs in parallel to populate the operator-facing iframe URL, but it no longer gates readiness. The JSONL transcript is written on the first operator turn (true on 2.1.143 and 2.1.128); the watcher fires a separate event for that, and `/list`, `/meta`, `/log` resolve any of four ids — `sessionId`, `bridgeSessionId`, `bridgeSuffix`, or numeric `pid` — to the same row. The Sidebar POST body carries no `initialMessage` (Task 146): Claude Code's own `tail.aiTitle` is the canonical sidebar row label, and the wrapper does not inject any cold-start stimulus on operator-driven spawns. The wrapper at `platform/ui/server/routes/admin/claude-sessions.ts` is still the single canonical entry point for any programmatic admin spawn-with-prompt — see `admin-session.md` "Spawn-with-initialMessage wrapper" and `internals.md` "Programmatic spawn entry point" and the turn-recorder loopback path still forwards an `initialMessage`. Resume flows are unaffected (the prior transcript is the stimulus).
84
84
 
85
- Immediately after the spawn POST returns, the Sidebar fires a `PATCH /api/admin/claude-sessions/<id>` with the same brand-stamped label as `displayName`, writing the manager's session sidecar deterministically. The sidebar row label resolution precedence is `sidecar.displayName → tail.aiTitle 8-char id` (see `jsonl-enumerator.ts`), so the auto-stamped sidecar wins over Claude Code's `aiTitle` heuristic (which uniformly emits "New coding session" regardless of the first turn). A manual rename via the row's Rename modal overwrites the sidecar and continues to win — auto-stamp only fires on cold-start, never on resume. The auto-stamp PATCH logs `sidecar-displayname-stamped sessionId=<8-prefix> displayName=<value>` server-side when the sidecar was previously empty; manual renames log only the existing `rename sessionId=… from=… to=…` line. A 404 from the watcher-indexing race retries once after 250ms and then soft-fails (the session itself is unaffected; only the auto-label is missing — the row label falls back to the `aiTitle` / id chain).
85
+ The sidebar row's displayed name is `tail.aiTitle` verbatim, parsed by `jsonl-enumerator.ts` from the JSONL Claude Code writes. Until Claude Code has written its title, the row label is null and the cell renders empty no UI-stamped sidecar layer, no 8-char id fallback. When Claude Code later updates its title mid-session, the next `/list` or `/events` tick surfaces the new label. Task 146.
86
86
 
87
- **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.
87
+ **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 + 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.
88
88
 
89
89
  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.
90
90
 
@@ -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 (five value pillars: auto-respond, viewing lifecycle, pipeline mining, listings prospecting, maintenance & preferences), property brochures, social-share image cards, and A4 market reports. 3 specialist roles (negotiator, valuer, compliance) | 4 sub-plugins (estate-sales, buyers, estate-coaching, estate-teaching) |
76
+ | `real-agent` | Bundle (12 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), PropertyData market analytics (valuation, sold prices, £/sqft baselines, £/sqft growth, demand-rent, area risk, planning precedent, UPRN matching, property-type distribution), property brochures, social-share image cards, A4 market reports, and single-address preval packs (full UK address → 4-page A4 PDF covering valuation, area, and demand). 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.
@@ -116,7 +116,19 @@ Keys default to all 8 groups. Restrict at registration time to limit what the ag
116
116
 
117
117
  ## Security
118
118
 
119
- Never include API key values in responses. Identify teams by name only. When the user asks for a key value, explain that keys are encrypted and cannot be retrieved — they must be re-registered from Loop's dashboard.
119
+ Never include API key values in responses. Identify teams by name only. When the user asks for a key value, explain that keys are encrypted and cannot be retrieved — they must be re-registered from Loop's dashboard. Keys are encrypted with AES-256-GCM; the encryption-key file lives at `~/<configDir>/.loop-encryption-key` (platform mode) or `~/.claude/.realagent-loop-encryption-key` (standalone mode), both mode `0600`.
120
+
121
+ ## Standalone mode (`~/.claude/` spawn)
122
+
123
+ The same `mcp/dist/index.js` boots in two environments, selected by env flag:
124
+
125
+ | Env var | Platform mode | Standalone mode |
126
+ |---|---|---|
127
+ | `LOOP_STANDALONE` | unset | `"1"` |
128
+ | `ACCOUNT_ID` | platform tenant id | absent (defaults to `"local"`) |
129
+ | `NEO4J_URI` etc. | required | unused |
130
+
131
+ In standalone mode team keys live at `~/.claude/.realagent-loop-key.enc` as one AES-256-GCM-encrypted JSON blob keyed by accountId then teamName. Multi-team support is preserved. See `.docs/realagent-mcps-standalone-mode.md` for the full contract.
120
132
 
121
133
  ## When to use these tools
122
134
 
@@ -1,5 +1,5 @@
1
- import { initStderrTee } from "../../../../lib/mcp-stderr-tee/dist/index.js";
2
- initStderrTee("loop");
1
+ // Logging-tee was a platform-wrapper concern; the host owns the stderr pipe
2
+ // in both platform and standalone (Claude Code native) spawn paths.
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { z } from "zod";
@@ -28,10 +28,10 @@ import { autoResponder } from "./tools/auto-responder.js";
28
28
  import { customerPreferences } from "./tools/customer-preferences.js";
29
29
  import { supplier } from "./tools/supplier.js";
30
30
  import { closeDriver } from "./lib/neo4j.js";
31
- const accountId = process.env.ACCOUNT_ID;
32
- if (!accountId) {
33
- throw new Error("ACCOUNT_ID environment variable is required");
34
- }
31
+ import { selectLoopKeyStore } from "./lib/key-store.js";
32
+ // ACCOUNT_ID is the platform's tenant id; "local" is the single-tenant
33
+ // standalone stand-in when spawned natively by Claude Code.
34
+ const accountId = process.env.ACCOUNT_ID ?? "local";
35
35
  const server = new McpServer({
36
36
  name: "maxy-loop",
37
37
  version: "0.2.0",
@@ -40,7 +40,8 @@ const ALL_PERMISSIONS = z.enum([
40
40
  "properties", "people", "viewings", "feedback", "team",
41
41
  "marketing", "customer", "supplier",
42
42
  ]);
43
- console.error(`[loop] server started, account=${accountId}`);
43
+ const store = selectLoopKeyStore();
44
+ console.error(`[loop] server started, account=${accountId} backend=${store.backendName} path=${store.backendPath}`);
44
45
  // Helper: wrap a tool function in standard error handling
45
46
  function toolHandler(fn) {
46
47
  return async (params) => {