cc-viewer 1.9.3 → 1.9.4
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/README.md +5 -5
- package/cli.js +24 -0
- package/dist/assets/App-DtBVNYE7.js +2 -0
- package/dist/assets/App-DuDeDOuX.css +1 -0
- package/dist/assets/{MdxEditorPanel-Ck8B5a5v.js → MdxEditorPanel-C8Cij93y.js} +1 -1
- package/dist/assets/{Mobile-B8AkKb0z.js → Mobile-OKCVi8un.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DsDVMf-c.js → ProxyStatsModal-DGlngtM9.js} +1 -1
- package/dist/assets/index-Clt4dw8Y.js +2 -0
- package/dist/assets/{seqResourceLoaders-BsMFNfvR.css → seqResourceLoaders-Bg9Lz8pH.css} +1 -1
- package/dist/assets/seqResourceLoaders-zlIVjapi.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +11 -0
- package/server/lib/sdk-manager.js +1521 -895
- package/server/routes/events.js +20 -0
- package/server/routes/resume.js +62 -1
- package/server/routes/sdk-context.js +24 -0
- package/server/routes/sdk-mcp-status.js +24 -0
- package/server/routes/workspaces.js +24 -1
- package/server/scratch-pty-manager.js +127 -46
- package/server/server.js +326 -41
- package/dist/assets/App-BzXO6ATr.js +0 -2
- package/dist/assets/App-DJ4yNvwz.css +0 -1
- package/dist/assets/index-Dq5UPr6v.js +0 -2
- package/dist/assets/seqResourceLoaders-BsQ6KXpV.js +0 -2
package/server/routes/events.js
CHANGED
|
@@ -50,6 +50,11 @@ function turnEndNotify(req, res, parsedUrl, isLocal, deps) {
|
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
52
52
|
deps.scheduleTurnEndBroadcast(payload.sessionId || null, payload.ts || Date.now(), payload.transcriptPath || null);
|
|
53
|
+
// Turn-level loading clears on the RAW Stop POST arrival — the debounced
|
|
54
|
+
// `turn_end` SSE frame (voice-pack semantics) would keep the spinner up for
|
|
55
|
+
// the whole debounce window. Idempotent on the server side.
|
|
56
|
+
try { deps.onTurnActiveClear?.(); }
|
|
57
|
+
catch (err) { reportSwallowed('turn-end-notify.turn-active-clear', err); }
|
|
53
58
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
54
59
|
res.end(JSON.stringify({ ok: true }));
|
|
55
60
|
});
|
|
@@ -578,6 +583,21 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
578
583
|
if (snap) sseWrite(res, `event: task_update\ndata: ${JSON.stringify({ sessionId: snap.sessionId, tasks: snap.tasks, ts: Date.now() })}\n\n`);
|
|
579
584
|
} catch (err) { reportSwallowed('sse.task_update.snapshot', err); }
|
|
580
585
|
|
|
586
|
+
// Turn-level loading replay: a page refresh mid-turn restores the spinner in the
|
|
587
|
+
// same connect block (turn-level state is in-memory only, not in the cold load).
|
|
588
|
+
// The flag is a bound-project global (same constraint as streamingState), so the
|
|
589
|
+
// replay only goes to viewers of the bound project — a chip-switched parallel view
|
|
590
|
+
// must not latch the bound project's spinner. NOTE: this makes turn_active MORE
|
|
591
|
+
// filtered than streaming_status/turn_end (which broadcast to all); the parallel
|
|
592
|
+
// view simply shows no spinner for its own project, matching the pre-multi-PTY
|
|
593
|
+
// single-project posture documented at the streaming_status timer.
|
|
594
|
+
try {
|
|
595
|
+
const viewedIsBound = !viewedProject || viewedProject === (_projectName || '');
|
|
596
|
+
if (viewedIsBound && deps.getPtyTurnActive?.()) {
|
|
597
|
+
sseWrite(res, `event: turn_active\ndata: ${JSON.stringify({ active: true, sessionId: null, ts: Date.now() })}\n\n`);
|
|
598
|
+
}
|
|
599
|
+
} catch (err) { reportSwallowed('sse.turn_active.replay', err); }
|
|
600
|
+
|
|
581
601
|
// req.on('close') 在某些异常断连时不一定立即触发;res 端 close/error 兜底保证
|
|
582
602
|
// 不会在 clients 数组里留下幽灵 res,防止 sendToClients 后续写入触发慢泄漏。
|
|
583
603
|
const removeFromClients = () => {
|
package/server/routes/resume.js
CHANGED
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
// The aggregation itself lives in lib/v2/resume-list.js (pure, unit-tested);
|
|
39
39
|
// this handler is a thin HTTP wrapper.
|
|
40
40
|
|
|
41
|
+
import { realpathSync } from 'node:fs';
|
|
42
|
+
import { join, dirname, basename } from 'node:path';
|
|
41
43
|
import { LOG_DIR } from '../../findcc.js';
|
|
42
44
|
import { _projectName, _v2Writer, initForWorkspace } from '../interceptor.js';
|
|
43
45
|
// Static import (not the route-local dynamic import) so the close-rebind helper
|
|
@@ -121,6 +123,30 @@ async function getLiveProcessesHandler(req, res, parsedUrl) {
|
|
|
121
123
|
});
|
|
122
124
|
}
|
|
123
125
|
} catch (err) { reportSwallowed('resume-route.live-main', err); }
|
|
126
|
+
// SDK mode (2026-10-12): SDK sessions are NOT PTYs, so listLivePtys() never contains them —
|
|
127
|
+
// without an entry the header's tab/chip source has no anchor for an SDK workspace, and the
|
|
128
|
+
// first launch's PTY-less list collapsed to just the new project. Enumerate the per-cwd SDK
|
|
129
|
+
// session registry (multi-session) as `kind:'main'` entries so each launched workspace shows
|
|
130
|
+
// up exactly like a PTY project. `active` marks the session the SDK composer is bound to;
|
|
131
|
+
// `closable:false` (no PTY to kill) tells the header to suppress the ×.
|
|
132
|
+
if (process.env.CCV_SDK_MODE === '1') {
|
|
133
|
+
try {
|
|
134
|
+
const sdk = await import('../lib/sdk-manager.js');
|
|
135
|
+
for (const e of sdk.listSdkSessions()) {
|
|
136
|
+
if (!e || !e.cwd) continue;
|
|
137
|
+
if (processes.some((p) => p && p.cwd === e.cwd)) continue; // a real PTY already covers this cwd
|
|
138
|
+
processes.push({
|
|
139
|
+
kind: 'main',
|
|
140
|
+
project: projectKeyForCwd(e.cwd),
|
|
141
|
+
cwd: e.cwd,
|
|
142
|
+
pid: null,
|
|
143
|
+
active: e.active === true,
|
|
144
|
+
instanceKey: null,
|
|
145
|
+
closable: false, // SDK resident query — killed via session switch, not the × close path
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
} catch (err) { reportSwallowed('resume-route.live-sdk', err); }
|
|
149
|
+
}
|
|
124
150
|
sendJson(res, 200, { processes, currentProject: _projectName || '', currentInstanceKey });
|
|
125
151
|
}
|
|
126
152
|
|
|
@@ -236,6 +262,38 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
236
262
|
try {
|
|
237
263
|
rebound = _maybeRebindAfterClose(r, deps);
|
|
238
264
|
} catch (err) { reportSwallowed('resume-route.close-rebind', err); }
|
|
265
|
+
// Closing a project/instance must also reap its scratch (mini-terminal) shells, or
|
|
266
|
+
// the viewed-then-switched-away workspace's shells stay alive as orphans until a
|
|
267
|
+
// full workspacesStop/process exit. The killed record's original spawn cwd keys
|
|
268
|
+
// the scratch buckets; scratch cwds were realpathSync-normalized at the upgrade,
|
|
269
|
+
// so match BOTH path spaces — when the workspace dir was later deleted,
|
|
270
|
+
// realpathSync(raw) throws and the raw spawn cwd is the only way to still hit the
|
|
271
|
+
// (realpath-keyed) bucket. Skip the reap when a surviving live claude record shares
|
|
272
|
+
// the cwd (a deliberate same-cwd second instance): its viewers still use those
|
|
273
|
+
// shared scratch shells.
|
|
274
|
+
try {
|
|
275
|
+
if (r.cwd) {
|
|
276
|
+
// listLivePtys() already only yields LIVE records (ptyProcess non-null), so a
|
|
277
|
+
// cwd match here is a surviving instance sharing the dir.
|
|
278
|
+
const survivorSharesCwd = listLivePtys().some((p) => p && p.cwd === r.cwd);
|
|
279
|
+
if (!survivorSharesCwd) {
|
|
280
|
+
// Build the candidate set in BOTH path spaces (raw spawn cwd + realpath).
|
|
281
|
+
// A deleted workspace dir makes realpathSync(raw) throw — then fall back to
|
|
282
|
+
// realpath(dirname)+basename, which still resolves a deleted LEAF dir through
|
|
283
|
+
// macOS's /tmp→/private/tmp and /var→/private/var symlinks (the exact case
|
|
284
|
+
// where the realpath-keyed bucket would otherwise be missed and leak).
|
|
285
|
+
const cands = [r.cwd];
|
|
286
|
+
const add = (p) => { if (p && !cands.includes(p)) cands.push(p); };
|
|
287
|
+
try {
|
|
288
|
+
add(realpathSync(r.cwd));
|
|
289
|
+
} catch {
|
|
290
|
+
try { add(join(realpathSync(dirname(r.cwd)), basename(r.cwd))); } catch { /* give up */ }
|
|
291
|
+
}
|
|
292
|
+
const { killScratchForCwd } = await import('../scratch-pty-manager.js');
|
|
293
|
+
killScratchForCwd(cands);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch (err) { reportSwallowed('resume-route.close-kill-scratch', err); }
|
|
239
297
|
sendJson(res, 200, { ok: true, project, instanceKey: r.key || instanceKey || null, killedActive: r.killedActive === true, rebound });
|
|
240
298
|
} catch (err) {
|
|
241
299
|
reportSwallowed('resume-route.close', err);
|
|
@@ -415,7 +473,10 @@ function postResumeSessionHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
415
473
|
// Read the env at request time (not module top) so tests can flip it per case.
|
|
416
474
|
if (process.env.CCV_SDK_MODE === '1') {
|
|
417
475
|
const sdk = await import('../lib/sdk-manager.js');
|
|
418
|
-
|
|
476
|
+
// Multi-session (2026-10-12): honor the requested workspace — resume the VIEWED
|
|
477
|
+
// session, not always the active one. project is the workspace name from the client.
|
|
478
|
+
const anchor = (typeof project === 'string' && project) ? { project } : undefined;
|
|
479
|
+
const r = sdk.switchToSession(sessionUuid, anchor);
|
|
419
480
|
if (r.ok) { sendJson(res, 200, { ok: true }); return; }
|
|
420
481
|
if (r.reason === 'busy') { sendJson(res, 409, { ok: false, reason: 'busy' }); return; }
|
|
421
482
|
if (r.reason === 'unavailable') { sendJson(res, 409, { ok: false, reason: 'unavailable' }); return; }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SDK-mode "Context usage" on-demand route. The panel opens/refreshes via this HTTP pull;
|
|
2
|
+
// the automatic turn-end/compact refreshes flow over the WS broadcast instead. A
|
|
3
|
+
// `session-inactive` outcome is a NORMAL state (no turn run yet / query dead), so it
|
|
4
|
+
// returns 200 with { ok:false, reason } rather than an HTTP error.
|
|
5
|
+
import { reportSwallowed } from '@ccv/core/error-report';
|
|
6
|
+
|
|
7
|
+
async function sdkContextUsage(req, res) {
|
|
8
|
+
try {
|
|
9
|
+
// Dynamic import: sdk-manager is only loaded in SDK mode (resume.js parity). The
|
|
10
|
+
// literal specifier satisfies the boundary verifier's R7 rule.
|
|
11
|
+
const sdk = await import('../lib/sdk-manager.js');
|
|
12
|
+
const r = await sdk.fetchContextUsage();
|
|
13
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
14
|
+
res.end(JSON.stringify(r.ok ? { ok: true, snapshot: r.snapshot } : { ok: false, reason: r.reason }));
|
|
15
|
+
} catch (err) {
|
|
16
|
+
reportSwallowed('sdk-context-route', err);
|
|
17
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
18
|
+
res.end(JSON.stringify({ error: String(err?.message || err) }));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const sdkContextRoutes = [
|
|
23
|
+
{ method: 'GET', match: 'exact', path: '/api/sdk-context-usage', handler: sdkContextUsage },
|
|
24
|
+
];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SDK-mode "MCP server status" on-demand route. The panel opens/refreshes via this HTTP
|
|
2
|
+
// pull; there is no turn-driven refresh (MCP status is not turn-correlated). A
|
|
3
|
+
// `session-inactive` outcome is a NORMAL state (no turn run yet / query dead), so it
|
|
4
|
+
// returns 200 with { ok:false, reason } rather than an HTTP error.
|
|
5
|
+
import { reportSwallowed } from '@ccv/core/error-report';
|
|
6
|
+
|
|
7
|
+
async function sdkMcpStatus(req, res) {
|
|
8
|
+
try {
|
|
9
|
+
// Dynamic import: sdk-manager is only loaded in SDK mode (resume.js parity). The
|
|
10
|
+
// literal specifier satisfies the boundary verifier's R7 rule.
|
|
11
|
+
const sdk = await import('../lib/sdk-manager.js');
|
|
12
|
+
const r = await sdk.fetchMcpStatus();
|
|
13
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
14
|
+
res.end(JSON.stringify(r.ok ? { ok: true, snapshot: r.snapshot } : { ok: false, reason: r.reason }));
|
|
15
|
+
} catch (err) {
|
|
16
|
+
reportSwallowed('sdk-mcp-status-route', err);
|
|
17
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
18
|
+
res.end(JSON.stringify({ error: String(err?.message || err) }));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const sdkMcpStatusRoutes = [
|
|
23
|
+
{ method: 'GET', match: 'exact', path: '/api/sdk-mcp-server-status', handler: sdkMcpStatus },
|
|
24
|
+
];
|
|
@@ -85,9 +85,26 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
|
|
|
85
85
|
// it to the latest main session.
|
|
86
86
|
if (mergedArgs.some((a) => RESUME_FLAGS.has(a))) markResumeSession();
|
|
87
87
|
|
|
88
|
+
// SDK mode (2026-10-12, per-cwd multi-session): launch = register/activate this cwd's
|
|
89
|
+
// resident SDK session — NOT a phantom claude PTY the SDK UI never consumes. PTY mode is
|
|
90
|
+
// untouched (the proxyPort branch below stays PTY-only).
|
|
91
|
+
if (deps.isSdkMode) {
|
|
92
|
+
try {
|
|
93
|
+
const sdk = await import('../lib/sdk-manager.js');
|
|
94
|
+
if (!sdk.setActiveSdkSession(wsPath)) {
|
|
95
|
+
// New cwd: register a session carrying the PROCESS-LEVEL launch config template
|
|
96
|
+
// (registered by cli.js at boot) so its query gets the proxy env / model /
|
|
97
|
+
// permission / executable — without it the child would bypass the ccv proxy
|
|
98
|
+
// (no transcript) and skip the per-cwd routing header. Process-global callbacks
|
|
99
|
+
// (broadcast/turn-end) are reused via initSdkSession's only-if-provided guard.
|
|
100
|
+
sdk.initSdkSession(wsPath, result.projectName, sdk.getSdkLaunchConfig() || undefined);
|
|
101
|
+
}
|
|
102
|
+
} catch (err) { reportSwallowed('workspaces.sdk-launch', err); }
|
|
103
|
+
}
|
|
104
|
+
|
|
88
105
|
// 启动 PTY
|
|
89
106
|
const proxyPort = process.env.CCV_PROXY_PORT;
|
|
90
|
-
if (proxyPort) {
|
|
107
|
+
if (proxyPort && !deps.isSdkMode) {
|
|
91
108
|
// Multi-instance (2026-10-06): spawnClaude now mints a fresh instanceKey per call and
|
|
92
109
|
// no longer kills an existing same-cwd record, so re-launching an ALREADY-LIVE cwd
|
|
93
110
|
// here would silently start a duplicate claude. ensurePtyForCwd restores the intended
|
|
@@ -186,6 +203,12 @@ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
|
|
|
186
203
|
// old project's claude leaks past the workspace session.
|
|
187
204
|
import('../pty-manager.js').then(({ killAllMain }) => killAllMain()),
|
|
188
205
|
import('../scratch-pty-manager.js').then(({ killAllScratch }) => killAllScratch()).catch(() => {}),
|
|
206
|
+
// SDK multi-session (2026-10-12): returning to the workspace list must also stop every
|
|
207
|
+
// resident SDK session, or they (and their child claude processes) accumulate for the
|
|
208
|
+
// whole process lifetime.
|
|
209
|
+
deps.isSdkMode
|
|
210
|
+
? import('../lib/sdk-manager.js').then((m) => m.stopSession()).catch((err) => reportSwallowed('workspaces.sdk-stop', err))
|
|
211
|
+
: Promise.resolve(),
|
|
189
212
|
]).then(() => {
|
|
190
213
|
// 接续原有清理流程
|
|
191
214
|
|
|
@@ -25,9 +25,23 @@ const MAX_BUFFER = 50000;
|
|
|
25
25
|
// 裁剪滞回:超 MAX_BUFFER 后一次裁到 TRIM_TO,降低每 chunk slice 重分配频率(同 pty-manager.js)
|
|
26
26
|
const BUFFER_TRIM_TO = 45000;
|
|
27
27
|
|
|
28
|
-
//
|
|
28
|
+
// Multi-project (2026-10-12): scratch PTYs are keyed by (tab id, resolved workspace
|
|
29
|
+
// cwd) so each viewed workspace owns an isolated shell group; switching away keeps the
|
|
30
|
+
// old group's processes alive and switching back re-attaches them. Callers that omit
|
|
31
|
+
// cwd fall back to STARTUP_CWD (pre-multi-project API shape — keeps older tests and
|
|
32
|
+
// any un-updated call site on a single, well-defined bucket).
|
|
33
|
+
function effCwd(opts) {
|
|
34
|
+
return (opts && typeof opts.cwd === 'string' && opts.cwd) ? opts.cwd : STARTUP_CWD;
|
|
35
|
+
}
|
|
36
|
+
function keyFor(id, cwd) {
|
|
37
|
+
// Space separator is collision-free: the id charset (SCRATCH_ID_RE, [A-Za-z0-9_-])
|
|
38
|
+
// excludes spaces, so the first space always delimits even when the cwd contains them.
|
|
39
|
+
return `${id} ${cwd || STARTUP_CWD}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// composite key -> { ptyProcess, dataListeners, exitListeners, lastExitCode, outputBuffer, lastCols, lastRows, batchBuffer, batchScheduled, cwd }
|
|
29
43
|
const ptys = new Map();
|
|
30
|
-
//
|
|
44
|
+
// composite key -> in-flight spawn Promise; dedupes concurrent spawns for the same id+cwd
|
|
31
45
|
const _spawnInflight = new Map();
|
|
32
46
|
let _ptyImportForTests = null;
|
|
33
47
|
|
|
@@ -43,7 +57,7 @@ async function getPty() {
|
|
|
43
57
|
return ptyMod.default || ptyMod;
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
function newState() {
|
|
60
|
+
function newState(cwd) {
|
|
47
61
|
return {
|
|
48
62
|
ptyProcess: null,
|
|
49
63
|
dataListeners: [],
|
|
@@ -54,23 +68,28 @@ function newState() {
|
|
|
54
68
|
lastRows: 24,
|
|
55
69
|
batchBuffer: '',
|
|
56
70
|
batchScheduled: false,
|
|
71
|
+
cwd: cwd || STARTUP_CWD,
|
|
57
72
|
};
|
|
58
73
|
}
|
|
59
74
|
|
|
60
75
|
// 仅在确实有消费方时才创建条目;为 onScratchData/onScratchExit 与 spawnScratch 复用
|
|
61
|
-
function getOrInit(id) {
|
|
62
|
-
|
|
76
|
+
function getOrInit(id, cwd) {
|
|
77
|
+
const k = keyFor(id, cwd);
|
|
78
|
+
let s = ptys.get(k);
|
|
63
79
|
if (!s) {
|
|
64
|
-
s = newState();
|
|
65
|
-
ptys.set(
|
|
80
|
+
s = newState(cwd);
|
|
81
|
+
ptys.set(k, s);
|
|
66
82
|
}
|
|
67
|
-
return s;
|
|
83
|
+
return { k, s };
|
|
68
84
|
}
|
|
69
85
|
|
|
70
|
-
// 当某
|
|
71
|
-
|
|
86
|
+
// 当某 key 既无 pty 又无监听器时清掉空记录,防止懒注册场景下 Map 长尾膨胀。
|
|
87
|
+
// Identity check: a kill+quick-respawn replaces the entry under the same key — the old
|
|
88
|
+
// process's async onExit must not delete the NEW live record (leaks an unreachable
|
|
89
|
+
// shell and desyncs the caps).
|
|
90
|
+
function maybeReap(k, s) {
|
|
72
91
|
if (!s.ptyProcess && s.dataListeners.length === 0 && s.exitListeners.length === 0) {
|
|
73
|
-
ptys.delete(
|
|
92
|
+
if (ptys.get(k) === s) ptys.delete(k);
|
|
74
93
|
}
|
|
75
94
|
}
|
|
76
95
|
|
|
@@ -117,14 +136,24 @@ function fixSpawnHelperPermissions() {
|
|
|
117
136
|
// 注:spawn 失败时 s 留在 Map 但 s.ptyProcess 仍为 null。server connection handler 在
|
|
118
137
|
// 失败后仍会通过 onScratchData/onScratchExit 注册 listener;ws.close 时它们通过 maybeReap
|
|
119
138
|
// 清空记录,所以无长期泄漏。已知降级体验:用户看到空终端,下次 input 会再试一次 spawn。
|
|
120
|
-
|
|
139
|
+
// Multi-project: the shell's cwd comes from the viewed-workspace resolution threaded
|
|
140
|
+
// through opts.cwd (server.js upgrade handler); STARTUP_CWD remains the fallback.
|
|
141
|
+
export async function spawnScratch(id, opts) {
|
|
121
142
|
if (!id || typeof id !== 'string') throw new Error('spawnScratch requires id');
|
|
122
|
-
const
|
|
143
|
+
const cwd = effCwd(opts);
|
|
144
|
+
const { k, s } = getOrInit(id, cwd);
|
|
123
145
|
if (s.ptyProcess) return s.ptyProcess;
|
|
124
|
-
if (_spawnInflight.has(
|
|
146
|
+
if (_spawnInflight.has(k)) return _spawnInflight.get(k);
|
|
125
147
|
const p = (async () => {
|
|
126
148
|
const pty = await getPty();
|
|
127
149
|
fixSpawnHelperPermissions();
|
|
150
|
+
// Stale-record guard: a killAll/killForCwd during the async getPty() window deletes
|
|
151
|
+
// the record from ptys. Without this check the freshly spawned process below would
|
|
152
|
+
// be assigned to an orphaned state object — a live shell no Map entry can reach
|
|
153
|
+
// (invisible to getScratchPid/writeScratch/kill, uncounted against the caps).
|
|
154
|
+
if (ptys.get(k) !== s) {
|
|
155
|
+
throw new Error('scratch record reaped during spawn');
|
|
156
|
+
}
|
|
128
157
|
|
|
129
158
|
const shell = process.env.SHELL || (process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh');
|
|
130
159
|
const env = { ...process.env };
|
|
@@ -142,16 +171,23 @@ export async function spawnScratch(id) {
|
|
|
142
171
|
applyClaudeAltScreenPref(env);
|
|
143
172
|
const shellSpawn = prepareEmbeddedShellSpawn(shell, env);
|
|
144
173
|
|
|
145
|
-
|
|
146
|
-
s.outputBuffer = '';
|
|
147
|
-
|
|
148
|
-
s.ptyProcess = pty.spawn(shellSpawn.command, shellSpawn.args, {
|
|
174
|
+
const proc = pty.spawn(shellSpawn.command, shellSpawn.args, {
|
|
149
175
|
name: 'xterm-256color',
|
|
150
176
|
cols: s.lastCols,
|
|
151
177
|
rows: s.lastRows,
|
|
152
|
-
cwd:
|
|
178
|
+
cwd: s.cwd,
|
|
153
179
|
env: shellSpawn.env,
|
|
154
180
|
});
|
|
181
|
+
// The synchronous spawn above cannot interleave with a reap, but belt-and-braces:
|
|
182
|
+
// re-check before publishing so a process is never attached to a reaped record.
|
|
183
|
+
if (ptys.get(k) !== s) {
|
|
184
|
+
try { proc.kill(); } catch { }
|
|
185
|
+
throw new Error('scratch record reaped during spawn');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
s.lastExitCode = null;
|
|
189
|
+
s.outputBuffer = '';
|
|
190
|
+
s.ptyProcess = proc;
|
|
155
191
|
|
|
156
192
|
s.ptyProcess.onData((data) => {
|
|
157
193
|
s.outputBuffer += data;
|
|
@@ -174,20 +210,23 @@ export async function spawnScratch(id) {
|
|
|
174
210
|
for (const cb of [...s.exitListeners]) {
|
|
175
211
|
try { cb(exitCode); } catch { }
|
|
176
212
|
}
|
|
213
|
+
// A shell that exits while its page is away (switch-away already removed
|
|
214
|
+
// listeners) would otherwise linger as a dead entry against the caps.
|
|
215
|
+
maybeReap(k, s);
|
|
177
216
|
});
|
|
178
217
|
|
|
179
218
|
return s.ptyProcess;
|
|
180
219
|
})();
|
|
181
|
-
_spawnInflight.set(
|
|
220
|
+
_spawnInflight.set(k, p);
|
|
182
221
|
try {
|
|
183
222
|
return await p;
|
|
184
223
|
} finally {
|
|
185
|
-
_spawnInflight.delete(
|
|
224
|
+
_spawnInflight.delete(k);
|
|
186
225
|
}
|
|
187
226
|
}
|
|
188
227
|
|
|
189
|
-
export function writeScratch(id, data) {
|
|
190
|
-
const s = ptys.get(id);
|
|
228
|
+
export function writeScratch(id, data, opts) {
|
|
229
|
+
const s = ptys.get(keyFor(id, effCwd(opts)));
|
|
191
230
|
if (s?.ptyProcess) {
|
|
192
231
|
s.ptyProcess.write(data);
|
|
193
232
|
return true;
|
|
@@ -203,8 +242,8 @@ function _clampScratchDim(v, min, max, fallback) {
|
|
|
203
242
|
return Math.max(min, Math.min(max, n));
|
|
204
243
|
}
|
|
205
244
|
|
|
206
|
-
export function resizeScratch(id, cols, rows) {
|
|
207
|
-
const s = ptys.get(id);
|
|
245
|
+
export function resizeScratch(id, cols, rows, opts) {
|
|
246
|
+
const s = ptys.get(keyFor(id, effCwd(opts)));
|
|
208
247
|
if (!s) return;
|
|
209
248
|
s.lastCols = _clampScratchDim(cols, 2, 1000, s.lastCols);
|
|
210
249
|
s.lastRows = _clampScratchDim(rows, 1, 1000, s.lastRows);
|
|
@@ -213,8 +252,10 @@ export function resizeScratch(id, cols, rows) {
|
|
|
213
252
|
}
|
|
214
253
|
}
|
|
215
254
|
|
|
216
|
-
|
|
217
|
-
|
|
255
|
+
// Shared teardown for killScratch/killAllScratch: kill the process tree, flush the
|
|
256
|
+
// batch pipeline, then drop the record entirely (listeners included — the frontend
|
|
257
|
+
// ws.close path unsubscribes separately).
|
|
258
|
+
function _killEntry(k, s) {
|
|
218
259
|
if (!s) return;
|
|
219
260
|
if (s.ptyProcess) {
|
|
220
261
|
flushBatch(s, true);
|
|
@@ -227,50 +268,80 @@ export function killScratch(id) {
|
|
|
227
268
|
}
|
|
228
269
|
s.ptyProcess = null;
|
|
229
270
|
}
|
|
230
|
-
|
|
231
|
-
|
|
271
|
+
ptys.delete(k);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function killScratch(id, opts) {
|
|
275
|
+
_killEntry(keyFor(id, effCwd(opts)), ptys.get(keyFor(id, effCwd(opts))));
|
|
232
276
|
}
|
|
233
277
|
|
|
234
278
|
export function killAllScratch() {
|
|
235
|
-
for (const
|
|
236
|
-
|
|
279
|
+
for (const [k, s] of [...ptys.entries()]) {
|
|
280
|
+
_killEntry(k, s);
|
|
237
281
|
}
|
|
238
282
|
}
|
|
239
283
|
|
|
240
|
-
|
|
241
|
-
|
|
284
|
+
// Kill every scratch PTY belonging to ONE workspace cwd (closing a project/instance via
|
|
285
|
+
// /api/live-processes/close). The match is against the record's stored spawn cwd, which
|
|
286
|
+
// was realpathSync-normalized at the upgrade — so callers pass candidates in BOTH path
|
|
287
|
+
// spaces (raw spawn cwd + its realpath): when the workspace dir was later deleted,
|
|
288
|
+
// realpathSync(raw) throws and only the raw candidate can match, while the bucket key
|
|
289
|
+
// may be the realpath. Same-basename twins have distinct cwds, so this never reaps a
|
|
290
|
+
// sibling workspace's shells. Accepts a single cwd string or an array of candidates.
|
|
291
|
+
// Returns the number reaped.
|
|
292
|
+
export function killScratchForCwd(cwd) {
|
|
293
|
+
const cands = (Array.isArray(cwd) ? cwd : [cwd]).filter((c) => typeof c === 'string' && c);
|
|
294
|
+
if (cands.length === 0) return 0;
|
|
295
|
+
let n = 0;
|
|
296
|
+
for (const [k, s] of [...ptys.entries()]) {
|
|
297
|
+
if (s && cands.includes(s.cwd)) {
|
|
298
|
+
_killEntry(k, s);
|
|
299
|
+
n++;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return n;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function onScratchData(id, cb, opts) {
|
|
306
|
+
const cwd = effCwd(opts);
|
|
307
|
+
const { k, s } = getOrInit(id, cwd);
|
|
242
308
|
s.dataListeners.push(cb);
|
|
243
309
|
return () => {
|
|
244
310
|
s.dataListeners = s.dataListeners.filter(l => l !== cb);
|
|
245
|
-
maybeReap(
|
|
311
|
+
maybeReap(k, s);
|
|
246
312
|
};
|
|
247
313
|
}
|
|
248
314
|
|
|
249
|
-
export function onScratchExit(id, cb) {
|
|
250
|
-
const
|
|
315
|
+
export function onScratchExit(id, cb, opts) {
|
|
316
|
+
const cwd = effCwd(opts);
|
|
317
|
+
const { k, s } = getOrInit(id, cwd);
|
|
251
318
|
s.exitListeners.push(cb);
|
|
252
319
|
return () => {
|
|
253
320
|
s.exitListeners = s.exitListeners.filter(l => l !== cb);
|
|
254
|
-
maybeReap(
|
|
321
|
+
maybeReap(k, s);
|
|
255
322
|
};
|
|
256
323
|
}
|
|
257
324
|
|
|
258
|
-
export function getScratchPid(id) {
|
|
259
|
-
const s = ptys.get(id);
|
|
325
|
+
export function getScratchPid(id, opts) {
|
|
326
|
+
const s = ptys.get(keyFor(id, effCwd(opts)));
|
|
260
327
|
return s?.ptyProcess ? s.ptyProcess.pid : null;
|
|
261
328
|
}
|
|
262
329
|
|
|
263
|
-
export function getScratchState(id) {
|
|
264
|
-
const
|
|
330
|
+
export function getScratchState(id, opts) {
|
|
331
|
+
const cwd = effCwd(opts);
|
|
332
|
+
const s = ptys.get(keyFor(id, cwd));
|
|
265
333
|
return {
|
|
266
334
|
running: !!(s?.ptyProcess),
|
|
267
335
|
exitCode: s?.lastExitCode ?? null,
|
|
268
|
-
cwd
|
|
336
|
+
// The record always carries its real spawn cwd; only a missing record falls back to
|
|
337
|
+
// the requested cwd (pre-spawn probe). No `|| cwd` blur — a corrupted record must
|
|
338
|
+
// surface, not silently report the requester's cwd.
|
|
339
|
+
cwd: s ? s.cwd : cwd,
|
|
269
340
|
};
|
|
270
341
|
}
|
|
271
342
|
|
|
272
|
-
export function getScratchOutputBuffer(id) {
|
|
273
|
-
return ptys.get(id)?.outputBuffer ?? '';
|
|
343
|
+
export function getScratchOutputBuffer(id, opts) {
|
|
344
|
+
return ptys.get(keyFor(id, effCwd(opts)))?.outputBuffer ?? '';
|
|
274
345
|
}
|
|
275
346
|
|
|
276
347
|
export function getScratchStartupCwd() {
|
|
@@ -288,9 +359,19 @@ export function getScratchPtyCount() {
|
|
|
288
359
|
return ptys.size;
|
|
289
360
|
}
|
|
290
361
|
|
|
362
|
+
// Per-workspace cap companion to the global count above: entries belonging to one
|
|
363
|
+
// resolved cwd (hidden workspaces keep their PTYs alive by design, so the global cap
|
|
364
|
+
// alone would starve multi-workspace use).
|
|
365
|
+
export function getScratchPtyCountForCwd(cwd) {
|
|
366
|
+
const target = effCwd({ cwd });
|
|
367
|
+
let n = 0;
|
|
368
|
+
for (const s of ptys.values()) if (s.cwd === target) n++;
|
|
369
|
+
return n;
|
|
370
|
+
}
|
|
371
|
+
|
|
291
372
|
// 服务端 upgrade 阶段判断 id 是否是"已有重连",避免 cap 误伤老 tab 重连
|
|
292
|
-
export function hasScratchPty(id) {
|
|
293
|
-
return ptys.has(id);
|
|
373
|
+
export function hasScratchPty(id, opts) {
|
|
374
|
+
return ptys.has(keyFor(id, effCwd(opts)));
|
|
294
375
|
}
|
|
295
376
|
|
|
296
377
|
// 当前 spawn 用的 shell basename(zsh / bash / fish 等),供前端渲染 tab 标签
|