cc-viewer 1.9.3 → 1.9.5
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 +29 -2
- package/dist/assets/App-Bj_-wfrB.js +2 -0
- package/dist/assets/App-DuDeDOuX.css +1 -0
- package/dist/assets/{MdxEditorPanel-Ck8B5a5v.js → MdxEditorPanel-DD2IrRnW.js} +1 -1
- package/dist/assets/{Mobile-B8AkKb0z.js → Mobile-BQBQkr2V.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DsDVMf-c.js → ProxyStatsModal-8VYl6n39.js} +1 -1
- package/dist/assets/index-CloXv0sm.js +2 -0
- package/dist/assets/{seqResourceLoaders-BsMFNfvR.css → seqResourceLoaders-Bg9Lz8pH.css} +1 -1
- package/dist/assets/seqResourceLoaders-M4XGblXm.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +176 -14
- package/server/lib/git-diff.js +14 -8
- package/server/lib/scope-router.js +68 -0
- package/server/lib/sdk-manager.js +1524 -895
- package/server/lib/task-state.js +74 -34
- package/server/lib/turn-end-bridge.js +6 -1
- package/server/lib/v2/live-feed.js +7 -6
- package/server/lib/v2/view-router.js +48 -0
- package/server/lib/workflow-watcher.js +13 -6
- package/server/lib/ws-mux.js +473 -0
- package/server/pty-manager.js +53 -0
- package/server/routes/ask-perm.js +13 -8
- package/server/routes/events.js +190 -74
- package/server/routes/resume.js +79 -1
- package/server/routes/sdk-context.js +24 -0
- package/server/routes/sdk-mcp-status.js +24 -0
- package/server/routes/session-pin.js +12 -2
- package/server/routes/workflow-journal.js +5 -2
- package/server/routes/workspaces.js +31 -2
- package/server/scratch-pty-manager.js +127 -46
- package/server/server.js +616 -108
- 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
|
@@ -33,10 +33,13 @@ function workflowJournal(req, res, parsedUrl, isLocal, deps) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// 惰性 arm:只监视真有人在看的 session workflows 目录,后续 journal 覆写经 SSE 实时推送。
|
|
36
|
+
// Per-workspace: the server-supplied filter routes the frames to only this
|
|
37
|
+
// project's viewers (absent ⇒ legacy broadcast-to-all).
|
|
38
|
+
const _wfFilter = typeof deps.filterClientsByViewProject === 'function' ? deps.filterClientsByViewProject : null;
|
|
36
39
|
const wfDir = resolveWorkflowsDir(session, project);
|
|
37
40
|
if (wfDir && deps && Array.isArray(deps.clients)) {
|
|
38
41
|
try {
|
|
39
|
-
armWorkflowWatch({ workflowsDir: wfDir, sessionId: session, project, clients: deps.clients });
|
|
42
|
+
armWorkflowWatch({ workflowsDir: wfDir, sessionId: session, project, clients: deps.clients, clientFilter: _wfFilter });
|
|
40
43
|
} catch {}
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -57,7 +60,7 @@ function workflowJournal(req, res, parsedUrl, isLocal, deps) {
|
|
|
57
60
|
const live = runDir ? deriveLiveJournal(runDir, runId) : null;
|
|
58
61
|
if (live) {
|
|
59
62
|
if (deps && Array.isArray(deps.clients)) {
|
|
60
|
-
try { armWorkflowLiveWatch({ runDir, runId, sessionId: session, project, clients: deps.clients }); } catch {}
|
|
63
|
+
try { armWorkflowLiveWatch({ runDir, runId, sessionId: session, project, clients: deps.clients, clientFilter: _wfFilter }); } catch {}
|
|
61
64
|
}
|
|
62
65
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
63
66
|
res.end(JSON.stringify({ ok: true, data: live }));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Workspace routes (moved verbatim from server.js handleRequest).
|
|
2
2
|
import { existsSync, statSync } from 'node:fs';
|
|
3
3
|
import { basename } from 'node:path';
|
|
4
|
-
import { initForWorkspace, resetWorkspace, getLiveLogSource, markContinuedLaunch, markForkSession, markResumeSession, isContinuedLaunch } from '../interceptor.js';
|
|
4
|
+
import { initForWorkspace, resetWorkspace, getLiveLogSource, markContinuedLaunch, markForkSession, markResumeSession, isContinuedLaunch, clearScopeState } from '../interceptor.js';
|
|
5
5
|
import { LOG_DIR } from '../../findcc.js';
|
|
6
6
|
import { migrationStatus } from '../lib/v2/migrate-prompt.js';
|
|
7
7
|
import { reportSwallowed } from '@ccv/core/error-report';
|
|
@@ -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
|
|
|
@@ -196,6 +219,12 @@ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
|
|
|
196
219
|
// 重置 interceptor 状态
|
|
197
220
|
resetWorkspace();
|
|
198
221
|
deps.setWorkspaceLaunched(false);
|
|
222
|
+
// Per-workspace: tear down every scope's streaming/turn buckets AND the
|
|
223
|
+
// server-side per-scope maps (session tags, streaming edges) — a PTY
|
|
224
|
+
// killed here fires no Stop POST, so without this the killed workspaces'
|
|
225
|
+
// buckets stay `active` forever and a stale session tag rejects the fresh
|
|
226
|
+
// session's first UserPromptSubmit on a same-name relaunch.
|
|
227
|
+
try { (deps.resetScopeRuntime ? deps.resetScopeRuntime() : clearScopeState()); } catch { /* best-effort */ }
|
|
199
228
|
|
|
200
229
|
// 通知所有 SSE 客户端
|
|
201
230
|
deps.clients.forEach(client => {
|
|
@@ -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 标签
|