@rubytech/create-maxy-code 0.1.35 → 0.1.37

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 (125) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +12 -0
  3. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +109 -0
  4. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +114 -0
  5. package/payload/platform/plugins/admin/skills/unzip-attachment/SKILL.md +1 -1
  6. package/payload/platform/plugins/contacts/PLUGIN.md +11 -0
  7. package/payload/platform/plugins/docs/references/internals.md +10 -0
  8. package/payload/platform/plugins/docs/references/platform.md +11 -4
  9. package/payload/platform/plugins/email/PLUGIN.md +11 -0
  10. package/payload/platform/plugins/linkedin-import/.claude-plugin/plugin.json +1 -1
  11. package/payload/platform/plugins/linkedin-import/PLUGIN.md +5 -5
  12. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/SKILL.md +3 -1
  13. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/connections.md +2 -2
  14. package/payload/platform/plugins/memory/PLUGIN.md +11 -0
  15. package/payload/platform/plugins/memory/skills/conversation-archive/SKILL.md +2 -0
  16. package/payload/platform/plugins/memory/skills/conversation-archive-enrich/SKILL.md +2 -0
  17. package/payload/platform/plugins/memory/skills/document-ingest/SKILL.md +6 -4
  18. package/payload/platform/plugins/outlook/PLUGIN.md +11 -0
  19. package/payload/platform/plugins/replicate/PLUGIN.md +11 -0
  20. package/payload/platform/plugins/scheduling/PLUGIN.md +11 -0
  21. package/payload/platform/plugins/tasks/PLUGIN.md +11 -0
  22. package/payload/platform/plugins/telegram/PLUGIN.md +11 -0
  23. package/payload/platform/plugins/waitlist/PLUGIN.md +11 -0
  24. package/payload/platform/plugins/whatsapp/PLUGIN.md +11 -0
  25. package/payload/platform/plugins/workflows/PLUGIN.md +11 -0
  26. package/payload/platform/scripts/seed-neo4j.sh +14 -0
  27. package/payload/platform/scripts/verify-skill-tool-surface.sh +5 -3
  28. package/payload/platform/services/claude-session-manager/dist/config.d.ts +0 -5
  29. package/payload/platform/services/claude-session-manager/dist/config.d.ts.map +1 -1
  30. package/payload/platform/services/claude-session-manager/dist/config.js +16 -6
  31. package/payload/platform/services/claude-session-manager/dist/config.js.map +1 -1
  32. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +0 -5
  33. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  34. package/payload/platform/services/claude-session-manager/dist/http-server.js +109 -139
  35. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  36. package/payload/platform/services/claude-session-manager/dist/index.js +63 -4
  37. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  38. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +47 -61
  39. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  40. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +191 -131
  41. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  42. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +43 -37
  43. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  44. package/payload/platform/services/claude-session-manager/dist/session-store.js +72 -61
  45. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  46. package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts +34 -4
  47. package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts.map +1 -1
  48. package/payload/platform/services/claude-session-manager/dist/tool-surface.js +125 -27
  49. package/payload/platform/services/claude-session-manager/dist/tool-surface.js.map +1 -1
  50. package/payload/platform/templates/agents/admin/IDENTITY.md +9 -18
  51. package/payload/platform/templates/specialists/agents/database-operator.md +27 -70
  52. package/payload/platform/templates/specialists/agents/librarian.md +55 -0
  53. package/payload/server/public/assets/{Checkbox-8mOR8-gV.js → Checkbox-DAki3vDW.js} +1 -1
  54. package/payload/server/public/assets/{admin-CK4V-B9A.js → admin-CyAZtsYZ.js} +1 -1
  55. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-2DgPtOot.js → architectureDiagram-Q4EWVU46-C-hla3Ip.js} +1 -1
  56. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-BilycXVZ.js → blockDiagram-DXYQGD6D-CbzjtNs3.js} +1 -1
  57. package/payload/server/public/assets/{brand-1_DraUIv.css → brand-Cun0PBrA.css} +1 -1
  58. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-CGiIFO5U.js → c4Diagram-AHTNJAMY-B87UblA0.js} +1 -1
  59. package/payload/server/public/assets/channel-CTNo72W7.js +1 -0
  60. package/payload/server/public/assets/{chunk-336JU56O-DxTg1h50.js → chunk-336JU56O-2a2pVlSg.js} +2 -2
  61. package/payload/server/public/assets/{chunk-426QAEUC-DkGPjs0P.js → chunk-426QAEUC-j9NOD8_a.js} +1 -1
  62. package/payload/server/public/assets/{chunk-4TB4RGXK-D2JaxD6r.js → chunk-4TB4RGXK-Cs872xAq.js} +1 -1
  63. package/payload/server/public/assets/{chunk-5FUZZQ4R-Do3pbAGo.js → chunk-5FUZZQ4R-BnDFbZ_I.js} +1 -1
  64. package/payload/server/public/assets/{chunk-5PVQY5BW-np5moZSl.js → chunk-5PVQY5BW-Bu5B3Hyf.js} +1 -1
  65. package/payload/server/public/assets/{chunk-EDXVE4YY-BUFLWwRP.js → chunk-EDXVE4YY-B96Fw4FF.js} +1 -1
  66. package/payload/server/public/assets/{chunk-ENJZ2VHE-BE7Gt41s.js → chunk-ENJZ2VHE-CcGrGQim.js} +1 -1
  67. package/payload/server/public/assets/{chunk-ICPOFSXX-gOBkROMP.js → chunk-ICPOFSXX-ZRvcI4Ai.js} +1 -1
  68. package/payload/server/public/assets/{chunk-OYMX7WX6-DR-BkNZa.js → chunk-OYMX7WX6-B6RI9Fbf.js} +1 -1
  69. package/payload/server/public/assets/{chunk-U2HBQHQK-qLp-uSVE.js → chunk-U2HBQHQK-BYGHVYyo.js} +1 -1
  70. package/payload/server/public/assets/{chunk-X2U36JSP-DQiKti0J.js → chunk-X2U36JSP-rwNs-gTO.js} +1 -1
  71. package/payload/server/public/assets/{chunk-YZCP3GAM-Cd0JhpTo.js → chunk-YZCP3GAM-jPttJRap.js} +1 -1
  72. package/payload/server/public/assets/{chunk-ZZ45TVLE-8u01H3Hy.js → chunk-ZZ45TVLE-DLykWOT5.js} +1 -1
  73. package/payload/server/public/assets/classDiagram-6PBFFD2Q-BaV5nDmT.js +1 -0
  74. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DOKlCKjY.js +1 -0
  75. package/payload/server/public/assets/clone-sQSwlSln.js +1 -0
  76. package/payload/server/public/assets/{dagre-KV5264BT-BBF_zxbi.js → dagre-KV5264BT-C6wSzHX2.js} +1 -1
  77. package/payload/server/public/assets/{dagre-BfVsOYOv.js → dagre-jnQDnF-5.js} +1 -1
  78. package/payload/server/public/assets/data-DbkuXcPb.js +1 -0
  79. package/payload/server/public/assets/{device-url-actions-CJBD3ecO.js → device-url-actions-y1tXNDZx.js} +1 -1
  80. package/payload/server/public/assets/{diagram-5BDNPKRD-BFHk2a7t.js → diagram-5BDNPKRD-N3X-n90h.js} +1 -1
  81. package/payload/server/public/assets/{diagram-G4DWMVQ6-Be3_qYl2.js → diagram-G4DWMVQ6-vfmbzdPd.js} +1 -1
  82. package/payload/server/public/assets/{diagram-MMDJMWI5-B3r4XVcu.js → diagram-MMDJMWI5-BETmPx_w.js} +1 -1
  83. package/payload/server/public/assets/{diagram-TYMM5635-BW5EIT4D.js → diagram-TYMM5635-BSUGM4wG.js} +1 -1
  84. package/payload/server/public/assets/{erDiagram-SMLLAGMA-DTu4_1Kb.js → erDiagram-SMLLAGMA-CWZ9OwMb.js} +1 -1
  85. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CadrjLcA.js → flowDiagram-DWJPFMVM-BpQOQS1a.js} +1 -1
  86. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-Cn0npD0L.js → ganttDiagram-T4ZO3ILL-Dc59igCS.js} +1 -1
  87. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-CK3gG5Qm.js → gitGraphDiagram-UUTBAWPF-D6lXg3zl.js} +1 -1
  88. package/payload/server/public/assets/graph-CC5_6-dg.js +1 -0
  89. package/payload/server/public/assets/{graph-labels-CPzF6Gbk.js → graph-labels-DFyNH0jb.js} +1 -1
  90. package/payload/server/public/assets/{graphlib-DFIOO59N.js → graphlib-yG7kH8Fr.js} +1 -1
  91. package/payload/server/public/assets/{infoDiagram-42DDH7IO-BohUsXnN.js → infoDiagram-42DDH7IO-TS9Pv8Os.js} +1 -1
  92. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-tjmA4BU8.js → ishikawaDiagram-UXIWVN3A-BJiJJTq6.js} +1 -1
  93. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-DzBlFESM.js → journeyDiagram-VCZTEJTY-C7PW4fmw.js} +1 -1
  94. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-BTw9BxK8.js → kanban-definition-6JOO6SKY-BEk21ciD.js} +1 -1
  95. package/payload/server/public/assets/{line-BYPL6wAj.js → line-DusyVWDE.js} +1 -1
  96. package/payload/server/public/assets/{mermaid-parser.core-Dmu4fWCS.js → mermaid-parser.core-DeeojQSb.js} +1 -1
  97. package/payload/server/public/assets/{mermaid.core-Z9vvieC2.js → mermaid.core-p65Lo7Sj.js} +3 -3
  98. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-BccAnfJ4.js → mindmap-definition-QFDTVHPH-B0jxFxdS.js} +1 -1
  99. package/payload/server/public/assets/{page-CBTlFm3b.js → page-BeYGnDzI.js} +1 -1
  100. package/payload/server/public/assets/{page-CDrsNrDO.js → page-x5khmfRx.js} +1 -1
  101. package/payload/server/public/assets/{pieDiagram-DEJITSTG-CZwxH1nJ.js → pieDiagram-DEJITSTG-DJ5GKYt6.js} +1 -1
  102. package/payload/server/public/assets/{public-MKWxjwiF.js → public-cnoUs7vq.js} +3 -3
  103. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-BWT4DItX.js → quadrantDiagram-34T5L4WZ-BV7R-kwV.js} +1 -1
  104. package/payload/server/public/assets/{requirementDiagram-MS252O5E-CHYkWUEy.js → requirementDiagram-MS252O5E-DRiLzlGN.js} +1 -1
  105. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-km7p45Ng.js → sankeyDiagram-XADWPNL6-C5-6_cCI.js} +1 -1
  106. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-BT1C0Dw-.js → sequenceDiagram-FGHM5R23-5FXm_VYM.js} +1 -1
  107. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-hibC19DY.js → stateDiagram-FHFEXIEX-FCQTaGqV.js} +1 -1
  108. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BdpPhdqg.js +1 -0
  109. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-Cnm86NfE.js → timeline-definition-GMOUNBTQ-tvtq62ef.js} +1 -1
  110. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-ByjQPLzS.js → vennDiagram-DHZGUBPP-Dk-k7CE4.js} +1 -1
  111. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-CMVrWw7l.js → wardleyDiagram-NUSXRM2D-CZSrqniY.js} +1 -1
  112. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-Mpc1u3FV.js → xychartDiagram-5P7HB3ND-CpGLpk7Q.js} +1 -1
  113. package/payload/server/public/data.html +5 -5
  114. package/payload/server/public/graph.html +6 -6
  115. package/payload/server/public/index.html +8 -8
  116. package/payload/server/public/public.html +5 -5
  117. package/payload/server/server.js +128 -106
  118. package/payload/server/public/assets/channel-DYpIZW3Q.js +0 -1
  119. package/payload/server/public/assets/classDiagram-6PBFFD2Q-nkfy9gg7.js +0 -1
  120. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-B-pMmeCC.js +0 -1
  121. package/payload/server/public/assets/clone-jiTzIER6.js +0 -1
  122. package/payload/server/public/assets/data-BIOaDoqe.js +0 -1
  123. package/payload/server/public/assets/graph-ClfU7t1k.js +0 -1
  124. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-MuJZxjmU.js +0 -1
  125. /package/payload/server/public/assets/{brand-CENN2zdq.js → brand-lj7EhWRg.js} +0 -0
