@rubytech/create-maxy-code 0.1.91 → 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 (126) 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/package-lock.json +18 -4
  6. package/payload/platform/plugins/docs/references/platform.md +4 -2
  7. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +65 -5
  8. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  9. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +468 -223
  10. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  11. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +10 -3
  12. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  13. package/payload/platform/services/claude-session-manager/dist/http-server.js +486 -193
  14. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  15. package/payload/platform/services/claude-session-manager/dist/index.js +72 -13
  16. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  17. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +10 -4
  18. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
  19. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +27 -8
  20. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
  21. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts +17 -0
  22. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js +25 -0
  24. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +165 -33
  26. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +290 -91
  28. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +6 -2
  30. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/reaper.js +29 -25
  32. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts +55 -0
  34. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts.map +1 -0
  35. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js +170 -0
  36. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js.map +1 -0
  37. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +6 -4
  38. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  39. package/payload/platform/services/claude-session-manager/dist/session-store.js +8 -10
  40. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  41. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +114 -0
  42. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -0
  43. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +209 -0
  44. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -0
  45. package/payload/platform/services/claude-session-manager/dist/types.d.ts +0 -4
  46. package/payload/platform/services/claude-session-manager/dist/types.d.ts.map +1 -1
  47. package/payload/platform/services/claude-session-manager/dist/user-title-store.d.ts +39 -0
  48. package/payload/platform/services/claude-session-manager/dist/user-title-store.d.ts.map +1 -0
  49. package/payload/platform/services/claude-session-manager/dist/user-title-store.js +138 -0
  50. package/payload/platform/services/claude-session-manager/dist/user-title-store.js.map +1 -0
  51. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +3 -0
  52. package/payload/server/public/assets/{ChatInput-dLWJ0Q8I.css → ChatInput-CzQhPt90.css} +1 -1
  53. package/payload/server/public/assets/{Checkbox-Bx8mRHQ3.js → Checkbox-ynZtDWi3.js} +1 -1
  54. package/payload/server/public/assets/admin-ux6TKHS1.js +217 -0
  55. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BEW_bD50.js → architectureDiagram-Q4EWVU46-Ko_izKmY.js} +1 -1
  56. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-DFLT98C3.js → blockDiagram-DXYQGD6D-D58VDC0w.js} +1 -1
  57. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-BemKtROA.js → c4Diagram-AHTNJAMY-C-eRXyKi.js} +1 -1
  58. package/payload/server/public/assets/channel-CejaITMF.js +1 -0
  59. package/payload/server/public/assets/{chunk-336JU56O-BL8fGJ7_.js → chunk-336JU56O-DZLEfLAb.js} +2 -2
  60. package/payload/server/public/assets/{chunk-426QAEUC-BD6by0MP.js → chunk-426QAEUC-BriTU3L8.js} +1 -1
  61. package/payload/server/public/assets/{chunk-4TB4RGXK-DX-cb6Nd.js → chunk-4TB4RGXK-BdxRL-AY.js} +1 -1
  62. package/payload/server/public/assets/{chunk-5FUZZQ4R-B2QqtIZS.js → chunk-5FUZZQ4R-BgEmy7Y3.js} +1 -1
  63. package/payload/server/public/assets/{chunk-5PVQY5BW-CzJWmsmd.js → chunk-5PVQY5BW-emnbDRLA.js} +1 -1
  64. package/payload/server/public/assets/{chunk-EDXVE4YY-DP9vcvEU.js → chunk-EDXVE4YY-BvCf2Xf2.js} +1 -1
  65. package/payload/server/public/assets/{chunk-ENJZ2VHE-D5xWEjF0.js → chunk-ENJZ2VHE-DTpEYJRb.js} +1 -1
  66. package/payload/server/public/assets/{chunk-ICPOFSXX-AKduf9o4.js → chunk-ICPOFSXX-Ced2j48Y.js} +1 -1
  67. package/payload/server/public/assets/{chunk-OYMX7WX6-D6jsVNyQ.js → chunk-OYMX7WX6-B21_a3Dv.js} +1 -1
  68. package/payload/server/public/assets/{chunk-U2HBQHQK-BNAnTYh8.js → chunk-U2HBQHQK-CPS3yyXK.js} +1 -1
  69. package/payload/server/public/assets/{chunk-X2U36JSP-UdQhYDnh.js → chunk-X2U36JSP-BJ_B5NW7.js} +1 -1
  70. package/payload/server/public/assets/{chunk-YZCP3GAM-DSSmSGmS.js → chunk-YZCP3GAM-CMLy-WXm.js} +1 -1
  71. package/payload/server/public/assets/{chunk-ZZ45TVLE-Czm7IDMC.js → chunk-ZZ45TVLE-BkLnzCHe.js} +1 -1
  72. package/payload/server/public/assets/classDiagram-6PBFFD2Q-BmoqALf4.js +1 -0
  73. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-vMajwCNi.js +1 -0
  74. package/payload/server/public/assets/clone-mvVfHkyg.js +1 -0
  75. package/payload/server/public/assets/{dagre-hSIMHJcO.js → dagre-Dd_gq4yj.js} +1 -1
  76. package/payload/server/public/assets/{dagre-KV5264BT-CBuRCJE7.js → dagre-KV5264BT-RY0E5UAn.js} +1 -1
  77. package/payload/server/public/assets/data-BK4r8Dqy.js +1 -0
  78. package/payload/server/public/assets/{diagram-5BDNPKRD-dbMrH0le.js → diagram-5BDNPKRD-DR7XiPLh.js} +1 -1
  79. package/payload/server/public/assets/{diagram-G4DWMVQ6-DBc6hs9a.js → diagram-G4DWMVQ6-2EbOfKFs.js} +1 -1
  80. package/payload/server/public/assets/{diagram-MMDJMWI5-ubSGybUk.js → diagram-MMDJMWI5-CGcY0Ze7.js} +1 -1
  81. package/payload/server/public/assets/{diagram-TYMM5635-WxL3cv-9.js → diagram-TYMM5635-Dk6MmAso.js} +1 -1
  82. package/payload/server/public/assets/{erDiagram-SMLLAGMA-CrBBOAN8.js → erDiagram-SMLLAGMA-CKvdlUqk.js} +1 -1
  83. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CXUq5Hzt.js → flowDiagram-DWJPFMVM-BqKhvCDz.js} +1 -1
  84. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-vTCBnYVS.js → ganttDiagram-T4ZO3ILL-CeiE-IRb.js} +1 -1
  85. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-BHrj_bRa.js → gitGraphDiagram-UUTBAWPF-BbPKn7v2.js} +1 -1
  86. package/payload/server/public/assets/graph-Cb6A8Hhs.js +1 -0
  87. package/payload/server/public/assets/graph-labels-DCsUNtnE.js +1 -0
  88. package/payload/server/public/assets/{graphlib-CDO75l2X.js → graphlib-tG6kR59B.js} +1 -1
  89. package/payload/server/public/assets/{infoDiagram-42DDH7IO-CcQdaOhi.js → infoDiagram-42DDH7IO-6MiRGCI1.js} +1 -1
  90. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-3KvltFgV.js → ishikawaDiagram-UXIWVN3A-BwY4p5mg.js} +1 -1
  91. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-DZCXaGFE.js → journeyDiagram-VCZTEJTY-BXAQ_fUi.js} +1 -1
  92. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DAIAKy1-.js → kanban-definition-6JOO6SKY-ChiiO7Yt.js} +1 -1
  93. package/payload/server/public/assets/{lib-CCLURO3E.js → lib-DbPz9cfu.js} +1 -1
  94. package/payload/server/public/assets/{line-C8iCE2Wx.js → line-B5g5OrOD.js} +1 -1
  95. package/payload/server/public/assets/{mermaid-parser.core-ElvUhnV9.js → mermaid-parser.core-BMGzMKJx.js} +1 -1
  96. package/payload/server/public/assets/{mermaid.core-7dSH31Ao.js → mermaid.core-DLMgkjXn.js} +3 -3
  97. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-FZ0rcfac.js → mindmap-definition-QFDTVHPH-BvyzRLAi.js} +1 -1
  98. package/payload/server/public/assets/{page-D3bFmqF4.js → page-BeYR_GI2.js} +1 -1
  99. package/payload/server/public/assets/{page-DNjUtT5q.js → page-DVNp-Dfy.js} +2 -2
  100. package/payload/server/public/assets/{pieDiagram-DEJITSTG-CDuvMZbO.js → pieDiagram-DEJITSTG--EV9pVfs.js} +1 -1
  101. package/payload/server/public/assets/{public-g_2skbnY.js → public-sYW0Dwku.js} +3 -3
  102. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-xDjhkqNc.js → quadrantDiagram-34T5L4WZ-DJcL08ft.js} +1 -1
  103. package/payload/server/public/assets/{requirementDiagram-MS252O5E-BPKpyT0n.js → requirementDiagram-MS252O5E-D4VdmmSu.js} +1 -1
  104. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-BvFIrihC.js → sankeyDiagram-XADWPNL6-Dsw3nHRS.js} +1 -1
  105. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-C6G1oAE0.js → sequenceDiagram-FGHM5R23-CAgemUx5.js} +1 -1
  106. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DbOJT_Xh.js → stateDiagram-FHFEXIEX-B7dyL9_X.js} +1 -1
  107. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CImN6Wk6.js +1 -0
  108. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-D1DSxHh6.js → timeline-definition-GMOUNBTQ-B7iKswhp.js} +1 -1
  109. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BYoSeMdo.js → vennDiagram-DHZGUBPP-uFS0i1qX.js} +1 -1
  110. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-DH3kZdea.js → wardleyDiagram-NUSXRM2D-lDUh4LAD.js} +1 -1
  111. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-C6MROMLG.js → xychartDiagram-5P7HB3ND-0v7MEVwW.js} +1 -1
  112. package/payload/server/public/data.html +5 -5
  113. package/payload/server/public/graph.html +6 -6
  114. package/payload/server/public/index.html +8 -8
  115. package/payload/server/public/public.html +5 -5
  116. package/payload/server/server.js +17 -0
  117. package/payload/server/public/assets/admin-C57bDUgs.js +0 -217
  118. package/payload/server/public/assets/channel-CfpByn-m.js +0 -1
  119. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DNc9R1IX.js +0 -1
  120. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DM1DQh8N.js +0 -1
  121. package/payload/server/public/assets/clone-BSrNdvLW.js +0 -1
  122. package/payload/server/public/assets/data-3n20lir5.js +0 -1
  123. package/payload/server/public/assets/graph-ChwMpxn_.js +0 -1
  124. package/payload/server/public/assets/graph-labels-CUgJgIwl.js +0 -1
  125. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ce4GFk-H.js +0 -1
  126. /package/payload/server/public/assets/{ChatInput-DoS80kaC.js → ChatInput-Cj9JQtYt.js} +0 -0
