@rubytech/create-maxy-code 0.1.92 → 0.1.93

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 (108) hide show
  1. package/dist/__tests__/claude-ptys-slice.test.js +25 -0
  2. package/dist/index.js +10 -1
  3. package/dist/port-resolution.js +29 -0
  4. package/package.json +1 -1
  5. package/payload/platform/plugins/docs/references/platform.md +4 -2
  6. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +61 -2
  7. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  8. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +337 -32
  9. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  10. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +1 -3
  11. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  12. package/payload/platform/services/claude-session-manager/dist/http-server.js +307 -209
  13. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  14. package/payload/platform/services/claude-session-manager/dist/index.js +62 -13
  15. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  16. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts +9 -0
  17. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts.map +1 -1
  18. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js +14 -0
  19. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js.map +1 -1
  20. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +165 -33
  21. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  22. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +283 -92
  23. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  24. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +4 -2
  25. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
  26. package/payload/platform/services/claude-session-manager/dist/reaper.js +23 -22
  27. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
  28. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts +55 -0
  29. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts.map +1 -0
  30. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js +170 -0
  31. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js.map +1 -0
  32. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +114 -0
  33. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -0
  34. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +209 -0
  35. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -0
  36. package/payload/server/public/assets/{ChatInput-dLWJ0Q8I.css → ChatInput-CzQhPt90.css} +1 -1
  37. package/payload/server/public/assets/{Checkbox-Bx8mRHQ3.js → Checkbox-ynZtDWi3.js} +1 -1
  38. package/payload/server/public/assets/{admin-BhEvxMrC.js → admin-ux6TKHS1.js} +1 -1
  39. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BEW_bD50.js → architectureDiagram-Q4EWVU46-Ko_izKmY.js} +1 -1
  40. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-DFLT98C3.js → blockDiagram-DXYQGD6D-D58VDC0w.js} +1 -1
  41. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-BemKtROA.js → c4Diagram-AHTNJAMY-C-eRXyKi.js} +1 -1
  42. package/payload/server/public/assets/channel-CejaITMF.js +1 -0
  43. package/payload/server/public/assets/{chunk-336JU56O-BL8fGJ7_.js → chunk-336JU56O-DZLEfLAb.js} +2 -2
  44. package/payload/server/public/assets/{chunk-426QAEUC-BD6by0MP.js → chunk-426QAEUC-BriTU3L8.js} +1 -1
  45. package/payload/server/public/assets/{chunk-4TB4RGXK-DX-cb6Nd.js → chunk-4TB4RGXK-BdxRL-AY.js} +1 -1
  46. package/payload/server/public/assets/{chunk-5FUZZQ4R-B2QqtIZS.js → chunk-5FUZZQ4R-BgEmy7Y3.js} +1 -1
  47. package/payload/server/public/assets/{chunk-5PVQY5BW-CzJWmsmd.js → chunk-5PVQY5BW-emnbDRLA.js} +1 -1
  48. package/payload/server/public/assets/{chunk-EDXVE4YY-DP9vcvEU.js → chunk-EDXVE4YY-BvCf2Xf2.js} +1 -1
  49. package/payload/server/public/assets/{chunk-ENJZ2VHE-D5xWEjF0.js → chunk-ENJZ2VHE-DTpEYJRb.js} +1 -1
  50. package/payload/server/public/assets/{chunk-ICPOFSXX-AKduf9o4.js → chunk-ICPOFSXX-Ced2j48Y.js} +1 -1
  51. package/payload/server/public/assets/{chunk-OYMX7WX6-D6jsVNyQ.js → chunk-OYMX7WX6-B21_a3Dv.js} +1 -1
  52. package/payload/server/public/assets/{chunk-U2HBQHQK-BNAnTYh8.js → chunk-U2HBQHQK-CPS3yyXK.js} +1 -1
  53. package/payload/server/public/assets/{chunk-X2U36JSP-UdQhYDnh.js → chunk-X2U36JSP-BJ_B5NW7.js} +1 -1
  54. package/payload/server/public/assets/{chunk-YZCP3GAM-DSSmSGmS.js → chunk-YZCP3GAM-CMLy-WXm.js} +1 -1
  55. package/payload/server/public/assets/{chunk-ZZ45TVLE-Czm7IDMC.js → chunk-ZZ45TVLE-BkLnzCHe.js} +1 -1
  56. package/payload/server/public/assets/classDiagram-6PBFFD2Q-BmoqALf4.js +1 -0
  57. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-vMajwCNi.js +1 -0
  58. package/payload/server/public/assets/clone-mvVfHkyg.js +1 -0
  59. package/payload/server/public/assets/{dagre-hSIMHJcO.js → dagre-Dd_gq4yj.js} +1 -1
  60. package/payload/server/public/assets/{dagre-KV5264BT-CBuRCJE7.js → dagre-KV5264BT-RY0E5UAn.js} +1 -1
  61. package/payload/server/public/assets/data-BK4r8Dqy.js +1 -0
  62. package/payload/server/public/assets/{diagram-5BDNPKRD-dbMrH0le.js → diagram-5BDNPKRD-DR7XiPLh.js} +1 -1
  63. package/payload/server/public/assets/{diagram-G4DWMVQ6-DBc6hs9a.js → diagram-G4DWMVQ6-2EbOfKFs.js} +1 -1
  64. package/payload/server/public/assets/{diagram-MMDJMWI5-ubSGybUk.js → diagram-MMDJMWI5-CGcY0Ze7.js} +1 -1
  65. package/payload/server/public/assets/{diagram-TYMM5635-WxL3cv-9.js → diagram-TYMM5635-Dk6MmAso.js} +1 -1
  66. package/payload/server/public/assets/{erDiagram-SMLLAGMA-CrBBOAN8.js → erDiagram-SMLLAGMA-CKvdlUqk.js} +1 -1
  67. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CXUq5Hzt.js → flowDiagram-DWJPFMVM-BqKhvCDz.js} +1 -1
  68. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-vTCBnYVS.js → ganttDiagram-T4ZO3ILL-CeiE-IRb.js} +1 -1
  69. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-BHrj_bRa.js → gitGraphDiagram-UUTBAWPF-BbPKn7v2.js} +1 -1
  70. package/payload/server/public/assets/graph-Cb6A8Hhs.js +1 -0
  71. package/payload/server/public/assets/graph-labels-DCsUNtnE.js +1 -0
  72. package/payload/server/public/assets/{graphlib-CDO75l2X.js → graphlib-tG6kR59B.js} +1 -1
  73. package/payload/server/public/assets/{infoDiagram-42DDH7IO-CcQdaOhi.js → infoDiagram-42DDH7IO-6MiRGCI1.js} +1 -1
  74. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-3KvltFgV.js → ishikawaDiagram-UXIWVN3A-BwY4p5mg.js} +1 -1
  75. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-DZCXaGFE.js → journeyDiagram-VCZTEJTY-BXAQ_fUi.js} +1 -1
  76. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DAIAKy1-.js → kanban-definition-6JOO6SKY-ChiiO7Yt.js} +1 -1
  77. package/payload/server/public/assets/{lib-CCLURO3E.js → lib-DbPz9cfu.js} +1 -1
  78. package/payload/server/public/assets/{line-C8iCE2Wx.js → line-B5g5OrOD.js} +1 -1
  79. package/payload/server/public/assets/{mermaid-parser.core-ElvUhnV9.js → mermaid-parser.core-BMGzMKJx.js} +1 -1
  80. package/payload/server/public/assets/{mermaid.core-7dSH31Ao.js → mermaid.core-DLMgkjXn.js} +3 -3
  81. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-FZ0rcfac.js → mindmap-definition-QFDTVHPH-BvyzRLAi.js} +1 -1
  82. package/payload/server/public/assets/{page-C1hj89Gf.js → page-BeYR_GI2.js} +1 -1
  83. package/payload/server/public/assets/{page-Bu8kO4jO.js → page-DVNp-Dfy.js} +1 -1
  84. package/payload/server/public/assets/{pieDiagram-DEJITSTG-CDuvMZbO.js → pieDiagram-DEJITSTG--EV9pVfs.js} +1 -1
  85. package/payload/server/public/assets/{public-g_2skbnY.js → public-sYW0Dwku.js} +3 -3
  86. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-xDjhkqNc.js → quadrantDiagram-34T5L4WZ-DJcL08ft.js} +1 -1
  87. package/payload/server/public/assets/{requirementDiagram-MS252O5E-BPKpyT0n.js → requirementDiagram-MS252O5E-D4VdmmSu.js} +1 -1
  88. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-BvFIrihC.js → sankeyDiagram-XADWPNL6-Dsw3nHRS.js} +1 -1
  89. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-C6G1oAE0.js → sequenceDiagram-FGHM5R23-CAgemUx5.js} +1 -1
  90. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DbOJT_Xh.js → stateDiagram-FHFEXIEX-B7dyL9_X.js} +1 -1
  91. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CImN6Wk6.js +1 -0
  92. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-D1DSxHh6.js → timeline-definition-GMOUNBTQ-B7iKswhp.js} +1 -1
  93. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BYoSeMdo.js → vennDiagram-DHZGUBPP-uFS0i1qX.js} +1 -1
  94. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-DH3kZdea.js → wardleyDiagram-NUSXRM2D-lDUh4LAD.js} +1 -1
  95. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-C6MROMLG.js → xychartDiagram-5P7HB3ND-0v7MEVwW.js} +1 -1
  96. package/payload/server/public/data.html +5 -5
  97. package/payload/server/public/graph.html +6 -6
  98. package/payload/server/public/index.html +8 -8
  99. package/payload/server/public/public.html +5 -5
  100. package/payload/server/public/assets/channel-CfpByn-m.js +0 -1
  101. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DNc9R1IX.js +0 -1
  102. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DM1DQh8N.js +0 -1
  103. package/payload/server/public/assets/clone-BSrNdvLW.js +0 -1
  104. package/payload/server/public/assets/data-Cbo0tTS3.js +0 -1
  105. package/payload/server/public/assets/graph-Beucu3_J.js +0 -1
  106. package/payload/server/public/assets/graph-labels-D15-eFQK.js +0 -1
  107. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ce4GFk-H.js +0 -1
  108. /package/payload/server/public/assets/{ChatInput-DoS80kaC.js → ChatInput-Cj9JQtYt.js} +0 -0
