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.
@@ -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 = () => {
@@ -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
- const r = sdk.switchToSession(sessionUuid);
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
- // id -> { ptyProcess, dataListeners, exitListeners, lastExitCode, outputBuffer, lastCols, lastRows, batchBuffer, batchScheduled }
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
- // id -> in-flight spawn Promise,防同 id 并发 spawn 双开
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
- let s = ptys.get(id);
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(id, s);
80
+ s = newState(cwd);
81
+ ptys.set(k, s);
66
82
  }
67
- return s;
83
+ return { k, s };
68
84
  }
69
85
 
70
- // 当某 id 既无 pty 又无监听器时清掉空记录,防止懒注册场景下 Map 长尾膨胀
71
- function maybeReap(id, s) {
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(id);
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
- export async function spawnScratch(id) {
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 s = getOrInit(id);
143
+ const cwd = effCwd(opts);
144
+ const { k, s } = getOrInit(id, cwd);
123
145
  if (s.ptyProcess) return s.ptyProcess;
124
- if (_spawnInflight.has(id)) return _spawnInflight.get(id);
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
- s.lastExitCode = null;
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: STARTUP_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(id, p);
220
+ _spawnInflight.set(k, p);
182
221
  try {
183
222
  return await p;
184
223
  } finally {
185
- _spawnInflight.delete(id);
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
- export function killScratch(id) {
217
- const s = ptys.get(id);
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
- // 显式 kill 后整条记录清掉,监听器一并丢弃(前端 ws.close 也会清)
231
- ptys.delete(id);
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 id of [...ptys.keys()]) {
236
- killScratch(id);
279
+ for (const [k, s] of [...ptys.entries()]) {
280
+ _killEntry(k, s);
237
281
  }
238
282
  }
239
283
 
240
- export function onScratchData(id, cb) {
241
- const s = getOrInit(id);
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(id, s);
311
+ maybeReap(k, s);
246
312
  };
247
313
  }
248
314
 
249
- export function onScratchExit(id, cb) {
250
- const s = getOrInit(id);
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(id, s);
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 s = ptys.get(id);
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: STARTUP_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 标签