@@ -26,7 +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';
32
+ import { jsonlPathForSessionId, sidecarPathForSessionId } from './jsonl-path.js';
33
+ import { writeSidecar, updateSidecar } from './session-sidecar.js';
30
34
  import { probeMcpServerTools, } from './mcp-tools-probe.js';
31
35
  // CSI (Cursor / Style) escape sequence: ESC `[` <args> <final-letter>.
32
36
  const PTY_TAIL_CSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
@@ -55,6 +59,57 @@ function formatTailForLog(tail) {
55
59
  }
56
60
  import { composeAppendSystemPrompt, } from './system-prompt.js';
57
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
+ }
58
113
  /** Task 171 — specialists whose `--agent <name>` spawn has no human in the
59
114
  * loop: no operator browser tab, no chat channel, no `claude.ai/code/session_<id>`
60
115
  * URL to open. For these, `--remote-control` is dead weight (one outbound
@@ -316,21 +371,28 @@ function checkSlotsAndMaybeEvict(deps, args) {
316
371
  const specialistRows = liveRows.filter((r) => r.agent !== null);
317
372
  if (specialistRows.length >= deps.specialistCap) {
318
373
  const idleOldest = specialistRows
319
- .filter((r) => r.status === 'idle' && r.jsonlPath === null)
374
+ .filter((r) => r.status === 'idle' && !existsSync(jsonlPathForSessionId(deps.spawnCwd, r.sessionId)))
320
375
  .sort((a, b) => a.updatedAt - b.updatedAt)[0];
321
376
  if (!idleOldest) {
322
377
  deps.logger(`[pty-cap] specialist-cap-reached account=${deps.accountId} current=${specialistRows.length} cap=${deps.specialistCap} evicted=none`);
323
378
  return { kind: 'specialist-cap-reached', currentCount: specialistRows.length, cap: deps.specialistCap };
324
379
  }
325
- const victim = deps.store.get(idleOldest.sessionId);
380
+ const victim = livePtys.get(idleOldest.sessionId);
326
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.
327
388
  try {
328
- victim.pty.kill('SIGTERM');
389
+ const run = deps.runSystemctl ?? defaultSystemctlRunner;
390
+ stopScopeUnit(run, victim.scopeUnitToken);
329
391
  }
330
- catch { /* fire-and-forget */ }
392
+ catch { /* fire-and-forget — boot-seed orphan-stop will catch leaks */ }
331
393
  }