@@ -26,8 +26,11 @@
26
26
  import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
27
27
  import { tmpdir } from 'node:os';
28
28
  import { dirname, join } from 'node:path';
29
+ import { randomUUID } from 'node:crypto';
30
+ import { defaultSystemctlRunner, scopeUnitName, stopScopeUnit, } from './systemd-scope.js';
29
31
  import { startUrlCapture } from './url-capture.js';
30
- import { jsonlPathForSessionId } from './jsonl-path.js';
32
+ import { jsonlPathForSessionId, sidecarPathForSessionId } from './jsonl-path.js';
33
+ import { writeSidecar, updateSidecar } from './session-sidecar.js';
31
34
  import { probeMcpServerTools, } from './mcp-tools-probe.js';
32
35
  // CSI (Cursor / Style) escape sequence: ESC `[` <args> <final-letter>.
33
36
  const PTY_TAIL_CSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
@@ -56,6 +59,57 @@ function formatTailForLog(tail) {
56
59
  }
57
60
  import { composeAppendSystemPrompt, } from './system-prompt.js';
58
61
  import { discoverHooks } from './hooks-discovery.js';
62
+ const livePtys = new Map();
63
+ /** Returns true when the manager has a live PtyHandle for `sessionId`.
64
+ * False for exited rows (the tracker is removed) and for sessions the
65
+ * manager never spawned (e.g. a JSONL-only archived row). */
66
+ export function isLive(sessionId) {
67
+ const t = livePtys.get(sessionId);
68
+ return t !== undefined && !t.exited;
69
+ }
70
+ /** Test seam — read-only access to the tracker for a given session.
71
+ * Production routes never call this; they use the dedicated mutation
72
+ * exports (`writeInputToPty`, `killSession`, `onPtyExit`, etc.) so
73
+ * the PtyHandle stays encapsulated inside the module. */
74
+ export function getPtyTrackerForTests(sessionId) {
75
+ return livePtys.get(sessionId);
76
+ }
77
+ /** Register a callback to fire when the PTY for `sessionId` exits.
78
+ * Returns true when the tracker exists (callback was wired); false
79
+ * when no live PTY is tracked for that id (the caller's contract
80
+ * fires regardless — e.g. `attachPublicAudit` skips on no-tracker).
81
+ * Routes call this instead of reaching into the tracker for `.pty`. */
82
+ export function onPtyExit(sessionId, cb) {
83
+ const t = livePtys.get(sessionId);
84
+ if (!t)
85
+ return false;
86
+ t.pty.onExit(cb);
87
+ return true;
88
+ }
89
+ /** Iterate every live tracker. Used by index.ts's shutdown loop to
90
+ * SIGTERM each PTY synchronously, and by checkSlotsAndMaybeEvict's
91
+ * LRU search to walk specialists when the cap is hit. */
92
+ export function forEachLivePty(cb) {
93
+ for (const t of livePtys.values())
94
+ cb(t);
95
+ }
96
+ /** Count of live trackers. Surfaced by /healthz so the operator can
97
+ * see process-side liveness without scanning the watcher's row index. */
98
+ export function livePtyCount() {
99
+ return livePtys.size;
100
+ }
101
+ /** Test-only — clear the tracker map between vitest cases. Production
102
+ * never invokes this; the only legitimate eviction is PTY exit. */
103
+ export function __resetPtyTrackerForTests() {
104
+ livePtys.clear();
105
+ }
106
+ /** Test-only — directly register a tracker without going through
107
+ * `spawnClaudeSession`. Used by FD-release / natural-exit tests that
108
+ * need to construct a known PtyHandle + tracker pair and exercise
109
+ * `killSession` / `handlePtyNaturalExit` against it. */
110
+ export function __registerPtyTrackerForTests(tracker) {
111
+ livePtys.set(tracker.sessionId, tracker);
112
+ }
59
113
  /** Task 171 — specialists whose `--agent <name>` spawn has no human in the
60
114
  * loop: no operator browser tab, no chat channel, no `claude.ai/code/session_<id>`
61
115
  * URL to open. For these, `--remote-control` is dead weight (one outbound
@@ -323,15 +377,22 @@ function checkSlotsAndMaybeEvict(deps, args) {
323
377
  deps.logger(`[pty-cap] specialist-cap-reached account=${deps.accountId} current=${specialistRows.length} cap=${deps.specialistCap} evicted=none`);
324
378
  return { kind: 'specialist-cap-reached', currentCount: specialistRows.length, cap: deps.specialistCap };
325
379
  }
326
- const victim = deps.store.get(idleOldest.sessionId);
380
+ const victim = livePtys.get(idleOldest.sessionId);
327
381
  if (victim && !victim.exited) {
382
+ // Task 250 — eviction routes through systemctl stop of the scope
383
+ // unit, matching the operator-stop and reaper paths. Without this,
384
+ // an evicted specialist would only die when the manager process
385
+ // closes its master fd (and the SIGHUP wrap inside the scope makes
386
+ // it not die at all). systemctl stop gives systemd the cgroup-level
387
+ // SIGTERM→SIGKILL escalation, with TimeoutStopSec=5 as the ceiling.
328
388
  try {
329
- victim.pty.kill('SIGTERM');
389
+ const run = deps.runSystemctl ?? defaultSystemctlRunner;
390
+ stopScopeUnit(run, victim.scopeUnitToken);
330
391
  }
331
- catch { /* fire-and-forget */ }
392
+ catch { /* fire-and-forget — boot-seed orphan-stop will catch leaks */ }
332
393
  }
