cc-viewer 1.9.4 → 1.9.6
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/cli.js +5 -2
- package/dist/assets/App-GhvyKB0E.js +2 -0
- package/dist/assets/{MdxEditorPanel-C8Cij93y.js → MdxEditorPanel-DwwKGPIQ.js} +1 -1
- package/dist/assets/{Mobile-OKCVi8un.js → Mobile-CGiDFGRO.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DGlngtM9.js → ProxyStatsModal-CI5PkF_B.js} +1 -1
- package/dist/assets/{index-Clt4dw8Y.js → index-D0_0_HIv.js} +2 -2
- package/dist/assets/seqResourceLoaders-DHr1E1Ye.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +165 -14
- package/server/lib/git-diff.js +14 -8
- package/server/lib/scope-router.js +68 -0
- package/server/lib/sdk-manager.js +155 -32
- package/server/lib/sdk-session-index.js +25 -0
- 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/view-root.js +30 -10
- package/server/lib/workflow-watcher.js +13 -6
- package/server/lib/ws-mux.js +492 -0
- package/server/pty-manager.js +53 -0
- package/server/routes/ask-perm.js +13 -8
- package/server/routes/events.js +185 -86
- package/server/routes/project-meta.js +11 -4
- package/server/routes/proxy-stats.js +8 -4
- package/server/routes/resume.js +64 -10
- package/server/routes/session-pin.js +12 -2
- package/server/routes/workflow-journal.js +5 -2
- package/server/routes/workspaces.js +7 -1
- package/server/server.js +353 -120
- package/dist/assets/App-DtBVNYE7.js +0 -2
- package/dist/assets/seqResourceLoaders-zlIVjapi.js +0 -2
package/server/routes/resume.js
CHANGED
|
@@ -54,6 +54,7 @@ import { isAdminReq } from '../lib/is-admin.js';
|
|
|
54
54
|
import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
|
|
55
55
|
import { sseWrite } from '../lib/wire-compress.js';
|
|
56
56
|
import { reportSwallowed } from '@ccv/core/error-report';
|
|
57
|
+
import { findSdkSessionByInstance, listSdkSessionInstances } from '../lib/sdk-session-index.js';
|
|
57
58
|
|
|
58
59
|
function sendJson(res, code, obj) {
|
|
59
60
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
@@ -128,21 +129,22 @@ async function getLiveProcessesHandler(req, res, parsedUrl) {
|
|
|
128
129
|
// first launch's PTY-less list collapsed to just the new project. Enumerate the per-cwd SDK
|
|
129
130
|
// session registry (multi-session) as `kind:'main'` entries so each launched workspace shows
|
|
130
131
|
// up exactly like a PTY project. `active` marks the session the SDK composer is bound to;
|
|
131
|
-
//
|
|
132
|
+
// a cwd-stable instanceKey drives exact view routing and the per-session × close path.
|
|
132
133
|
if (process.env.CCV_SDK_MODE === '1') {
|
|
133
134
|
try {
|
|
134
135
|
const sdk = await import('../lib/sdk-manager.js');
|
|
135
136
|
for (const e of sdk.listSdkSessions()) {
|
|
136
137
|
if (!e || !e.cwd) continue;
|
|
137
138
|
if (processes.some((p) => p && p.cwd === e.cwd)) continue; // a real PTY already covers this cwd
|
|
139
|
+
const boundCwd = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
140
|
+
if (!currentInstanceKey && e.instanceKey && e.cwd === boundCwd) currentInstanceKey = e.instanceKey;
|
|
138
141
|
processes.push({
|
|
139
142
|
kind: 'main',
|
|
140
143
|
project: projectKeyForCwd(e.cwd),
|
|
141
144
|
cwd: e.cwd,
|
|
142
145
|
pid: null,
|
|
143
146
|
active: e.active === true,
|
|
144
|
-
instanceKey: null,
|
|
145
|
-
closable: false, // SDK resident query — killed via session switch, not the × close path
|
|
147
|
+
instanceKey: e.instanceKey || null,
|
|
146
148
|
});
|
|
147
149
|
}
|
|
148
150
|
} catch (err) { reportSwallowed('resume-route.live-sdk', err); }
|
|
@@ -176,6 +178,25 @@ function postLiveProcessAttachHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
176
178
|
return;
|
|
177
179
|
}
|
|
178
180
|
try {
|
|
181
|
+
const sdkAnchor = instanceKey && findSdkSessionByInstance(instanceKey)
|
|
182
|
+
? { instanceKey }
|
|
183
|
+
: (!instanceKey && listSdkSessionInstances().length ? { project: project || undefined } : null);
|
|
184
|
+
if (sdkAnchor) {
|
|
185
|
+
const sdk = await import('../lib/sdk-manager.js');
|
|
186
|
+
const r = sdk.attachSdkSession(sdkAnchor);
|
|
187
|
+
if (r.ok) {
|
|
188
|
+
sendJson(res, 200, { ok: true, project, instanceKey: r.instanceKey, running: true, ptyKind: 'sdk', switched: r.switched });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (r.reason === 'ambiguous') {
|
|
192
|
+
sendJson(res, 409, { ok: false, reason: 'ambiguous', candidates: r.candidates || [] });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (instanceKey) {
|
|
196
|
+
sendJson(res, 404, { ok: false, reason: 'not-found' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
179
200
|
const { attachPtyFor } = await import('../pty-manager.js');
|
|
180
201
|
const r = attachPtyFor({ project: project || undefined, instanceKey: instanceKey || undefined });
|
|
181
202
|
if (!r.ok) {
|
|
@@ -238,8 +259,22 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
238
259
|
return;
|
|
239
260
|
}
|
|
240
261
|
try {
|
|
241
|
-
const
|
|
242
|
-
|
|
262
|
+
const sdkByInstance = instanceKey ? findSdkSessionByInstance(instanceKey) : null;
|
|
263
|
+
let r;
|
|
264
|
+
let survivorRecords = null;
|
|
265
|
+
if (sdkByInstance) {
|
|
266
|
+
const sdk = await import('../lib/sdk-manager.js');
|
|
267
|
+
r = sdk.closeSdkSession({ instanceKey });
|
|
268
|
+
survivorRecords = listSdkSessionInstances().map((s) => ({ key: s.instanceKey, cwd: s.cwd, isActive: s.active === true }));
|
|
269
|
+
} else {
|
|
270
|
+
const { killPtyFor } = await import('../pty-manager.js');
|
|
271
|
+
r = killPtyFor({ project: project || undefined, instanceKey: instanceKey || undefined });
|
|
272
|
+
if (!r.ok && r.reason === 'not-found' && listSdkSessionInstances().length) {
|
|
273
|
+
const sdk = await import('../lib/sdk-manager.js');
|
|
274
|
+
r = sdk.closeSdkSession({ project: project || undefined, instanceKey: instanceKey || undefined });
|
|
275
|
+
if (r.ok) survivorRecords = listSdkSessionInstances().map((s) => ({ key: s.instanceKey, cwd: s.cwd, isActive: s.active === true }));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
243
278
|
if (!r.ok) {
|
|
244
279
|
// Multi-instance: an ambiguous project-name-only close (2+ live same-basename
|
|
245
280
|
// instances) is a 409 with the live candidates, so the client re-issues with an
|
|
@@ -260,8 +295,25 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
260
295
|
// CCV_PROJECT_DIR — never by basename, or a same-name twin close would false-hit.
|
|
261
296
|
let rebound = null;
|
|
262
297
|
try {
|
|
263
|
-
rebound = _maybeRebindAfterClose(r, deps);
|
|
298
|
+
rebound = _maybeRebindAfterClose(r, deps, survivorRecords);
|
|
264
299
|
} catch (err) { reportSwallowed('resume-route.close-rebind', err); }
|
|
300
|
+
// A PTY killed here fires no Stop POST, so its scope's streaming/turn
|
|
301
|
+
// bucket would stay `active:true` forever — the heartbeat/streaming timer
|
|
302
|
+
// keeps re-broadcasting it and the client's 30s staleness escape is
|
|
303
|
+
// perpetually reset by those same frames (the permanent-spinner leak).
|
|
304
|
+
// Reap exactly that scope. The producer buckets it under
|
|
305
|
+
// projectKeyForCwd(cwd) (interceptor scopeKeyForEntry / server
|
|
306
|
+
// _resolveScopeForHookPayload), NOT the instanceKey — so resolve the same
|
|
307
|
+
// project-scoped key here, collapsed to '' when it was the bound project.
|
|
308
|
+
try {
|
|
309
|
+
if (r.cwd && deps.clearScopeRuntimeFor) {
|
|
310
|
+
const { projectKeyForCwd } = await import('../lib/system-prompt-snapshots.js');
|
|
311
|
+
const { normalizeProducerScope, scopeKeyOf } = await import('../lib/scope-router.js');
|
|
312
|
+
const proj = projectKeyForCwd(r.cwd);
|
|
313
|
+
const n = normalizeProducerScope({ project: proj, instance: null }, deps.projectName || '');
|
|
314
|
+
deps.clearScopeRuntimeFor(scopeKeyOf(n.project, n.instance));
|
|
315
|
+
}
|
|
316
|
+
} catch (err) { reportSwallowed('resume-route.close-clear-scope', err); }
|
|
265
317
|
// Closing a project/instance must also reap its scratch (mini-terminal) shells, or
|
|
266
318
|
// the viewed-then-switched-away workspace's shells stay alive as orphans until a
|
|
267
319
|
// full workspacesStop/process exit. The killed record's original spawn cwd keys
|
|
@@ -294,7 +346,7 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
294
346
|
}
|
|
295
347
|
}
|
|
296
348
|
} catch (err) { reportSwallowed('resume-route.close-kill-scratch', err); }
|
|
297
|
-
sendJson(res, 200, { ok: true, project, instanceKey: r.key || instanceKey || null, killedActive: r.killedActive === true, rebound });
|
|
349
|
+
sendJson(res, 200, { ok: true, project, instanceKey: r.instanceKey || r.key || instanceKey || null, killedActive: r.killedActive === true, rebound });
|
|
298
350
|
} catch (err) {
|
|
299
351
|
reportSwallowed('resume-route.close', err);
|
|
300
352
|
sendJson(res, 500, { ok: false, reason: 'close-failed' });
|
|
@@ -322,7 +374,7 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
322
374
|
// request can observe a half-applied rebind, and a throwing side-effect can no
|
|
323
375
|
// longer strand the "binding moved but nobody was told" state (the broadcast
|
|
324
376
|
// has already gone out by then).
|
|
325
|
-
function _maybeRebindAfterClose(killResult, deps) {
|
|
377
|
+
function _maybeRebindAfterClose(killResult, deps, survivorRecords = null) {
|
|
326
378
|
// Electron multi-tab: the manager process does NOT own log init (the tab
|
|
327
379
|
// workers do); mirroring workspaces.js:56, skip rebind entirely there.
|
|
328
380
|
if (process.env.CCV_ELECTRON_MULTITAB === '1') return null;
|
|
@@ -338,7 +390,7 @@ function _maybeRebindAfterClose(killResult, deps) {
|
|
|
338
390
|
// terminal attachment to (reattachedTo), then the terminal's live attachment
|
|
339
391
|
// (isActive — preserves the launch-time invariant bound == attached), else
|
|
340
392
|
// the first live claude record in Map order.
|
|
341
|
-
const live = listLivePtys().filter((p) => p && p.cwd && (!p.ptyKind || p.ptyKind === 'claude'));
|
|
393
|
+
const live = survivorRecords || listLivePtys().filter((p) => p && p.cwd && (!p.ptyKind || p.ptyKind === 'claude'));
|
|
342
394
|
if (!live.length) return null;
|
|
343
395
|
// A same-cwd TWIN of the closed bound project still lives → the bound
|
|
344
396
|
// identity (same project name, same log dir) is already correct for it;
|
|
@@ -475,7 +527,9 @@ function postResumeSessionHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
475
527
|
const sdk = await import('../lib/sdk-manager.js');
|
|
476
528
|
// Multi-session (2026-10-12): honor the requested workspace — resume the VIEWED
|
|
477
529
|
// session, not always the active one. project is the workspace name from the client.
|
|
478
|
-
const anchor = (
|
|
530
|
+
const anchor = (project || instanceKey)
|
|
531
|
+
? { project: project || undefined, instanceKey: instanceKey || undefined }
|
|
532
|
+
: undefined;
|
|
479
533
|
const r = sdk.switchToSession(sessionUuid, anchor);
|
|
480
534
|
if (r.ok) { sendJson(res, 200, { ok: true }); return; }
|
|
481
535
|
if (r.reason === 'busy') { sendJson(res, 409, { ok: false, reason: 'busy' }); return; }
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// - GET /api/session-pin → { pinnedSessionId }
|
|
5
5
|
// - POST /api/session-pin {pinnedSessionId: string|null} → persist + SSE-broadcast
|
|
6
6
|
// `_logDir` is the live per-project dir ('' = no active project → GET null / POST no-op).
|
|
7
|
-
import { _logDir } from '../interceptor.js';
|
|
7
|
+
import { _logDir, _projectName } from '../interceptor.js';
|
|
8
8
|
import { readPin, writePin } from '../lib/session-pin-store.js';
|
|
9
9
|
import { sendEventToClients } from '../lib/log-watcher.js';
|
|
10
|
+
import { filterClientsByViewProject } from '../lib/v2/view-router.js';
|
|
10
11
|
|
|
11
12
|
function sendJson(res, code, obj) {
|
|
12
13
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
@@ -28,7 +29,16 @@ function postHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
28
29
|
const pinnedSessionId = (typeof raw === 'string' && raw) ? raw : null;
|
|
29
30
|
writePin(_logDir, pinnedSessionId);
|
|
30
31
|
// Broadcast to THIS process's SSE clients so every device stays in sync.
|
|
31
|
-
|
|
32
|
+
// Per-workspace: the pin file is the BOUND project's (`_logDir`), so the
|
|
33
|
+
// frame must reach only bound-project viewers — a parallel view's pin must
|
|
34
|
+
// not be clobbered by it (and vice versa). Payload carries the project so
|
|
35
|
+
// the client can guard in depth.
|
|
36
|
+
const bound = _projectName || '';
|
|
37
|
+
sendEventToClients(
|
|
38
|
+
filterClientsByViewProject(deps.clients, bound, bound),
|
|
39
|
+
'session_pin',
|
|
40
|
+
{ pinnedSessionId, project: bound },
|
|
41
|
+
);
|
|
32
42
|
sendJson(res, 200, { ok: true, pinnedSessionId });
|
|
33
43
|
});
|
|
34
44
|
req.on('error', () => { try { sendJson(res, 400, { error: 'read-error' }); } catch {} });
|
|
@@ -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';
|
|
@@ -219,6 +219,12 @@ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
|
|
|
219
219
|
// 重置 interceptor 状态
|
|
220
220
|
resetWorkspace();
|
|
221
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 */ }
|
|
222
228
|
|
|
223
229
|
// 通知所有 SSE 客户端
|
|
224
230
|
deps.clients.forEach(client => {
|