@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.
- package/dist/__tests__/claude-ptys-slice.test.js +25 -0
- package/dist/index.js +10 -1
- package/dist/port-resolution.js +29 -0
- package/package.json +1 -1
- package/payload/platform/plugins/docs/references/platform.md +4 -2
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +61 -2
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +337 -32
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +1 -3
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +307 -209
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +62 -13
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts +9 -0
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.js +14 -0
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +165 -33
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +283 -92
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +4 -2
- package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/reaper.js +23 -22
- package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts +55 -0
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.js +170 -0
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +114 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +209 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -0
- package/payload/server/public/assets/{ChatInput-dLWJ0Q8I.css → ChatInput-CzQhPt90.css} +1 -1
- package/payload/server/public/assets/{Checkbox-Bx8mRHQ3.js → Checkbox-ynZtDWi3.js} +1 -1
- package/payload/server/public/assets/{admin-BhEvxMrC.js → admin-ux6TKHS1.js} +1 -1
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BEW_bD50.js → architectureDiagram-Q4EWVU46-Ko_izKmY.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-DFLT98C3.js → blockDiagram-DXYQGD6D-D58VDC0w.js} +1 -1
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-BemKtROA.js → c4Diagram-AHTNJAMY-C-eRXyKi.js} +1 -1
- package/payload/server/public/assets/channel-CejaITMF.js +1 -0
- package/payload/server/public/assets/{chunk-336JU56O-BL8fGJ7_.js → chunk-336JU56O-DZLEfLAb.js} +2 -2
- package/payload/server/public/assets/{chunk-426QAEUC-BD6by0MP.js → chunk-426QAEUC-BriTU3L8.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-DX-cb6Nd.js → chunk-4TB4RGXK-BdxRL-AY.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-B2QqtIZS.js → chunk-5FUZZQ4R-BgEmy7Y3.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-CzJWmsmd.js → chunk-5PVQY5BW-emnbDRLA.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-DP9vcvEU.js → chunk-EDXVE4YY-BvCf2Xf2.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-D5xWEjF0.js → chunk-ENJZ2VHE-DTpEYJRb.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-AKduf9o4.js → chunk-ICPOFSXX-Ced2j48Y.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-D6jsVNyQ.js → chunk-OYMX7WX6-B21_a3Dv.js} +1 -1
- package/payload/server/public/assets/{chunk-U2HBQHQK-BNAnTYh8.js → chunk-U2HBQHQK-CPS3yyXK.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-UdQhYDnh.js → chunk-X2U36JSP-BJ_B5NW7.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-DSSmSGmS.js → chunk-YZCP3GAM-CMLy-WXm.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-Czm7IDMC.js → chunk-ZZ45TVLE-BkLnzCHe.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-BmoqALf4.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-vMajwCNi.js +1 -0
- package/payload/server/public/assets/clone-mvVfHkyg.js +1 -0
- package/payload/server/public/assets/{dagre-hSIMHJcO.js → dagre-Dd_gq4yj.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-CBuRCJE7.js → dagre-KV5264BT-RY0E5UAn.js} +1 -1
- package/payload/server/public/assets/data-BK4r8Dqy.js +1 -0
- package/payload/server/public/assets/{diagram-5BDNPKRD-dbMrH0le.js → diagram-5BDNPKRD-DR7XiPLh.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-DBc6hs9a.js → diagram-G4DWMVQ6-2EbOfKFs.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-ubSGybUk.js → diagram-MMDJMWI5-CGcY0Ze7.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-WxL3cv-9.js → diagram-TYMM5635-Dk6MmAso.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-CrBBOAN8.js → erDiagram-SMLLAGMA-CKvdlUqk.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CXUq5Hzt.js → flowDiagram-DWJPFMVM-BqKhvCDz.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-vTCBnYVS.js → ganttDiagram-T4ZO3ILL-CeiE-IRb.js} +1 -1
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-BHrj_bRa.js → gitGraphDiagram-UUTBAWPF-BbPKn7v2.js} +1 -1
- package/payload/server/public/assets/graph-Cb6A8Hhs.js +1 -0
- package/payload/server/public/assets/graph-labels-DCsUNtnE.js +1 -0
- package/payload/server/public/assets/{graphlib-CDO75l2X.js → graphlib-tG6kR59B.js} +1 -1
- package/payload/server/public/assets/{infoDiagram-42DDH7IO-CcQdaOhi.js → infoDiagram-42DDH7IO-6MiRGCI1.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-3KvltFgV.js → ishikawaDiagram-UXIWVN3A-BwY4p5mg.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-DZCXaGFE.js → journeyDiagram-VCZTEJTY-BXAQ_fUi.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DAIAKy1-.js → kanban-definition-6JOO6SKY-ChiiO7Yt.js} +1 -1
- package/payload/server/public/assets/{lib-CCLURO3E.js → lib-DbPz9cfu.js} +1 -1
- package/payload/server/public/assets/{line-C8iCE2Wx.js → line-B5g5OrOD.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-ElvUhnV9.js → mermaid-parser.core-BMGzMKJx.js} +1 -1
- package/payload/server/public/assets/{mermaid.core-7dSH31Ao.js → mermaid.core-DLMgkjXn.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-FZ0rcfac.js → mindmap-definition-QFDTVHPH-BvyzRLAi.js} +1 -1
- package/payload/server/public/assets/{page-C1hj89Gf.js → page-BeYR_GI2.js} +1 -1
- package/payload/server/public/assets/{page-Bu8kO4jO.js → page-DVNp-Dfy.js} +1 -1
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-CDuvMZbO.js → pieDiagram-DEJITSTG--EV9pVfs.js} +1 -1
- package/payload/server/public/assets/{public-g_2skbnY.js → public-sYW0Dwku.js} +3 -3
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-xDjhkqNc.js → quadrantDiagram-34T5L4WZ-DJcL08ft.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-BPKpyT0n.js → requirementDiagram-MS252O5E-D4VdmmSu.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-BvFIrihC.js → sankeyDiagram-XADWPNL6-Dsw3nHRS.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-C6G1oAE0.js → sequenceDiagram-FGHM5R23-CAgemUx5.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DbOJT_Xh.js → stateDiagram-FHFEXIEX-B7dyL9_X.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CImN6Wk6.js +1 -0
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-D1DSxHh6.js → timeline-definition-GMOUNBTQ-B7iKswhp.js} +1 -1
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BYoSeMdo.js → vennDiagram-DHZGUBPP-uFS0i1qX.js} +1 -1
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-DH3kZdea.js → wardleyDiagram-NUSXRM2D-lDUh4LAD.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-C6MROMLG.js → xychartDiagram-5P7HB3ND-0v7MEVwW.js} +1 -1
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +8 -8
- package/payload/server/public/public.html +5 -5
- package/payload/server/public/assets/channel-CfpByn-m.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DNc9R1IX.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DM1DQh8N.js +0 -1
- package/payload/server/public/assets/clone-BSrNdvLW.js +0 -1
- package/payload/server/public/assets/data-Cbo0tTS3.js +0 -1
- package/payload/server/public/assets/graph-Beucu3_J.js +0 -1
- package/payload/server/public/assets/graph-labels-D15-eFQK.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ce4GFk-H.js +0 -1
- /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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
840
|
-
|
|
841
|
-
|
|
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
|
|
858
|
-
// canonical id passed in via resumeClaudeSessionId so the
|
|
859
|
-
// identity is stable even during the brief window where claude's
|
|
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
|
|
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
|
|
889
|
-
specialist
|
|
1004
|
+
hidden,
|
|
1005
|
+
specialist,
|
|
1006
|
+
scopeUnitToken: unitToken,
|
|
890
1007
|
};
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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({
|
|
1019
|
+
handlePtyNaturalExit({ logger: deps.logger }, tracker, e, stderrTail);
|
|
902
1020
|
});
|
|
903
1021
|
const responseMs = Date.now() - start;
|
|
904
|
-
deps.logger(`spawn-response-returned sessionId=${
|
|
905
|
-
deps.logger(`spawn pid=${pty.pid} sessionId=${
|
|
906
|
-
deps.logger(`[pty-spawn] sessionId=${
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
1125
|
+
deps.jsonlTail.register(sessionId, {
|
|
1008
1126
|
specialist: args.specialist,
|
|
1009
1127
|
argvTools: [],
|
|
1010
|
-
onFirstTurn: () => fireFinalSnapshot(
|
|
1128
|
+
onFirstTurn: () => fireFinalSnapshot(sessionId, 'first-turn'),
|
|
1011
1129
|
});
|
|
1012
|
-
pty.onExit(() => deps.jsonlTail?.unregister(
|
|
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(
|
|
1141
|
+
const state = finalSnapshotState.get(sessionId);
|
|
1024
1142
|
if (state?.watchdog)
|
|
1025
1143
|
clearTimeout(state.watchdog);
|
|
1026
|
-
finalSnapshotState.delete(
|
|
1144
|
+
finalSnapshotState.delete(sessionId);
|
|
1027
1145
|
});
|
|
1028
1146
|
}
|
|
1029
|
-
deps.onSessionReady?.(
|
|
1030
|
-
return {
|
|
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 `
|
|
1036
|
-
* contract on the second exit branch the original task
|
|
1037
|
-
* out-of-scoped. Without this, the master fd lingers in
|
|
1038
|
-
* internal socket until V8 GC finalises the IPty object
|
|
1039
|
-
*
|
|
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 `
|
|
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
|
|
1046
|
-
* key is a no-op, so the operator-request follow-up that
|
|
1047
|
-
* harmless). The log line carries the same `master-fd=...`
|
|
1048
|
-
* operator-request path emits — every `kill … pid=<n>`
|
|
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,
|
|
1055
|
-
|
|
1056
|
-
// Skip if a racing `
|
|
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 =
|
|
1187
|
+
const fdSuffix = tracker.fdReleased ? 'master-fd=closed' : releasePtyMasterFd(tracker);
|
|
1061
1188
|
if (fdSuffix === 'master-fd=closed')
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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(
|
|
1206
|
+
function releasePtyMasterFd(t) {
|
|
1074
1207
|
try {
|
|
1075
|
-
|
|
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
|
-
/**
|
|
1084
|
-
*
|
|
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
|
|
1087
|
-
if (!
|
|
1088
|
-
return { existed: false };
|
|
1089
|
-
|
|
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 =
|
|
1244
|
+
const fdSuffix = t.fdReleased ? 'master-fd=closed' : releasePtyMasterFd(t);
|
|
1096
1245
|
if (fdSuffix === 'master-fd=closed')
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
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
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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
|