333
- deps.store.remove(idleOldest.sessionId);
334
- deps.logger(`[pty-cap] specialist-cap-reached account=${deps.accountId} current=${specialistRows.length} cap=${deps.specialistCap} evicted=${idleOldest.sessionId}`);
394
+ livePtys.delete(idleOldest.sessionId);
395
+ deps.logger(`[pty-cap] specialist-cap-reached account=${deps.accountId} current=${specialistRows.length} cap=${deps.specialistCap} evicted=${idleOldest.sessionId} pty-tracker-size=${livePtys.size}`);
335
396
  }
336
397
  if (liveRows.length >= deps.totalPtyCap - deps.operatorReserve) {
337
398
  deps.logger(`[pty-cap] operator-slots-reserved total=${liveRows.length} reserve=${deps.operatorReserve} totalCap=${deps.totalPtyCap} rejected-specialist=${args.senderId}`);
@@ -495,6 +556,13 @@ export async function spawnClaudeSession(deps, args) {
495
556
  }
496
557
  const startedAt = new Date().toISOString();
497
558
  const start = Date.now();
559
+ // Task 250 — mint the systemd scope unit token before any argv work so
560
+ // both the spawn adapter and the eventual StoredSession agree on which
561
+ // unit name the manager will later target via `systemctl --user stop`.
562
+ // Resume spawns use the prior canonical sessionId (already a UUID, hence
563
+ // already a valid systemd unit token); fresh spawns mint a fresh UUID.
564
+ // Either way the value is opaque to systemd.
565
+ const unitToken = args.resumeClaudeSessionId ?? randomUUID();
498
566
  const composedHost = deps.tunnelUrlOverride !== undefined
499
567
  ? { ...deps.host, tunnelUrl: deps.tunnelUrlOverride }
500
568
  : deps.host;
@@ -805,7 +873,7 @@ export async function spawnClaudeSession(deps, args) {
805
873
  const ptySpawnStart = Date.now();
806
874
  let pty;
807
875
  try {
808
- pty = deps.spawnPty(deps.claudeBin, argv, spawnEnv);
876
+ pty = deps.spawnPty(deps.claudeBin, argv, spawnEnv, unitToken);
809
877
  }
810
878
  catch (err) {
811
879
  const e = err;
@@ -817,14 +885,23 @@ export async function spawnClaudeSession(deps, args) {
817
885
  deps.logger(`pty-spawned pid=${pty.pid} ms=${Date.now() - ptySpawnStart}`);
818
886
  const tail = attachPtyTail(pty);
819
887
  // URL capture runs in parallel; its result is patched onto the
820
- // session row when (if) it fires. The spawn promise does not block on
888
+ // sidecar when (if) it fires. The spawn promise does not block on
821
889
  // it. A timeout here is logged but not a failure — the operator's
822
890
  // iframe URL falls back to UI-side construction from bridgeSessionId.
823
891
  //
824
892
  // Task 171 — headless spawns never emit the `/remote-control is active`
825
893
  // banner (the flag is absent), so the handler is not attached and no
826
894
  // PTY bytes are consumed by a regex that will never match.
895
+ //
896
+ // Task 260 — `capturedUrl` is the value SpawnedSession returns to the
897
+ // caller (the route includes it in the immediate /spawn response when
898
+ // the banner happens to land before the spawn promise resolves). The
899
+ // canonical surface is the sidecar; the captured value is also written
900
+ // there via `updateSidecar` so the watcher's row picks it up.
827
901
  let capturedUrl = null;
902
+ // Mutable holder so the URL callback can find the row once sessionId
903
+ // is known. The PID-file event below sets sessionIdRef.current.
904
+ const sessionIdRef = { current: null };
828
905
  if (!headless) {
829
906
  startUrlCapture({
830
907
  pty,
@@ -836,9 +913,16 @@ export async function spawnClaudeSession(deps, args) {
836
913
  const sid = sessionIdRef.current;
837
914
  if (!sid)
838
915
  return;
839
- const live = deps.store.get(sid);
840
- if (live)
841
- live.url = url;
916
+ const sidecarPath = sidecarPathForSessionId(deps.spawnCwd, sid);
917
+ const written = updateSidecar(sidecarPath, { url }, deps.logger);
918
+ if (!written) {
919
+ deps.logger(`[session-sidecar] url-update-skipped sessionId=${sid.slice(0, 8)} reason=sidecar-missing`);
920
+ return;
921
+ }
922
+ // Force the watcher to re-read the sidecar so its row index
923
+ // surfaces the new URL to /events and /list. fs.watch does not
924
+ // fire for sidecar writes (we only watch *.jsonl and PID files).
925
+ deps.watcher.refreshSidecar(sid);
842
926
  deps.onUrlCaptured?.(sid);
843
927
  },
844
928
  onTimeout: () => {
@@ -846,18 +930,15 @@ export async function spawnClaudeSession(deps, args) {
846
930
  },
847
931
  });
848
932
  }
849
- // Mutable holder so the URL callback can find the row once sessionId
850
- // is known. The PID-file event below sets sessionIdRef.current.
851
- const sessionIdRef = { current: null };
852
933
  // Wait for the watcher's PID-file event. The watcher resolves
853
934
  // synchronously if the file already exists (race-safe boot scan).
854
935
  // Task 258 — for /resume, the archived jsonl was already restored to
855
936
  // the top-level path before this spawn ran; claude --resume appends to
856
937
  // it and (per Task 186) rewrites its pid file to carry the prior
857
- // canonical sessionId. The StoredSession is keyed on the prior
858
- // canonical id passed in via resumeClaudeSessionId so the wire/store
859
- // identity is stable even during the brief window where claude's pid
860
- // file still carries a fresh mint.
938
+ // canonical sessionId. The PtyTracker (Task 260) is keyed on the
939
+ // prior canonical id passed in via resumeClaudeSessionId so the
940
+ // wire identity is stable even during the brief window where claude's
941
+ // pid file still carries a fresh mint.
861
942
  let row;
862
943
  try {
863
944
  row = await deps.watcher.waitForPid(pty.pid, deps.pidFileTimeoutMs);
@@ -872,25 +953,62 @@ export async function spawnClaudeSession(deps, args) {
872
953
  }
873
954
  const sessionId = args.resumeClaudeSessionId ?? row.sessionId;
874
955
  sessionIdRef.current = sessionId;
875
- const session = {
956
+ const specialist = args.specialist ?? row.agent ?? null;
957
+ const hidden = args.hidden === true;
958
+ // Task 260 — write the sidecar BEFORE registering the tracker. The
959
+ // sidecar is the canonical record of spawn metadata; the watcher
960
+ // row will read it the moment the JSONL CREATE event fires (the
961
+ // first user turn flush). If the write fails — disk full, RO mount,
962
+ // unexpected eperm — refuse the spawn loudly rather than launch a
963
+ // metadata-less PTY that the wire payload could not honour. The
964
+ // PTY is killed in the catch so no orphan claude child remains.
965
+ const sidecar = {
966
+ senderId: args.senderId,
967
+ role: args.role,
968
+ channel: args.channel,
969
+ url: capturedUrl,
970
+ startedAt,
971
+ permissionMode: args.permissionMode ?? null,
972
+ model: args.model ?? null,
973
+ hidden,
974
+ specialist,
975
+ };
976
+ const sidecarPath = sidecarPathForSessionId(deps.spawnCwd, sessionId);
977
+ try {
978
+ writeSidecar(sidecarPath, sidecar, deps.logger);
979
+ }
980
+ catch (err) {
981
+ const detail = err instanceof Error ? err.message : String(err);
982
+ pty.kill('SIGKILL');
983
+ const stderrTail = tail.getTail();
984
+ tail.dispose();
985
+ deps.logger(`spawn-failed reason=sidecar-write-failed sessionId=${sessionId.slice(0, 8)} path=${sidecarPath} err=${JSON.stringify(detail)} stderr-tail=${formatTailForLog(stderrTail)}`);
986
+ return { ok: false, reason: 'sidecar-write-failed', stderrTail };
987
+ }
988
+ // Task 260 — the watcher's row was built BEFORE we wrote the sidecar
989
+ // (waitForPid resolved first), so its sidecar fields are still null.
990
+ // Force a re-read so subsequent /list and /events calls see the
991
+ // freshly-written senderId/role/channel/etc. Some test fixtures use
992
+ // a hand-built watcher mock that predates `refreshSidecar` on the
993
+ // interface — the optional-chain keeps them working without a forced
994
+ // sweep update.
995
+ deps.watcher.refreshSidecar?.(sessionId);
996
+ const tracker = {
876
997
  sessionId,
877
998
  bridgeSessionId: row.bridgeSessionId ?? `session_${sessionId}`,
878
999
  bridgeSuffix: row.bridgeSuffix ?? sessionId,
879
1000
  pid: pty.pid,
880
- url: capturedUrl,
881
- startedAt,
882
- senderId: args.senderId,
883
- role: args.role,
884
- channel: args.channel,
885
1001
  pty,
886
1002
  exited: false,
887
1003
  fdReleased: false,
888
- hidden: args.hidden === true,
889
- specialist: args.specialist ?? row.agent ?? null,
1004
+ hidden,
1005
+ specialist,
1006
+ scopeUnitToken: unitToken,
890
1007
  };
891
- deps.store.add(session);
892
- if (args.hidden === true) {
893
- deps.logger(`hidden-spawn specialist=${session.specialist ?? 'none'} sessionId=${session.sessionId} pid=${pty.pid}`);
1008
+ livePtys.set(sessionId, tracker);
1009
+ deps.logger(`[pty-tracker] op=spawn sessionId=${sessionId.slice(0, 8)} pid=${pty.pid} size=${livePtys.size}`);
1010
+ if (hidden) {
1011
+ deps.logger(`hidden-spawn specialist=${specialist ?? 'none'} sessionId=${sessionId} pid=${pty.pid}`);
894
1012
  }
895
1013
  // PTY exit: mark exited; the watcher will fire a delete event when
896
1014
  // claude removes its PID file, which the http-server subscriber
@@ -898,12 +1016,12 @@ export async function spawnClaudeSession(deps, args) {
898
1016
  pty.onExit((e) => {
899
1017
  const stderrTail = tail.getTail();
900
1018
  tail.dispose();
901
- handlePtyNaturalExit({ store: deps.store, logger: deps.logger }, session, e, stderrTail);
1019
+ handlePtyNaturalExit({ logger: deps.logger }, tracker, e, stderrTail);
902
1020
  });
903
1021
  const responseMs = Date.now() - start;
904
- deps.logger(`spawn-response-returned sessionId=${session.sessionId} pid=${pty.pid} ms=${responseMs}`);
905
- deps.logger(`spawn pid=${pty.pid} sessionId=${session.sessionId} senderId=${args.senderId} role=${args.role} channel=${args.channel} permissionMode=${args.permissionMode ?? 'default'} remoteControl=${!headless} latency-ms=${responseMs}`);
906
- deps.logger(`[pty-spawn] sessionId=${session.sessionId} appendSystemPromptBytes=${Buffer.byteLength(appendSystemPrompt, 'utf8')} identityBytes=${composedBytes.identity} soulBytes=${composedBytes.soul} aboutOwnerBytes=${composedBytes.aboutOwner} dormantPluginsBytes=${composedBytes.dormantPlugins} accountDir=${deps.accountDir} role=${args.role} specialist=${args.specialist ?? 'none'} hostname=${deps.host.hostname} lanIPv4=${deps.host.lanIPv4} adminUrl=${deps.host.adminUrl} tunnelUrl=${composedHost.tunnelUrl ?? 'none'}`);
1022
+ deps.logger(`spawn-response-returned sessionId=${sessionId} pid=${pty.pid} ms=${responseMs}`);
1023
+ deps.logger(`spawn pid=${pty.pid} sessionId=${sessionId} senderId=${args.senderId} role=${args.role} channel=${args.channel} permissionMode=${args.permissionMode ?? 'default'} remoteControl=${!headless} latency-ms=${responseMs}`);
1024
+ deps.logger(`[pty-spawn] sessionId=${sessionId} appendSystemPromptBytes=${Buffer.byteLength(appendSystemPrompt, 'utf8')} identityBytes=${composedBytes.identity} soulBytes=${composedBytes.soul} aboutOwnerBytes=${composedBytes.aboutOwner} dormantPluginsBytes=${composedBytes.dormantPlugins} accountDir=${deps.accountDir} role=${args.role} specialist=${args.specialist ?? 'none'} hostname=${deps.host.hostname} lanIPv4=${deps.host.lanIPv4} adminUrl=${deps.host.adminUrl} tunnelUrl=${composedHost.tunnelUrl ?? 'none'}`);
907
1025
  // Task 178 — emit [pty-spawn-tool-inventory] for specialist spawns. The
908
1026
  // probe runs in parallel with the real claude child's own MCP handshake
909
1027
  // and is fire-and-forget: it never blocks the spawn response, and its
@@ -957,7 +1075,7 @@ export async function spawnClaudeSession(deps, args) {
957
1075
  }));
958
1076
  const inventoryTimeoutMs = deps.toolInventoryProbeTimeoutMs ?? 5_000;
959
1077
  emitToolInventoryAsync({
960
- sessionId: session.sessionId,
1078
+ sessionId: sessionId,
961
1079
  specialist: args.specialist,
962
1080
  frontmatterAllowlist: specialistTools,
963
1081
  resolvedServers,
@@ -975,7 +1093,7 @@ export async function spawnClaudeSession(deps, args) {
975
1093
  // this task investigates): without the timer, a spawn that never
976
1094
  // produces a first turn would never emit a final snapshot, and the
977
1095
  // operator would have no signal at all for the broken case.
978
- finalSnapshotState.set(session.sessionId, {
1096
+ finalSnapshotState.set(sessionId, {
979
1097
  specialist: args.specialist,
980
1098
  frontmatterAllowlist: specialistTools,
981
1099
  resolvedServers,
@@ -986,7 +1104,7 @@ export async function spawnClaudeSession(deps, args) {
986
1104
  logger: deps.logger,
987
1105
  spawnedAt: Date.now(),
988
1106
  fired: false,
989
- watchdog: setTimeout(() => fireFinalSnapshot(session.sessionId, 'first-turn-timeout'), firstTurnTimeoutMs),
1107
+ watchdog: setTimeout(() => fireFinalSnapshot(sessionId, 'first-turn-timeout'), firstTurnTimeoutMs),
990
1108
  });
991
1109
  }
992
1110
  // Task 183 — register the specialist spawn with the JSONL tail. The
@@ -1004,12 +1122,12 @@ export async function spawnClaudeSession(deps, args) {
1004
1122
  // probe block); whichever fires first wins, the other is a no-op via
1005
1123
  // the `fired` guard inside `fireFinalSnapshot`.
1006
1124
  if (isSpecialistSpawn && deps.jsonlTail) {
1007
- deps.jsonlTail.register(session.sessionId, {
1125
+ deps.jsonlTail.register(sessionId, {
1008
1126
  specialist: args.specialist,
1009
1127
  argvTools: [],
1010
- onFirstTurn: () => fireFinalSnapshot(session.sessionId, 'first-turn'),
1128
+ onFirstTurn: () => fireFinalSnapshot(sessionId, 'first-turn'),
1011
1129
  });
1012
- pty.onExit(() => deps.jsonlTail?.unregister(session.sessionId));
1130
+ pty.onExit(() => deps.jsonlTail?.unregister(sessionId));
1013
1131
  }
1014
1132
  // Task 215 — PTY-exit cleanup for the final-snapshot state runs
1015
1133
  // INDEPENDENTLY of the JsonlTail wiring. When `deps.jsonlTail` is
@@ -1020,48 +1138,63 @@ export async function spawnClaudeSession(deps, args) {
1020
1138
  // against a long-dead session 60 s later.
1021
1139
  if (isSpecialistSpawn && specialistTools !== null) {
1022
1140
  pty.onExit(() => {
1023
- const state = finalSnapshotState.get(session.sessionId);
1141
+ const state = finalSnapshotState.get(sessionId);
1024
1142
  if (state?.watchdog)
1025
1143
  clearTimeout(state.watchdog);
1026
- finalSnapshotState.delete(session.sessionId);
1144
+ finalSnapshotState.delete(sessionId);
1027
1145
  });
1028
1146
  }
1029
- deps.onSessionReady?.(session);
1030
- return { ok: true, session };
1147
+ deps.onSessionReady?.(tracker);
1148
+ return {
1149
+ ok: true,
1150
+ session: {
1151
+ sessionId,
1152
+ pid: pty.pid,
1153
+ startedAt,
1154
+ url: capturedUrl,
1155
+ tracker,
1156
+ },
1157
+ };
1031
1158
  }
1032
1159
  /** Task 176 — natural-exit handler. Fires when the claude child exits on
1033
1160
  * its own (operator `/quit`, SIGINT in the PTY, crash, network drop on
1034
1161
  * `--remote-control`), with no operator `/stop` and no auto-archive in
1035
- * flight. Mirrors `stopSession`'s post-Task-170 fd-release + store-remove
1036
- * contract on the second exit branch the original task explicitly
1037
- * out-of-scoped. Without this, the master fd lingers in node-pty's
1038
- * internal socket until V8 GC finalises the IPty object and the
1039
- * SessionStore row survives until manager shutdown — so the shutdown
1040
- * loop SIGTERMs PIDs that already logged `process-exited`.
1162
+ * flight. Mirrors `killSession`'s post-Task-170 fd-release + tracker
1163
+ * removal contract on the second exit branch the original task
1164
+ * explicitly out-of-scoped. Without this, the master fd lingers in
1165
+ * node-pty's internal socket until V8 GC finalises the IPty object,
1166
+ * and the tracker entry survives until manager shutdown — so the
1167
+ * shutdown loop SIGTERMs PIDs that already logged `process-exited`.
1041
1168
  *
1042
1169
  * Side-effect order is load-bearing: set `exited` first so any
1043
- * concurrent `stopSession` sees the already-exited branch; release the
1170
+ * concurrent `killSession` sees the already-exited branch; release the
1044
1171
  * fd second (idempotent on a destroyed socket via the try/catch in
1045
- * `releasePtyMasterFd`); drop the row third (Map.delete on a missing
1046
- * key is a no-op, so the operator-request follow-up that races us is
1047
- * harmless). The log line carries the same `master-fd=...` suffix the
1048
- * operator-request path emits — every `kill … pid=<n>` line in
1049
- * `server.log` now ends in `master-fd=...`, no exceptions.
1172
+ * `releasePtyMasterFd`); drop the tracker third (Map.delete on a
1173
+ * missing key is a no-op, so the operator-request follow-up that
1174
+ * races us is harmless). The log line carries the same `master-fd=...`
1175
+ * suffix the operator-request path emits — every `kill … pid=<n>`
1176
+ * line in `server.log` now ends in `master-fd=...`, no exceptions.
1050
1177
  *
1051
1178
  * Exported so `__tests__/stop-session-fd-release.test.ts` can verify
1052
1179
  * the contract end-to-end without re-implementing the handler in test
1053
1180
  * code. */
1054
- export function handlePtyNaturalExit(deps, session, exit, stderrTail) {
1055
- session.exited = true;
1056
- // Skip if a racing `stopSession` already released the fd — second
1181
+ export function handlePtyNaturalExit(deps, tracker, exit, stderrTail) {
1182
+ tracker.exited = true;
1183
+ // Skip if a racing `killSession` already released the fd — second
1057
1184
  // destroy() on a destroyed node-pty socket throws "socket already
1058
1185
  // destroyed" and would turn this line's suffix into a false
1059
1186
  // `master-fd=close-failed`.
1060
- const fdSuffix = session.fdReleased ? 'master-fd=closed' : releasePtyMasterFd(session);
1187
+ const fdSuffix = tracker.fdReleased ? 'master-fd=closed' : releasePtyMasterFd(tracker);
1061
1188
  if (fdSuffix === 'master-fd=closed')
1062
- session.fdReleased = true;
1063
- deps.store.remove(session.sessionId);
1064
- deps.logger(`kill pid=${session.pid} reason=process-exited exit-code=${exit.exitCode} signal=${exit.signal ?? 'none'} stderr-tail=${formatTailForLog(stderrTail)} ${fdSuffix}`);
1189
+ tracker.fdReleased = true;
1190
+ // Task 260 — only remove the tracker entry if it is still OUR tracker.
1191
+ // A subsequent spawn could have replaced the entry under the same
1192
+ // sessionId (LRU recycle in production; cross-fixture sessionId
1193
+ // collision in tests).
1194
+ if (livePtys.get(tracker.sessionId) === tracker)
1195
+ livePtys.delete(tracker.sessionId);
1196
+ deps.logger(`kill pid=${tracker.pid} reason=process-exited exit-code=${exit.exitCode} signal=${exit.signal ?? 'none'} stderr-tail=${formatTailForLog(stderrTail)} ${fdSuffix}`);
1197
+ deps.logger(`[pty-tracker] op=exit sessionId=${tracker.sessionId.slice(0, 8)} pid=${tracker.pid} size=${livePtys.size}`);
1065
1198
  }
1066
1199
  /** Release the pty master fd by destroying node-pty's internal socket.
1067
1200
  * Without this, the fd lingers until V8 GC finalizes the IPty object —
@@ -1070,9 +1203,9 @@ export function handlePtyNaturalExit(deps, session, exit, stderrTail) {
1070
1203
  * err=..." on throw). The throw path is rescued so a destroy failure
1071
1204
  * on a corner-case socket state cannot turn a logically-successful
1072
1205
  * stop into a 500. Task 170. */
1073
- function releasePtyMasterFd(s) {
1206
+ function releasePtyMasterFd(t) {
1074
1207
  try {
1075
- s.pty.destroy?.();
1208
+ t.pty.destroy?.();
1076
1209
  return 'master-fd=closed';
1077
1210
  }
1078
1211
  catch (err) {
@@ -1080,42 +1213,100 @@ function releasePtyMasterFd(s) {
1080
1213
  return `master-fd=close-failed err=${JSON.stringify(msg)}`;
1081
1214
  }
1082
1215
  }
1083
- /** SIGTERM-only stop. Idempotentalready-exited rows resolve immediately.
1084
- * Does not touch the JSONL transcript. Returns whether a PTY existed. */
1216
+ /** Task 250 + 260 operator-request stop. Issues `systemctl --user
1217
+ * stop --no-block <scope>` against the PTY's scope unit (Task 250)
1218
+ * instead of `pty.kill('SIGTERM')`; the scope's TimeoutStopSec=5 handles
1219
+ * SIGTERM→SIGKILL escalation inside its own cgroup. Idempotent: a
1220
+ * no-such-unit response (exit code 5) is treated as success because
1221
+ * claude exited naturally and systemd retired the scope. The master fd
1222
+ * is still released locally — fd ownership stays with the manager
1223
+ * process and must be cleaned up there, irrespective of which process
1224
+ * killed claude.
1225
+ *
1226
+ * Task 260 — looks up the tracker in the module-scoped `livePtys` map
1227
+ * (the metadata-rich SessionStore is gone). Deletes by identity (only
1228
+ * if `livePtys.get(sessionId) === t`) so an LRU recycle or a
1229
+ * cross-fixture sessionId collision in tests never clobbers a live
1230
+ * PTY whose tracker was registered after the current call resolved. */
1085
1231
  export async function stopSession(deps, sessionId) {
1086
- const s = deps.store.get(sessionId);
1087
- if (!s)
1088
- return { existed: false };
1089
- if (s.exited) {
1232
+ const t = livePtys.get(sessionId);
1233
+ if (!t) {
1234
+ return { existed: false, scopeOutcome: 'no-store-entry', exitCode: null };
1235
+ }
1236
+ const start = Date.now();
1237
+ const unit = scopeUnitName(t.scopeUnitToken);
1238
+ if (t.exited) {
1090
1239
  // Task 176 short-circuit: if the natural-exit handler already
1091
1240
  // released the fd, skip the second destroy() — on real node-pty it
1092
1241
  // would throw "socket already destroyed" and we'd emit a false
1093
1242
  // `master-fd=close-failed`. Defense in depth still holds: if the
1094
1243
  // earlier destroy threw, fdReleased stays false and we retry here.
1095
- const fdSuffix = s.fdReleased ? 'master-fd=closed' : releasePtyMasterFd(s);
1244
+ const fdSuffix = t.fdReleased ? 'master-fd=closed' : releasePtyMasterFd(t);
1096
1245
  if (fdSuffix === 'master-fd=closed')
1097
- s.fdReleased = true;
1098
- deps.store.remove(sessionId);
1099
- deps.logger(`kill sessionId=${sessionId} pid=${s.pid} reason=operator-request status=already-exited ${fdSuffix}`);
1100
- return { existed: true };
1246
+ t.fdReleased = true;
1247
+ if (livePtys.get(sessionId) === t)
1248
+ livePtys.delete(sessionId);
1249
+ deps.logger(`[stop-session] systemd-scope-stop unit=${unit} outcome=already-exited ms=${Date.now() - start} sessionId=${sessionId} pid=${t.pid} ${fdSuffix}`);
1250
+ deps.logger(`[pty-tracker] op=exit sessionId=${sessionId.slice(0, 8)} pid=${t.pid} size=${livePtys.size}`);
1251
+ return { existed: true, scopeOutcome: 'already-gone', exitCode: null };
1101
1252
  }
1102
- s.pty.kill('SIGTERM');
1103
- await new Promise((resolve) => {
1104
- const grace = setTimeout(() => {
1105
- if (!s.exited)
1106
- s.pty.kill('SIGKILL');
1107
- resolve();
1108
- }, deps.killGraceMs);
1109
- s.pty.onExit(() => {
1110
- clearTimeout(grace);
1111
- resolve();
1112
- });
1113
- });
1114
- const fdSuffix = s.fdReleased ? 'master-fd=closed' : releasePtyMasterFd(s);
1253
+ const run = deps.runSystemctl ?? defaultSystemctlRunner;
1254
+ const stop = stopScopeUnit(run, t.scopeUnitToken);
1255
+ if (stop.outcome === 'error') {
1256
+ deps.logger(`[stop-session] systemd-scope-stop unit=${unit} outcome=error exit-code=${stop.exitCode} ms=${Date.now() - start} sessionId=${sessionId} pid=${t.pid} stderr=${JSON.stringify(stop.stderr.slice(-200))}`);
1257
+ return { existed: true, scopeOutcome: 'error', exitCode: stop.exitCode };
1258
+ }
1259
+ // outcome=ok or already-gone — both mean the scope is on its way out (or
1260
+ // already retired). Release the manager-side master fd and drop the
1261
+ // tracker entry; the watcher will observe Claude Code's PID-file delete
1262
+ // shortly and emit the archive transition.
1263
+ const fdSuffix = t.fdReleased ? 'master-fd=closed' : releasePtyMasterFd(t);
1115
1264
  if (fdSuffix === 'master-fd=closed')
1116
- s.fdReleased = true;
1117
- deps.store.remove(sessionId);
1118
- deps.logger(`kill sessionId=${sessionId} pid=${s.pid} reason=operator-request exit-code=0 ${fdSuffix}`);
1119
- return { existed: true };
1265
+ t.fdReleased = true;
1266
+ if (livePtys.get(sessionId) === t)
1267
+ livePtys.delete(sessionId);
1268
+ deps.logger(`[stop-session] systemd-scope-stop unit=${unit} outcome=${stop.outcome} ms=${Date.now() - start} sessionId=${sessionId} pid=${t.pid} ${fdSuffix}`);
1269
+ deps.logger(`[pty-tracker] op=exit sessionId=${sessionId.slice(0, 8)} pid=${t.pid} size=${livePtys.size}`);
1270
+ return {
1271
+ existed: true,
1272
+ scopeOutcome: stop.outcome,
1273
+ exitCode: stop.outcome === 'already-gone' ? stop.exitCode : 0,
1274
+ };
1275
+ }
1276
+ /** Task 260 — alias preserved during the http-server callsite rename
1277
+ * in this branch. The canonical export name is `stopSession` (Task 250). */
1278
+ export const killSession = stopSession;
1279
+ export function writeInputToPty(sessionId, text, logger, channel) {
1280
+ const t = livePtys.get(sessionId);
1281
+ if (!t)
1282
+ return { ok: false, error: 'not-found' };
1283
+ if (t.exited)
1284
+ return { ok: false, error: 'exited' };
1285
+ const bytes = Buffer.byteLength(text, 'utf8');
1286
+ // Task 122 — multi-line `text` must arrive at the TUI as one submitted
1287
+ // turn, not as keystrokes. Claude's TUI treats embedded `\n` typed via
1288
+ // `pty.write` as a bracketed-paste boundary that buffers and never
1289
+ // submits. Wrapping the bytes in DEC bracketed-paste markers
1290
+ // (\e[200~ ... \e[201~) and then sending `\r` makes the TUI treat the
1291
+ // payload as a single paste followed by an explicit submit keystroke.
1292
+ // Single-line text keeps the previous typed-keystroke path byte-for-byte
1293
+ // so the Sidebar timestamp stimulus is unchanged.
1294
+ const multiline = text.includes('\n');
1295
+ const submitMode = multiline ? 'bracketed-paste' : 'typed';
1296
+ try {
1297
+ if (multiline) {
1298
+ t.pty.write(`\x1b[200~${text}\x1b[201~\r`);
1299
+ }
1300
+ else {
1301
+ t.pty.write(text + '\r');
1302
+ }
1303
+ }
1304
+ catch (err) {
1305
+ const msg = err instanceof Error ? err.message : String(err);
1306
+ logger(`input-failed pid=${t.pid} sessionId=${sessionId} message=${msg}`);
1307
+ return { ok: false, error: 'write-failed', detail: msg };
1308
+ }
1309
+ logger(`input pid=${t.pid} sessionId=${sessionId} bytes=${bytes} channel=${channel ?? 'unknown'} multiline=${multiline} submit-mode=${submitMode}`);
1310
+ return { ok: true, bytes };
1120
1311
  }
1121
1312
  //# sourceMappingURL=pty-spawner.js.map