@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.
- 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/package-lock.json +18 -4
- package/payload/platform/plugins/docs/references/platform.md +4 -2
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +65 -5
- 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 +468 -223
- 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 +10 -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 +486 -193
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +72 -13
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +10 -4
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +27 -8
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts +17 -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 +25 -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 +290 -91
- 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 +6 -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 +29 -25
- 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/session-store.d.ts +6 -4
- package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-store.js +8 -10
- package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
- 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/platform/services/claude-session-manager/dist/types.d.ts +0 -4
- package/payload/platform/services/claude-session-manager/dist/types.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/user-title-store.d.ts +39 -0
- package/payload/platform/services/claude-session-manager/dist/user-title-store.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/user-title-store.js +138 -0
- package/payload/platform/services/claude-session-manager/dist/user-title-store.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +3 -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-ux6TKHS1.js +217 -0
- 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-D3bFmqF4.js → page-BeYR_GI2.js} +1 -1
- package/payload/server/public/assets/{page-DNjUtT5q.js → page-DVNp-Dfy.js} +2 -2
- 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/server.js +17 -0
- package/payload/server/public/assets/admin-C57bDUgs.js +0 -217
- 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-3n20lir5.js +0 -1
- package/payload/server/public/assets/graph-ChwMpxn_.js +0 -1
- package/payload/server/public/assets/graph-labels-CUgJgIwl.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,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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
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
|
|
881
|
-
specialist
|
|
1004
|
+
hidden,
|
|
1005
|
+
specialist,
|
|
1006
|
+
scopeUnitToken: unitToken,
|
|
882
1007
|
};
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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({
|
|
1019
|
+
handlePtyNaturalExit({ logger: deps.logger }, tracker, e, stderrTail);
|
|
894
1020
|
});
|
|
895
1021
|
const responseMs = Date.now() - start;
|
|
896
|
-
deps.logger(`spawn-response-returned sessionId=${
|
|
897
|
-
deps.logger(`spawn pid=${pty.pid} sessionId=${
|
|
898
|
-
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'}`);
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
1125
|
+
deps.jsonlTail.register(sessionId, {
|
|
1000
1126
|
specialist: args.specialist,
|
|
1001
1127
|
argvTools: [],
|
|
1002
|
-
onFirstTurn: () => fireFinalSnapshot(
|
|
1128
|
+
onFirstTurn: () => fireFinalSnapshot(sessionId, 'first-turn'),
|
|
1003
1129
|
});
|
|
1004
|
-
pty.onExit(() => deps.jsonlTail?.unregister(
|
|
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(
|
|
1141
|
+
const state = finalSnapshotState.get(sessionId);
|
|
1016
1142
|
if (state?.watchdog)
|
|
1017
1143
|
clearTimeout(state.watchdog);
|
|
1018
|
-
finalSnapshotState.delete(
|
|
1144
|
+
finalSnapshotState.delete(sessionId);
|
|
1019
1145
|
});
|
|
1020
1146
|
}
|
|
1021
|
-
deps.onSessionReady?.(
|
|
1022
|
-
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
|
+
};
|
|
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 `
|
|
1028
|
-
* contract on the second exit branch the original task
|
|
1029
|
-
* out-of-scoped. Without this, the master fd lingers in
|
|
1030
|
-
* internal socket until V8 GC finalises the IPty object
|
|
1031
|
-
*
|
|
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 `
|
|
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
|
|
1038
|
-
* key is a no-op, so the operator-request follow-up that
|
|
1039
|
-
* harmless). The log line carries the same `master-fd=...`
|
|
1040
|
-
* operator-request path emits — every `kill … pid=<n>`
|
|
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,
|
|
1047
|
-
|
|
1048
|
-
// 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
|
|
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 =
|
|
1187
|
+
const fdSuffix = tracker.fdReleased ? 'master-fd=closed' : releasePtyMasterFd(tracker);
|
|
1053
1188
|
if (fdSuffix === 'master-fd=closed')
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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(
|
|
1206
|
+
function releasePtyMasterFd(t) {
|
|
1066
1207
|
try {
|
|
1067
|
-
|
|
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
|
-
/**
|
|
1076
|
-
*
|
|
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
|
|
1079
|
-
if (!
|
|
1080
|
-
return { existed: false };
|
|
1081
|
-
|
|
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 =
|
|
1244
|
+
const fdSuffix = t.fdReleased ? 'master-fd=closed' : releasePtyMasterFd(t);
|
|
1088
1245
|
if (fdSuffix === 'master-fd=closed')
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|