332
- deps.store.remove(idleOldest.sessionId);
333
- 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}`);
334
396
  }
335
397
  if (liveRows.length >= deps.totalPtyCap - deps.operatorReserve) {
336
398
  deps.logger(`[pty-cap] operator-slots-reserved total=${liveRows.length} reserve=${deps.operatorReserve} totalCap=${deps.totalPtyCap} rejected-specialist=${args.senderId}`);
@@ -494,6 +556,13 @@ export async function spawnClaudeSession(deps, args) {
494
556
  }
495
557
  const startedAt = new Date().toISOString();
496
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();
497
566
  const composedHost = deps.tunnelUrlOverride !== undefined
498
567
  ? { ...deps.host, tunnelUrl: deps.tunnelUrlOverride }
499
568
  : deps.host;
@@ -804,7 +873,7 @@ export async function spawnClaudeSession(deps, args) {
804
873
  const ptySpawnStart = Date.now();
805
874
  let pty;
806
875
  try {
807
- pty = deps.spawnPty(deps.claudeBin, argv, spawnEnv);
876
+ pty = deps.spawnPty(deps.claudeBin, argv, spawnEnv, unitToken);
808
877
  }
809
878
  catch (err) {
810
879
  const e = err;
@@ -816,14 +885,23 @@ export async function spawnClaudeSession(deps, args) {
816
885
  deps.logger(`pty-spawned pid=${pty.pid} ms=${Date.now() - ptySpawnStart}`);
817
886
  const tail = attachPtyTail(pty);
818
887
  // URL capture runs in parallel; its result is patched onto the
819
- // 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
820
889
  // it. A timeout here is logged but not a failure — the operator's
821
890
  // iframe URL falls back to UI-side construction from bridgeSessionId.
822
891
  //
823
892
  // Task 171 — headless spawns never emit the `/remote-control is active`
824
893
  // banner (the flag is absent), so the handler is not attached and no
825
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.
826
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 };
827
905
  if (!headless) {
828
906
  startUrlCapture({
829
907
  pty,
@@ -835,9 +913,16 @@ export async function spawnClaudeSession(deps, args) {
835
913
  const sid = sessionIdRef.current;
836
914
  if (!sid)
837
915
  return;
838
- const live = deps.store.get(sid);
839
- if (live)
840
- 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);
841
926
  deps.onUrlCaptured?.(sid);
842
927
  },
843
928
  onTimeout: () => {
@@ -845,11 +930,15 @@ export async function spawnClaudeSession(deps, args) {
845
930
  },
846
931
  });
847
932
  }
848
- // Mutable holder so the URL callback can find the row once sessionId
849
- // is known. The PID-file event below sets sessionIdRef.current.
850
- const sessionIdRef = { current: null };
851
933
  // Wait for the watcher's PID-file event. The watcher resolves
852
934
  // synchronously if the file already exists (race-safe boot scan).
935
+ // Task 258 — for /resume, the archived jsonl was already restored to
936
+ // the top-level path before this spawn ran; claude --resume appends to
937
+ // it and (per Task 186) rewrites its pid file to carry the prior
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.
853
942
  let row;
854
943
  try {
855
944
  row = await deps.watcher.waitForPid(pty.pid, deps.pidFileTimeoutMs);
@@ -862,27 +951,64 @@ export async function spawnClaudeSession(deps, args) {
862
951
  deps.logger(`spawn-failed reason=pid-file-timeout pid=${pty.pid} detail=${JSON.stringify(detail)} stderr-tail=${formatTailForLog(stderrTail)}`);
863
952
  return { ok: false, reason: 'pid-file-timeout', stderrTail };
864
953
  }
865
- sessionIdRef.current = row.sessionId;
866
- const session = {
867
- sessionId: row.sessionId,
868
- bridgeSessionId: row.bridgeSessionId ?? `session_${row.sessionId}`,
869
- bridgeSuffix: row.bridgeSuffix ?? row.sessionId,
870
- pid: pty.pid,
871
- url: capturedUrl,
872
- startedAt,
954
+ const sessionId = args.resumeClaudeSessionId ?? row.sessionId;
955
+ sessionIdRef.current = sessionId;
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 = {
873
966
  senderId: args.senderId,
874
967
  role: args.role,
875
968
  channel: args.channel,
876
- jsonlPath: row.jsonlPath,
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 = {
997
+ sessionId,
998
+ bridgeSessionId: row.bridgeSessionId ?? `session_${sessionId}`,
999
+ bridgeSuffix: row.bridgeSuffix ?? sessionId,
1000
+ pid: pty.pid,
877
1001
  pty,
878
1002
  exited: false,
879
1003
  fdReleased: false,
880
- hidden: args.hidden === true,
881
- specialist: args.specialist ?? row.agent ?? null,
1004
+ hidden,
1005
+ specialist,
1006
+ scopeUnitToken: unitToken,
882
1007
  };
883
- deps.store.add(session);
884
- if (args.hidden === true) {
885
- 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}`);
886
1012
  }