@@ -3,7 +3,7 @@
3
3
  // here via fetch.
4
4
  //
5
5
  // Routes:
6
- // POST /spawn { senderId, role, channel } -> Session
6
+ // POST /spawn { senderId, role, channel, tunnelUrl? } -> Session
7
7
  // GET /list?senderId=... -> Session[]
8
8
  // POST /resume { senderId, role, channel, claudeSessionId } -> Session
9
9
  // DELETE /:sessionId -> 204
@@ -14,15 +14,22 @@
14
14
  // GET /:sessionId/meta -> SessionMeta payload
15
15
  // PATCH /:sessionId { displayName } -> 204 (writes sidecar)
16
16
  //
17
- // The `/log` route returns the path of claude's per-session JSONL transcript
18
- // (not a manager-written .pty file that's gone). On a streaming request
19
- // (?follow=1) the manager opens the file, streams the current bytes, then
20
- // watches for appends and pushes them as they arrive. On client disconnect
21
- // the watcher is cleared.
17
+ // `/spawn` returns 200 the instant Claude Code emits its `/remote-control`
18
+ // URL — the operator gets a usable session inside ~2 s even when they never
19
+ // type. The wire shape is `{sessionId: <bridge-suffix>, claudeSessionId: null,
20
+ // jsonlPath: null, ...LivePayload}`. The jsonl-observer fires on the first
21
+ // operator turn (claude does not write the JSONL until then, verified Pi
22
+ // 2.1.143 and Mac 2.1.128) and promotes the row to the intrinsic
23
+ // claudeSessionId in the background — no second client call needed.
22
24
  //