887
1013
  // PTY exit: mark exited; the watcher will fire a delete event when
888
1014
  // claude removes its PID file, which the http-server subscriber
@@ -890,12 +1016,12 @@ export async function spawnClaudeSession(deps, args) {
890
1016
  pty.onExit((e) => {
891
1017
  const stderrTail = tail.getTail();
892
1018
  tail.dispose();
893
- handlePtyNaturalExit({ store: deps.store, logger: deps.logger }, session, e, stderrTail);
1019
+ handlePtyNaturalExit({ logger: deps.logger }, tracker, e, stderrTail);
894
1020
  });
895
1021
  const responseMs = Date.now() - start;
896
- deps.logger(`spawn-response-returned sessionId=${session.sessionId} pid=${pty.pid} ms=${responseMs}`);
897
- 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}`);
898
- 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'}`);
899
1025
  // Task 178 — emit [pty-spawn-tool-inventory] for specialist spawns. The
900
1026
  // probe runs in parallel with the real claude child's own MCP handshake
901
1027
  // and is fire-and-forget: it never blocks the spawn response, and its
@@ -949,7 +1075,7 @@ export async function spawnClaudeSession(deps, args) {
949
1075
  }));
950
1076
  const inventoryTimeoutMs = deps.toolInventoryProbeTimeoutMs ?? 5_000;