23
- // sidecar `<id>.meta.json` next to the JSONL holds operator-
24
- // owned fields (displayName). PATCH writes it; /meta reads it via the
25
- // enumerator helpers. Sidecar never touches the claude-owned JSONL.
25
+ // Every GET route resolves either the bridge suffix or the intrinsic id to
26
+ // the same row, so a UI that mounted on the bridge suffix at t=0 keeps
27
+ // routing correctly after promotion without flipping the row's visible
28
+ // position. The intrinsic id is the right field to use for routing once
29
+ // known (UI reads `claudeSessionId ?? sessionId`).
30
+ //
31
+ // sidecar `<id>.meta.json` next to the JSONL holds operator-owned fields
32
+ // (displayName). PATCH writes it; /meta reads it via the enumerator helpers.
26
33
  import { Hono } from 'hono';
27
34
  import { stream } from 'hono/streaming';
28
35
  import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
@@ -32,10 +39,6 @@ import { claudeStateRoot, projectSlugForCwd } from './jsonl-path.js';
32
39
  import { basename, join } from 'node:path';
33
40
  const ROLES = ['admin', 'public'];
34
41
  const CHANNELS = ['browser', 'whatsapp', 'telegram', 'webchat', 'email'];
35
- /** Parse the optional `aboutOwner` field from a /spawn or /resume POST body.
36
- * Accepts either `{ ok:true, body:string }` or `{ ok:false, reason:string }`.
37
- * Returns undefined for missing / malformed shapes so a stale UI never blocks
38
- * the spawn — the composer simply omits the `<about-owner>` sentinel. */
39
42
  function parseAboutOwner(raw) {
40
43
  if (!raw || typeof raw !== 'object')
41
44
  return undefined;
@@ -48,9 +51,6 @@ function parseAboutOwner(raw) {
48
51
  }
49
52
  return undefined;
50
53
  }
51
- /** Parse the optional `dormantPlugins` array. Each entry must be
52
- * `{ name:string, description:string }`. Malformed entries are dropped
53
- * silently — the composer suppresses the sentinel if the array ends up empty. */
54
54
  function parseDormantPlugins(raw) {
55
55
  if (!Array.isArray(raw))
56
56
  return undefined;
@@ -67,40 +67,36 @@ function parseDormantPlugins(raw) {
67
67
  }
68
68
  return out.length > 0 ? out : undefined;
69
69
  }