951
1077
  emitToolInventoryAsync({
952
- sessionId: session.sessionId,
1078
+ sessionId: sessionId,
953
1079
  specialist: args.specialist,
954
1080
  frontmatterAllowlist: specialistTools,
955
1081
  resolvedServers,
@@ -967,7 +1093,7 @@ export async function spawnClaudeSession(deps, args) {
967
1093
  // this task investigates): without the timer, a spawn that never
968
1094
  // produces a first turn would never emit a final snapshot, and the
969
1095
  // operator would have no signal at all for the broken case.
970
- finalSnapshotState.set(session.sessionId, {
1096
+ finalSnapshotState.set(sessionId, {
971
1097
  specialist: args.specialist,
972
1098
  frontmatterAllowlist: specialistTools,
973
1099
  resolvedServers,
@@ -978,7 +1104,7 @@ export async function spawnClaudeSession(deps, args) {
978
1104
  logger: deps.logger,
979
1105
  spawnedAt: Date.now(),
980
1106
  fired: false,
981
- watchdog: setTimeout(() => fireFinalSnapshot(session.sessionId, 'first-turn-timeout'), firstTurnTimeoutMs),
1107
+ watchdog: setTimeout(() => fireFinalSnapshot(sessionId, 'first-turn-timeout'), firstTurnTimeoutMs),
982
1108
  });
983
1109
  }
984
1110
  // Task 183 — register the specialist spawn with the JSONL tail. The
@@ -996,12 +1122,12 @@ export async function spawnClaudeSession(deps, args) {
996
1122
  // probe block); whichever fires first wins, the other is a no-op via
997
1123
  // the `fired` guard inside `fireFinalSnapshot`.
998
1124
  if (isSpecialistSpawn && deps.jsonlTail) {
999
- deps.jsonlTail.register(session.sessionId, {
1125
+ deps.jsonlTail.register(sessionId, {
1000
1126
  specialist: args.specialist,
1001
1127
  argvTools: [],
1002
- onFirstTurn: () => fireFinalSnapshot(session.sessionId, 'first-turn'),
1128
+ onFirstTurn: () => fireFinalSnapshot(sessionId, 'first-turn'),
1003
1129
  });
1004
- pty.onExit(() => deps.jsonlTail?.unregister(session.sessionId));
1130
+ pty.onExit(() => deps.jsonlTail?.unregister(sessionId));
1005
1131
  }
1006
1132
  // Task 215 — PTY-exit cleanup for the final-snapshot state runs
1007
1133
  // INDEPENDENTLY of the JsonlTail wiring. When `deps.jsonlTail` is
@@ -1012,48 +1138,63 @@ export async function spawnClaudeSession(deps, args) {
1012
1138
  // against a long-dead session 60 s later.
1013
1139
  if (isSpecialistSpawn && specialistTools !== null) {
1014
1140
  pty.onExit(() => {
1015
- const state = finalSnapshotState.get(session.sessionId);
1141
+ const state = finalSnapshotState.get(sessionId);
1016
1142
  if (state?.watchdog)
1017
1143
  clearTimeout(state.watchdog);
1018
- finalSnapshotState.delete(session.sessionId);
1144
+ finalSnapshotState.delete(sessionId);
1019
1145
  });
1020
1146
  }
1021
- deps.onSessionReady?.(session);
1022
- 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
+ };
1023
1158
  }
1024
1159
  /** Task 176 — natural-exit handler. Fires when the claude child exits on
1025
1160
  * its own (operator `/quit`, SIGINT in the PTY, crash, network drop on
1026
1161
  * `--remote-control`), with no operator `/stop` and no auto-archive in
1027
- * flight. Mirrors `stopSession`'s post-Task-170 fd-release + store-remove
1028
- * contract on the second exit branch the original task explicitly
1029
- * out-of-scoped. Without this, the master fd lingers in node-pty's
1030
- * internal socket until V8 GC finalises the IPty object and the
1031
- * SessionStore row survives until manager shutdown — so the shutdown
1032
- * 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`.
1033
1168
  *
1034
1169
  * Side-effect order is load-bearing: set `exited` first so any
1035
- * concurrent `stopSession` sees the already-exited branch; release the
1170
+ * concurrent `killSession` sees the already-exited branch; release the
1036
1171
  * fd second (idempotent on a destroyed socket via the try/catch in
1037
- * `releasePtyMasterFd`); drop the row third (Map.delete on a missing
1038
- * key is a no-op, so the operator-request follow-up that races us is
1039
- * harmless). The log line carries the same `master-fd=...` suffix the
1040
- * operator-request path emits — every `kill … pid=<n>` line in
1041
- * `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.
1042
1177
  *
1043
1178
  * Exported so `__tests__/stop-session-fd-release.test.ts` can verify
1044
1179
  * the contract end-to-end without re-implementing the handler in test
1045
1180
  * code. */
1046
- export function handlePtyNaturalExit(deps, session, exit, stderrTail) {
1047
- session.exited = true;
1048
- // 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
1049
1184
  // destroy() on a destroyed node-pty socket throws "socket already
1050
1185
  // destroyed" and would turn this line's suffix into a false
1051
1186
  // `master-fd=close-failed`.
1052
- const fdSuffix = session.fdReleased ? 'master-fd=closed' : releasePtyMasterFd(session);
1187
+ const fdSuffix = tracker.fdReleased ? 'master-fd=closed' : releasePtyMasterFd(tracker);
1053
1188
  if (fdSuffix === 'master-fd=closed')
1054
- session.fdReleased = true;
1055
- deps.store.remove(session.sessionId);
1056
- 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}`);
1057
1198
  }
1058
1199
  /** Release the pty master fd by destroying node-pty's internal socket.
1059
1200
  * Without this, the fd lingers until V8 GC finalizes the IPty object —
@@ -1062,9 +1203,9 @@ export function handlePtyNaturalExit(deps, session, exit, stderrTail) {
1062
1203
  * err=..." on throw). The throw path is rescued so a destroy failure
1063
1204
  * on a corner-case socket state cannot turn a logically-successful
1064
1205
  * stop into a 500. Task 170. */
1065
- function releasePtyMasterFd(s) {
1206
+ function releasePtyMasterFd(t) {
1066
1207
  try {
1067
- s.pty.destroy?.();
1208
+ t.pty.destroy?.();
1068
1209
  return 'master-fd=closed';
1069
1210
  }
1070
1211
  catch (err) {
@@ -1072,42 +1213,100 @@ function releasePtyMasterFd(s) {
1072
1213
  return `master-fd=close-failed err=${JSON.stringify(msg)}`;
1073
1214
  }
1074
1215
  }
1075
- /** SIGTERM-only stop. Idempotentalready-exited rows resolve immediately.
1076
- * 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. */
1077
1231
  export async function stopSession(deps, sessionId) {
1078
- const s = deps.store.get(sessionId);
1079
- if (!s)
1080
- return { existed: false };
1081
- 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) {
1082
1239
  // Task 176 short-circuit: if the natural-exit handler already
1083
1240
  // released the fd, skip the second destroy() — on real node-pty it
1084
1241
  // would throw "socket already destroyed" and we'd emit a false
1085
1242
  // `master-fd=close-failed`. Defense in depth still holds: if the
1086
1243
  // earlier destroy threw, fdReleased stays false and we retry here.
1087
- const fdSuffix = s.fdReleased ? 'master-fd=closed' : releasePtyMasterFd(s);
1244
+ const fdSuffix = t.fdReleased ? 'master-fd=closed' : releasePtyMasterFd(t);
1088
1245
  if (fdSuffix === 'master-fd=closed')
1089
- s.fdReleased = true;
1090
- deps.store.remove(sessionId);
1091
- deps.logger(`kill sessionId=${sessionId} pid=${s.pid} reason=operator-request status=already-exited ${fdSuffix}`);
1092
- 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 };
1093
1252
  }
1094
- s.pty.kill('SIGTERM');
1095
- await new Promise((resolve) => {
1096
- const grace = setTimeout(() => {
1097
- if (!s.exited)
1098
- s.pty.kill('SIGKILL');
1099
- resolve();
1100
- }, deps.killGraceMs);
1101
- s.pty.onExit(() => {
1102
- clearTimeout(grace);
1103
- resolve();
1104
- });
1105
- });
1106
- 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);
1107
1264
  if (fdSuffix === 'master-fd=closed')
1108
- s.fdReleased = true;
1109
- deps.store.remove(sessionId);
1110
- deps.logger(`kill sessionId=${sessionId} pid=${s.pid} reason=operator-request exit-code=0 ${fdSuffix}`);
1111
- 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 };
1112
1311
  }
1113
1312
  //# sourceMappingURL=pty-spawner.js.map