70
- // Path-traversal guard. every sessionId reaching the manager
71
- // (alive rows + dehydrated rows) is now the intrinsic claude session id
72
- // Claude Code wrote as the JSONL basename (`session_<id>` or a bare UUID,
73
- // per claude's writer). Reject anything with `/`, `..`, or out-of-range
74
- // chars before touching the filesystem.
70
+ // Path-traversal guard. Bridge suffixes and intrinsic JSONL basenames both
71
+ // match this character class.
75
72
  const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
76
73
  const LIST_TAIL_BYTES = 64 * 1024;
77
74
  const DISPLAY_NAME_MAX = 256;
78
75
  function timed(logger, method, path, status, ms) {
79
76
  logger(`api method=${method} path=${path} outcome=${status} ms=${ms}`);
80
77
  }
81
- /** Locate the JSONL path for any sessionId unifies live and
82
- * dehydrated rows under the intrinsic claude session id, so the lookup
83
- * is one map probe plus a filesystem fallback for ended rows. */
78
+ /** Locate the JSONL path for any sessionId. For alive rows, returns null
79
+ * when the row has not yet promoted (no JSONL on disk yet). */
84
80
  function resolveJsonlPath(deps, sessionId) {
85
81
  const live = deps.store.get(sessionId);
86
- if (live) {
82
+ if (live && live.claudeSessionId !== null && live.jsonlPath !== null) {
87
83
  return { path: live.jsonlPath, claudeSessionId: live.claudeSessionId };
88
84
  }
85
+ if (live && live.claudeSessionId === null) {
86
+ // Row is alive but pre-promotion: no JSONL exists yet.
87
+ return null;
88
+ }
89
89
  const slug = projectSlugForCwd(deps.spawnCwd);
90
90
  const path = join(claudeStateRoot(), 'projects', slug, `${sessionId}.jsonl`);
91
91
  if (existsSync(path))
92
92
  return { path, claudeSessionId: sessionId };
93
93
  return null;
94
94
  }
95
- /** attach the audit watcher for a public session's JSONL.
96
- * the attach is gated on `onJsonlObserved`, which fires only
97
- * after the observer has bound the JSONL, so jsonlPath and claudeSessionId
98
- * are always set by the time this runs. Race-safe: if the PTY exited
99
- * between observer fire and this call, detach immediately so the watcher
100
- * does not leak. */
101
95
  function attachPublicAudit(deps, session) {
102
96
  if (session.role !== 'public')
103
97
  return;
98
+ if (session.claudeSessionId === null || session.jsonlPath === null)
99
+ return;
104
100
  const claudeSessionId = session.claudeSessionId;
105
101
  deps.publicToolAudit.attach(claudeSessionId, session.jsonlPath);
106
102
  if (session.exited) {
@@ -113,27 +109,24 @@ function attachPublicAudit(deps, session) {
113
109
  }
114
110
  export function buildHttpApp(deps) {
115
111
  const app = new Hono();
116
- // display-name resolution for alive rows shares the same
117
- // three-tier chain as ended rows: sidecar → ai-title → 8-char slice.
118
- // The ai-title tier comes from the JSONL tail; we read it via the
119
- // enumerator's cached parse so /list polls remain cheap once warm.
120
- // every live row in the store carries the intrinsic
121
- // `claudeSessionId` (the spawn promise blocks on observation, so a row
122
- // never appears in the store without it).
123
112
  function liveDisplayName(s, sidecarName) {
124
113
  if (sidecarName && sidecarName.trim())
125
114
  return sidecarName.trim();
126
- if (existsSync(s.jsonlPath)) {
115
+ if (s.jsonlPath && s.claudeSessionId && existsSync(s.jsonlPath)) {
127
116
  const enriched = readJsonlSession(deps.spawnCwd, s.claudeSessionId, LIST_TAIL_BYTES, deps.logger);
128
117
  if (enriched?.aiTitle?.trim())
129
118
  return enriched.aiTitle.trim();
130
119
  }
131
- return s.claudeSessionId.slice(0, 8);
120
+ // pre-promotion 8 chars of the bridge suffix; post-promotion → 8
121
+ // chars of the intrinsic id (which the operator may copy into resume).
122
+ return (s.claudeSessionId ?? s.bridgeSuffix).slice(0, 8);
132
123
  }
133
124
  function toLivePayload(s) {
134
- const sidecar = readSidecar(s.jsonlPath);
125
+ const sidecar = s.jsonlPath ? readSidecar(s.jsonlPath) : null;
126
+ const hasJsonl = s.jsonlPath !== null && existsSync(s.jsonlPath);
135
127
  return {
136
- sessionId: s.claudeSessionId,
128
+ sessionId: s.bridgeSuffix,
129
+ claudeSessionId: s.claudeSessionId,
137
130
  pid: s.pid,
138
131
  url: s.url,
139
132
  startedAt: s.startedAt,
@@ -143,12 +136,12 @@ export function buildHttpApp(deps) {
143
136
  jsonlPath: s.jsonlPath,
144
137
  status: 'alive',
145
138
  displayName: liveDisplayName(s, sidecar?.displayName),
146
- // Pane-grade fields for alive rows are filled lazily on /meta to keep
147
- // /list polls cheap; /list payload carries null/0 placeholders.
148
139
  lastMessageAt: null,
149
140
  turnCount: 0,
150
141
  capped: false,
151
- sizeBytes: existsSync(s.jsonlPath) ? statSync(s.jsonlPath).size : null,
142
+ sizeBytes: hasJsonl && s.jsonlPath ? statSync(s.jsonlPath).size : null,
143
+ hidden: s.hidden,
144
+ specialist: s.specialist,
152
145
  };
153
146
  }
154
147
  app.post('/spawn', async (c) => {
@@ -165,21 +158,20 @@ export function buildHttpApp(deps) {
165
158
  const senderId = typeof body.senderId === 'string' ? body.senderId : '';
166
159
  const role = typeof body.role === 'string' ? body.role : '';
167
160
  const channel = typeof body.channel === 'string' ? body.channel : '';
168
- // log every spawn request entry so the per-spawn timeline starts
169
- // with a row in server.log even when the spawn fails before any
170
- // downstream emitter (composer, PTY, observer) had a chance to log.
161
+ const hidden = body.hidden === true;
162
+ const specialist = typeof body.specialist === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.specialist)
163
+ ? body.specialist
164
+ : undefined;
165
+ const stdinPayload = typeof body.stdinPayload === 'string' ? body.stdinPayload : undefined;
171
166
  const aboutOwnerShape = body.aboutOwner == null ? 'absent' : (typeof body.aboutOwner === 'object' ? 'object' : typeof body.aboutOwner);
172
167
  const dormantPluginsShape = Array.isArray(body.dormantPlugins)
173
168
  ? `array(${body.dormantPlugins.length})`
174
169
  : body.dormantPlugins == null ? 'absent' : typeof body.dormantPlugins;
175
- deps.logger(`spawn-request senderId=${senderId || 'missing'} role=${role || 'missing'} channel=${channel || 'missing'} permissionMode=${typeof body.permissionMode === 'string' ? body.permissionMode : 'default'} channels=${Array.isArray(body.channels) ? body.channels.length : 0} attachmentDir=${typeof body.attachmentDir === 'string' ? 'set' : 'absent'} aboutOwner=${aboutOwnerShape} dormantPlugins=${dormantPluginsShape}`);
170
+ deps.logger(`spawn-request senderId=${senderId || 'missing'} role=${role || 'missing'} channel=${channel || 'missing'} permissionMode=${typeof body.permissionMode === 'string' ? body.permissionMode : 'default'} channels=${Array.isArray(body.channels) ? body.channels.length : 0} attachmentDir=${typeof body.attachmentDir === 'string' ? 'set' : 'absent'} aboutOwner=${aboutOwnerShape} dormantPlugins=${dormantPluginsShape} tunnelUrl=${typeof body.tunnelUrl === 'string' ? 'set' : (body.tunnelUrl === null ? 'null' : 'absent')}`);
176
171
  if (!senderId || !ROLES.includes(role) || !CHANNELS.includes(channel)) {
177
172
  timed(deps.logger, 'POST', '/spawn', 400, Date.now() - start);
178
173
  return c.json({ error: 'invalid-arguments', detail: { senderId: !!senderId, role, channel } }, 400);
179
174
  }
180
- // gate `role=public` spawns on the in-memory token buckets.
181
- // Admin spawns are unthrottled. Rejection carries the bucket that tripped
182
- // so the caller can distinguish channel-level abuse from cross-channel.
183
175
  if (role === 'public') {
184
176
  const decision = deps.publicSpawnLimiter.admit(senderId, channel);
185
177
  if (!decision.admitted) {
@@ -191,29 +183,27 @@ export function buildHttpApp(deps) {
191
183
  }, 429);
192
184
  }
193
185
  }
194
- // `channels` is an optional string[]; non-string entries are
195
- // dropped silently. Empty / absent leaves the spawn argv unchanged.
196
186
  const channels = Array.isArray(body.channels)
197
187
  ? body.channels.filter((c) => typeof c === 'string' && c.length > 0)
198
188
  : undefined;
199
- // optional permission-mode passthrough. Unknown values are
200
- // dropped silently (spawn proceeds in `default` Ask-permissions mode)
201
- // so a stale UI never blocks a spawn over a typo'd mode string.
202
189
  const permissionMode = typeof body.permissionMode === 'string' && PERMISSION_MODES.includes(body.permissionMode)
203
190
  ? body.permissionMode
204
191
  : undefined;
205
- // optional public-chat attachment dir. Absolute path required;
206
- // non-absolute or non-string values are dropped silently so a malformed
207
- // bridge payload never blocks a spawn.
208
192
  const attachmentDir = typeof body.attachmentDir === 'string' && body.attachmentDir.startsWith('/')
209
193
  ? body.attachmentDir
210
194
  : undefined;
211
- // Pre-resolved owner profile + dormant-plugins set, computed UI-side by
212
- // the caller that holds Neo4j credentials. Missing / malformed shapes
213
- // suppress the corresponding sentinel rather than blocking the spawn.
214
195
  const aboutOwner = parseAboutOwner(body.aboutOwner);
215
196
  const dormantPlugins = parseDormantPlugins(body.dormantPlugins);
216
- const result = await spawnClaudeSession({ ...deps, store: deps.store, onJsonlObserved: (s) => attachPublicAudit(deps, s) }, {
197
+ // tunnelUrl override resolved upstream by the UI process from the
198
+ // brand's tunnel.state on disk (the manager has no Neo4j credentials).
199
+ // string → render; null → render `tunnel-url: none`; undefined → fall
200
+ // back to deps.host.tunnelUrl (whatever boot resolved).
201
+ const tunnelUrlOverride = typeof body.tunnelUrl === 'string'
202
+ ? body.tunnelUrl
203
+ : body.tunnelUrl === null
204
+ ? null
205
+ : undefined;
206
+ const result = await spawnClaudeSession({ ...deps, store: deps.store, onJsonlObserved: (s) => attachPublicAudit(deps, s), tunnelUrlOverride }, {
217
207
  senderId,
218
208
  role: role,
219
209
  channel: channel,
@@ -222,46 +212,45 @@ export function buildHttpApp(deps) {
222
212
  attachmentDir,
223
213
  aboutOwner,
224
214
  dormantPlugins,
215
+ hidden,
216
+ specialist,
217
+ stdinPayload,
225
218
  });
226
219
  if (!result.ok) {
227
- // observe-timeout is the loud-fail upper bound on the
228
- // bridge-binding race; surface as 504 so the UI can distinguish it
229
- // from a generic spawn failure. Everything else stays 500.
230
- // stderrTail carries the captured PTY stdout (welcome banner, error
231
- // text, exit prompt) so the UI can show why the spawn failed
232
- // instead of an opaque `spawn-failed` label.
233
- const status = result.reason === 'observe-timeout' ? 504 : 500;
234
- timed(deps.logger, 'POST', '/spawn', status, Date.now() - start);
235
- return c.json({ error: 'spawn-failed', reason: result.reason, stderrTail: result.stderrTail }, status);
236
- }
237
- // public-tool audit attaches inside the
238
- // `onJsonlObserved` callback once the observer has matched Claude
239
- // Code's JSONL on disk. — by the time the spawn promise
240
- // resolves, that callback has fired and the audit is attached.
220
+ // Every remaining spawn-failure surface is 500: which-claude-not-found,
221
+ // pty-spawn-failed, url-capture-timeout, bridge-suffix-unparseable,
222
+ // host-context-unresolved, identity-unresolved. The observe-timeout 504
223
+ // is gone /spawn never blocks on observation.
224
+ timed(deps.logger, 'POST', '/spawn', 500, Date.now() - start);
225
+ return c.json({ error: 'spawn-failed', reason: result.reason, stderrTail: result.stderrTail }, 500);
226
+ }
241
227
  timed(deps.logger, 'POST', '/spawn', 200, Date.now() - start);
242
228
  return c.json(toLivePayload(result.session), 200);
243
229
  });
244
230
  app.get('/list', (c) => {
245
231
  const start = Date.now();
246
232
  const senderId = c.req.query('senderId') ?? '';
233
+ const includeHidden = c.req.query('includeHidden') === '1';
247
234
  if (!senderId) {
248
235
  timed(deps.logger, 'GET', '/list', 400, Date.now() - start);
249
236
  return c.json({ error: 'senderId-required' }, 400);
250
237
  }
251
- // live rows and dehydrated rows share the same surface id
252
- // (Claude Code's intrinsic claudeSessionId), so dedup is a simple
253
- // set-membership check. Live entries take precedence on a collision
254
- // (the PTY is still attached; the dehydrated row would be a stale
255
- // snapshot of the same JSONL).
256
- const liveStored = deps.store.listBySender(senderId);
238
+ const liveStored = deps.store.listBySender(senderId, { includeHidden });
257
239
  const live = liveStored.map(toLivePayload);
258
- const liveIds = new Set(liveStored.map((s) => s.claudeSessionId));
240
+ // Live rows dedup against dehydrated by intrinsic id when promoted;
241
+ // un-promoted rows have no intrinsic id and cannot collide with disk.
242
+ const liveIntrinsicIds = new Set();
243
+ for (const s of liveStored) {
244
+ if (s.claudeSessionId !== null)
245
+ liveIntrinsicIds.add(s.claudeSessionId);
246
+ }
259
247
  const dehydrated = [];
260
248
  for (const j of listJsonlSessions(deps.spawnCwd, LIST_TAIL_BYTES, deps.logger)) {
261
- if (liveIds.has(j.claudeSessionId))
249
+ if (liveIntrinsicIds.has(j.claudeSessionId))
262
250
  continue;
263
251
  dehydrated.push({
264
252
  sessionId: j.claudeSessionId,
253
+ claudeSessionId: j.claudeSessionId,
265
254
  pid: null,
266
255
  url: null,
267
256
  startedAt: j.startedAt,
@@ -275,6 +264,8 @@ export function buildHttpApp(deps) {
275
264
  turnCount: j.turnCount,
276
265
  capped: j.capped,
277
266
  sizeBytes: j.sizeBytes,
267
+ hidden: false,
268
+ specialist: null,
278
269
  });
279
270
  }
280
271
  deps.logger(`list-source live=${live.length} jsonl=${dehydrated.length}`);
@@ -301,15 +292,17 @@ export function buildHttpApp(deps) {
301
292
  timed(deps.logger, 'POST', '/resume', 400, Date.now() - start);
302
293
  return c.json({ error: 'invalid-arguments' }, 400);
303
294
  }
304
- // same `channels` passthrough as /spawn so resumed sessions
305
- // re-attach to the brand's channel plugins. Absent/empty preserves
306
- // today's argv shape.
307
295
  const channels = Array.isArray(body.channels)
308
296
  ? body.channels.filter((c) => typeof c === 'string' && c.length > 0)
309
297
  : undefined;
310
298
  const aboutOwner = parseAboutOwner(body.aboutOwner);
311
299
  const dormantPlugins = parseDormantPlugins(body.dormantPlugins);
312
- const result = await spawnClaudeSession({ ...deps, store: deps.store, onJsonlObserved: (s) => attachPublicAudit(deps, s) }, {
300
+ const tunnelUrlOverride = typeof body.tunnelUrl === 'string'
301
+ ? body.tunnelUrl
302
+ : body.tunnelUrl === null
303
+ ? null
304
+ : undefined;
305
+ const result = await spawnClaudeSession({ ...deps, store: deps.store, onJsonlObserved: (s) => attachPublicAudit(deps, s), tunnelUrlOverride }, {
313
306
  senderId,
314
307
  role: role,
315
308
  channel: channel,
@@ -320,18 +313,13 @@ export function buildHttpApp(deps) {
320
313
  });
321
314
  if (!result.ok) {
322
315
  deps.logger(`resume-failed reason=${result.reason} resumeFrom=${claudeSessionId}`);
323
- const status = result.reason === 'observe-timeout' ? 504 : 500;
324
- timed(deps.logger, 'POST', '/resume', status, Date.now() - start);
325
- return c.json({ error: 'resume-failed', reason: result.reason }, status);
316
+ timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
317
+ return c.json({ error: 'resume-failed', reason: result.reason }, 500);
326
318
  }
327
319
  deps.logger(`resume pid=${result.session.pid} sessionUrl=${result.session.url} resumeFrom=${claudeSessionId} latency-ms=${Date.now() - start}`);
328
- // audit watcher attaches via onJsonlObserved.
329
320
  timed(deps.logger, 'POST', '/resume', 200, Date.now() - start);
330
321
  return c.json(toLivePayload(result.session), 200);
331
322
  });
332
- // sessionId metadata for the pane. Merges live store, dehydrated
333
- // enumeration, and sidecar into one payload. Alive rows get a fresh tail
334
- // parse with the full jsonlReadMaxBytes; ended rows likewise (cached).
335
323
  app.get('/:sessionId/meta', (c) => {
336
324
  const start = Date.now();
337
325
  const sessionId = c.req.param('sessionId');
@@ -343,15 +331,12 @@ export function buildHttpApp(deps) {
343
331
  let payload;
344
332
  if (live) {
345
333
  const base = toLivePayload(live);
346
- // claudeSessionId is the row's only id; the manager-mint
347
- // is gone.
348
- const claudeSessionId = live.claudeSessionId;
349
334
  let lastMessageAt = null;
350
335
  let turnCount = 0;
351
336
  let capped = false;
352
337
  let sizeBytes = base.sizeBytes;
353
- if (existsSync(live.jsonlPath)) {
354
- const enriched = readJsonlSession(deps.spawnCwd, claudeSessionId, deps.jsonlReadMaxBytes, deps.logger);
338
+ if (live.claudeSessionId && live.jsonlPath && existsSync(live.jsonlPath)) {
339
+ const enriched = readJsonlSession(deps.spawnCwd, live.claudeSessionId, deps.jsonlReadMaxBytes, deps.logger);
355
340
  if (enriched) {
356
341
  lastMessageAt = enriched.lastMessageAt;
357
342
  turnCount = enriched.turnCount;
@@ -365,7 +350,6 @@ export function buildHttpApp(deps) {
365
350
  turnCount,
366
351
  capped,
367
352
  sizeBytes,
368
- claudeSessionId,
369
353
  };
370
354
  }
371
355
  else {
@@ -376,6 +360,7 @@ export function buildHttpApp(deps) {
376
360
  }
377
361
  payload = {
378
362
  sessionId: enriched.claudeSessionId,
363
+ claudeSessionId: enriched.claudeSessionId,
379
364
  pid: null,
380
365
  url: null,
381
366
  startedAt: enriched.startedAt,
@@ -389,7 +374,8 @@ export function buildHttpApp(deps) {
389
374
  turnCount: enriched.turnCount,
390
375
  capped: enriched.capped,
391
376
  sizeBytes: enriched.sizeBytes,
392
- claudeSessionId: enriched.claudeSessionId,
377
+ hidden: false,
378
+ specialist: null,
393
379
  };
394
380
  deps.logger(`meta-jsonl-tail-bytes sessionId=${sessionId.slice(0, 8)} read=${Math.min(enriched.sizeBytes, deps.jsonlReadMaxBytes)} capped=${enriched.capped}`);
395
381
  }
@@ -397,8 +383,6 @@ export function buildHttpApp(deps) {
397
383
  timed(deps.logger, 'GET', `/${sessionId}/meta`, 200, Date.now() - start);
398
384
  return c.json(payload, 200);
399
385
  });
400
- // operator-owned displayName. Writes sidecar JSON next to the
401
- // JSONL. Sidecar never touches the claude-owned transcript.
402
386
  app.patch('/:sessionId', async (c) => {
403
387
  const start = Date.now();
404
388
  const sessionId = c.req.param('sessionId');
@@ -438,12 +422,6 @@ export function buildHttpApp(deps) {
438
422
  timed(deps.logger, 'PATCH', `/${sessionId}`, 200, Date.now() - start);
439
423
  return c.json({ ok: true, displayName: body.displayName }, 200);
440
424
  });
441
- // archive / unarchive. Moves <id>.jsonl + sidecar between the
442
- // project dir and a sibling `archive/` subdir at
443
- // ${claudeStateRoot()}/projects/<slug>/archive/. Archived JSONLs no longer
444
- // appear in /list because the enumerator only reads files at the top of the
445
- // slug dir, not subdirs. Refuses with 409 pty-still-alive when any live PTY
446
- // maps to this claudeSessionId — same guard as the purge route.
447
425
  app.post('/:sessionId/archive', async (c) => {
448
426
  const start = Date.now();
449
427
  const sessionId = c.req.param('sessionId');
@@ -464,9 +442,6 @@ export function buildHttpApp(deps) {
464
442
  timed(deps.logger, 'POST', `/${sessionId}/archive`, 400, Date.now() - start);
465
443
  return c.json({ error: 'invalid-mode', detail: { expected: ['archive', 'unarchive'] } }, 400);
466
444
  }
467
- // live and dehydrated rows share the intrinsic id, so a
468
- // single store probe answers "is the PTY still alive?". The compensating
469
- // findLiveByClaudeId lookup is gone.
470
445
  if (deps.store.get(sessionId)) {
471
446
  deps.logger(`archive-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
472
447
  timed(deps.logger, 'POST', `/${sessionId}/archive`, 409, Date.now() - start);
@@ -502,9 +477,6 @@ export function buildHttpApp(deps) {
502
477
  timed(deps.logger, 'POST', `/${sessionId}/archive`, 200, Date.now() - start);
503
478
  return c.json({ ok: true, mode }, 200);
504
479
  });
505
- // DELETE branches on `?purge=1`. Bare DELETE is the existing
506
- // "end session" path (kills the PTY, leaves the JSONL). Purge hard-removes
507
- // the JSONL + sidecar + any `<sessionId>/` subdir claude wrote.
508
480
  app.delete('/:sessionId', async (c) => {
509
481
  const start = Date.now();
510
482
  const sessionId = c.req.param('sessionId');
@@ -518,8 +490,6 @@ export function buildHttpApp(deps) {
518
490
  timed(deps.logger, 'DELETE', `/${sessionId}`, 204, Date.now() - start);
519
491
  return c.body(null, 204);
520
492
  }
521
- // Purge path. — live rows are keyed on the intrinsic id, so a
522
- // single store probe is the alive-PTY check.
523
493
  if (deps.store.get(sessionId)) {
524
494
  deps.logger(`purge-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
525
495
  timed(deps.logger, 'DELETE', `/${sessionId}?purge=1`, 409, Date.now() - start);
@@ -603,26 +573,27 @@ export function buildHttpApp(deps) {
603
573
  timed(deps.logger, 'GET', `/${sessionId}/log`, 404, Date.now() - start);
604
574
  return c.json({ error: 'session-not-found' }, 404);
605
575
  }
606
- // every promoted live session carries a non-null jsonlPath
607
- // (the spawn promise blocked on observation), so the jsonl-path-not-
608
- // derived 422 branch the predecessor needed is gone.
576
+ if (s.jsonlPath === null) {
577
+ // alive but pre-promotion: the first operator turn has not landed,
578
+ // so claude has not written a JSONL yet. Loud-fail with 202 so the
579
+ // UI can re-poll once `claudeSessionId` flips on a subsequent /list.
580
+ timed(deps.logger, 'GET', `/${sessionId}/log`, 202, Date.now() - start);
581
+ return c.json({ jsonlPath: null, exists: false, sizeBytes: 0, pending: true }, 202);
582
+ }
583
+ const jsonlPath = s.jsonlPath;
609
584
  const follow = c.req.query('follow') === '1';
610
585
  if (!follow) {
611
- const exists = existsSync(s.jsonlPath);
612
- const sizeBytes = exists ? statSync(s.jsonlPath).size : 0;
586
+ const exists = existsSync(jsonlPath);
587
+ const sizeBytes = exists ? statSync(jsonlPath).size : 0;
613
588
  timed(deps.logger, 'GET', `/${sessionId}/log`, 200, Date.now() - start);
614
- return c.json({ jsonlPath: s.jsonlPath, exists, sizeBytes }, 200);
589
+ return c.json({ jsonlPath, exists, sizeBytes }, 200);
615
590
  }
616
- // Streaming follow. Stream the current file content, then watch for
617
- // appends and push new bytes as they arrive. On client disconnect
618
- // (controller.cancel via Hono's onAbort) unwatch.
619
- const path = s.jsonlPath;
620
591
  timed(deps.logger, 'GET', `/${sessionId}/log?follow=1`, 200, Date.now() - start);
621
592
  return stream(c, async (out) => {
622
593
  let position = 0;
623
- if (existsSync(path)) {
624
- const initialSize = statSync(path).size;
625
- const head = createReadStream(path, { start: 0, end: Math.max(0, initialSize - 1) });
594
+ if (existsSync(jsonlPath)) {
595
+ const initialSize = statSync(jsonlPath).size;
596
+ const head = createReadStream(jsonlPath, { start: 0, end: Math.max(0, initialSize - 1) });
626
597
  for await (const chunk of head) {
627
598
  await out.write(chunk);
628
599
  }
@@ -631,25 +602,24 @@ export function buildHttpApp(deps) {
631
602
  let active = true;
632
603
  out.onAbort(() => {
633
604
  active = false;
634
- unwatchFile(path);
605
+ unwatchFile(jsonlPath);
635
606
  });
636
607
  const listener = () => {
637
608
  if (!active)
638
609
  return;
639
- if (!existsSync(path))
610
+ if (!existsSync(jsonlPath))
640
611
  return;
641
- const newSize = statSync(path).size;
612
+ const newSize = statSync(jsonlPath).size;
642
613
  if (newSize <= position)
643
614
  return;
644
- const tail = createReadStream(path, { start: position, end: newSize - 1 });
615
+ const tail = createReadStream(jsonlPath, { start: position, end: newSize - 1 });
645
616
  position = newSize;
646
617
  tail.on('data', (chunk) => {
647
618
  if (active)
648
619
  void out.write(chunk);
649
620
  });
650
621
  };
651
- watchFile(path, { interval: 250 }, listener);
652
- // Keep the stream open until the client aborts.
622
+ watchFile(jsonlPath, { interval: 250 }, listener);
653
623
  while (active) {
654
624
  await new Promise((resolve) => setTimeout(resolve, 1_000));
655
625
  }