cc-viewer 1.8.20 → 1.9.1
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 +33 -4
- package/dist/assets/App-DM-fbhNd.js +2 -0
- package/dist/assets/App-DOZI5HME.css +1 -0
- package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
- package/dist/assets/{MdxEditorPanel-Dsnv2wIw.js → MdxEditorPanel-DjwOQDh0.js} +1 -1
- package/dist/assets/Mobile-BD-hefIH.js +1 -0
- package/dist/assets/ProxyStatsModal-pYa_0QVL.js +1 -0
- package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
- package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
- package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
- package/dist/assets/clone-CFj55h49.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
- package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
- package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
- package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
- package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
- package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
- package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
- package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
- package/dist/assets/index-DkIhbLjr.js +2 -0
- package/dist/assets/{index-Nvk8r3tO.css → index-Dz36p_bA.css} +1 -1
- package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
- package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
- package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
- package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
- package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
- package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
- package/dist/assets/seqResourceLoaders-BdWMUSrC.css +41 -0
- package/dist/assets/seqResourceLoaders-Dzy1J6G9.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
- package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
- package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
- package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
- package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
- package/dist/index.html +5 -5
- package/package.json +1 -1
- package/server/interceptor.js +119 -13
- package/server/lib/chat-queue.js +67 -4
- package/server/lib/same-origin.js +22 -0
- package/server/lib/v2/convert.js +1 -1
- package/server/lib/v2/layout.js +28 -3
- package/server/lib/v2/live-feed.js +41 -4
- package/server/lib/v2/resume-list.js +150 -0
- package/server/lib/v2/session-select.js +63 -14
- package/server/lib/v2/v2-writer.js +172 -34
- package/server/lib/v2/view-router.js +83 -0
- package/server/lib/view-root.js +209 -0
- package/server/pty-manager.js +790 -159
- package/server/routes/ask-perm.js +21 -1
- package/server/routes/events.js +123 -7
- package/server/routes/expert.js +46 -14
- package/server/routes/files-content.js +72 -7
- package/server/routes/files-fs.js +28 -3
- package/server/routes/git.js +31 -5
- package/server/routes/preferences.js +1 -16
- package/server/routes/project-meta.js +32 -3
- package/server/routes/proxy-stats.js +33 -5
- package/server/routes/resume.js +302 -0
- package/server/routes/search.js +23 -1
- package/server/routes/skills.js +18 -2
- package/server/routes/v2.js +6 -1
- package/server/routes/workspaces.js +10 -3
- package/server/server.js +191 -25
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
- package/server/workspace-registry.js +15 -1
- package/dist/assets/App-CesjoFSv.js +0 -2
- package/dist/assets/App-M61pqQEx.css +0 -1
- package/dist/assets/Mobile-bvGZ392d.js +0 -1
- package/dist/assets/ProxyStatsModal-HFk51cI8.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
- package/dist/assets/clone-B0_ghF3F.js +0 -1
- package/dist/assets/index-DanpP6qP.js +0 -2
- package/dist/assets/seqResourceLoaders-BumAjQDW.css +0 -41
- package/dist/assets/seqResourceLoaders-ByHQMNIi.js +0 -2
package/server/pty-manager.js
CHANGED
|
@@ -9,38 +9,53 @@ import { killPtyTree } from './lib/term-signals.js';
|
|
|
9
9
|
import { findSafeSliceStart, splitTrailingIncomplete } from './lib/ansi-safe-slice.js';
|
|
10
10
|
import { resolveSpawnModel } from './lib/spawn-model-resolver.js';
|
|
11
11
|
import { mergeSettingsIntoArgs } from './lib/settings-merge.js';
|
|
12
|
+
import { projectKeyForCwd } from './lib/system-prompt-snapshots.js';
|
|
12
13
|
import { MODEL_PROMPT_DIR } from './lib/model-system-prompts.js';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
13
15
|
// Launch-time system-prompt/thinking-display pipeline lives in lib/launch-config.js
|
|
14
16
|
// (shared with the SDK link). Re-exported here for existing consumers/tests.
|
|
15
17
|
import { withDefaultThinkingDisplay, resolveLaunchSystemPrompt, insertBeforeDashDash } from './lib/launch-config.js';
|
|
16
18
|
export { withDefaultThinkingDisplay } from './lib/launch-config.js';
|
|
19
|
+
import { reportSwallowed } from '@ccv/core/error-report';
|
|
17
20
|
import { t, tFor } from './i18n.js';
|
|
18
21
|
|
|
19
22
|
const __filename = fileURLToPath(import.meta.url);
|
|
20
23
|
const __dirname = dirname(__filename);
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
25
|
+
// Multi-PTY (2026-10): the module used to manage a SINGLE main PTY via module-level
|
|
26
|
+
// singletons (`let ptyProcess` + companions). To run parallel projects — each
|
|
27
|
+
// activated project's claude keeps running in the background while the main view
|
|
28
|
+
// attaches to one of them — the per-PTY state now lives in a Map, plus an
|
|
29
|
+
// `activePtyKey` pointer naming the PTY the main view is attached to. Every
|
|
30
|
+
// existing no-arg export (writeToPty / resizePty / killPty / getPtyState / ...) keeps its
|
|
31
|
+
// signature and operates on the ACTIVE entry, so all current callers (terminal WS, DingTalk
|
|
32
|
+
// bridge, chat-queue, theme sync, getClaudePid) are byte-compatible in the single-PTY case.
|
|
33
|
+
//
|
|
34
|
+
// Map key = per-spawn `instanceKey` (2026-10-06, NOT cwd): the same cwd may host TWO
|
|
35
|
+
// concurrent processes (the user can deliberately run two claude/shell instances in one
|
|
36
|
+
// project dir), so cwd can no longer be the unique addressing key. Each spawn mints a fresh
|
|
37
|
+
// instanceKey; the real cwd is stored on the record (`record.cwd`) so basename/cwd
|
|
38
|
+
// reverse-lookups never depend on the key shape. A record's instanceKey is its identity for
|
|
39
|
+
// its whole lifetime — self-heal respawns reuse the SAME key via `_respawnInto`.
|
|
40
|
+
//
|
|
41
|
+
// Per-PTY record shape (one per instanceKey):
|
|
42
|
+
// instanceKey, cwd, ptyProcess, ptyKind, ptySkipPermissions, lastExitCode, outputBuffer,
|
|
43
|
+
// currentWorkspacePath, lastWorkspacePath, lastPtyCols, lastPtyRows,
|
|
44
|
+
// batchBuffer, batchScheduled, sessionId
|
|
45
|
+
// `dataListeners` / `exitListeners` stay GLOBAL (single main view broadcasts to its SSE/WS
|
|
46
|
+
// clients); a PTY only flushes its output to them while it is the active one (see flushBatch).
|
|
47
|
+
const ptys = new Map();
|
|
48
|
+
// Key of the PTY the main view is attached to (null until the first spawn). All no-arg
|
|
49
|
+
// exports resolve their target through this.
|
|
50
|
+
let activePtyKey = null;
|
|
30
51
|
let dataListeners = [];
|
|
31
52
|
let exitListeners = [];
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// In-flight guard for the main PTY spawn: the guard runs before `await getPty` and
|
|
39
|
-
// ptyProcess is assigned after the await, so two synchronously-arriving input messages
|
|
40
|
-
// could both pass the guard and double-spawn (the first pty loses its reference → leak +
|
|
41
|
-
// output cross-talk). Synchronously reserve a promise and reuse it across concurrent calls,
|
|
42
|
-
// never spawning twice (mirrors scratch-pty-manager._spawnInflight).
|
|
43
|
-
let _spawnInflight = null;
|
|
53
|
+
// In-flight guard for the main PTY spawn, now PER-PTY-KEY: the guard runs before
|
|
54
|
+
// `await getPty` and the record is assigned after the await, so two synchronously-arriving
|
|
55
|
+
// input messages could both pass the guard and double-spawn (the first pty loses its
|
|
56
|
+
// reference → leak + output cross-talk). A Map so concurrent spawns for DIFFERENT projects
|
|
57
|
+
// serialize independently instead of falsely blocking each other.
|
|
58
|
+
const _spawnInflight = new Map();
|
|
44
59
|
// cols/rows clamp range at the resize entry: the upper bound is wide enough (4K-display
|
|
45
60
|
// ultra-wide terminals), the lower bound is ≥2 cols/1 row to keep FitAddon's 2×1 (from a
|
|
46
61
|
// 0-size container) or a malformed client's NaN/negative from poisoning lastPtyCols/Rows.
|
|
@@ -51,10 +66,69 @@ const MAX_BUFFER = 200000;
|
|
|
51
66
|
// MAX_BUFFER on every chunk — dropping the ~200KB slice reallocation frequency from once
|
|
52
67
|
// per chunk to once per ~20KB of new output.
|
|
53
68
|
const BUFFER_TRIM_TO = 180000;
|
|
54
|
-
let batchBuffer = '';
|
|
55
|
-
let batchScheduled = false;
|
|
56
69
|
let _ptyImportForTests = null;
|
|
57
70
|
|
|
71
|
+
// Mint a fresh per-spawn instanceKey. This is the PTY-map key: a crypto-random,
|
|
72
|
+
// collision-proof identifier decoupled from cwd, so the same cwd may host multiple
|
|
73
|
+
// concurrent records (2026-10-06). crypto.randomBytes (not Date.now/Math.random) keeps it
|
|
74
|
+
// unique even across rapid same-tick spawns and steering-test clocks.
|
|
75
|
+
function _mintInstanceKey() {
|
|
76
|
+
return `ccv-${randomBytes(18).toString('hex')}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Lazily create (or fetch) the per-PTY record for an instanceKey. Every per-PTY field that
|
|
80
|
+
// used to be a module singleton is initialized here so a record is always well-formed.
|
|
81
|
+
// `cwd` is set at spawn time (the real, persistent working dir) and never cleared on exit —
|
|
82
|
+
// reverse-lookups (basename / exact-cwd) read it, never the opaque key.
|
|
83
|
+
function _getOrInit(key) {
|
|
84
|
+
let s = ptys.get(key);
|
|
85
|
+
if (!s) {
|
|
86
|
+
s = {
|
|
87
|
+
instanceKey: key, // the Map key, mirrored on the record for convenience
|
|
88
|
+
cwd: null, // real working dir (persistent; set at spawn, kept after exit)
|
|
89
|
+
ptyProcess: null,
|
|
90
|
+
ptyKind: null, // 'claude' | 'shell' | null
|
|
91
|
+
ptySkipPermissions: false,
|
|
92
|
+
lastExitCode: null,
|
|
93
|
+
outputBuffer: '',
|
|
94
|
+
currentWorkspacePath: null,
|
|
95
|
+
lastWorkspacePath: null, // kept after exit, used for respawn shell
|
|
96
|
+
lastPtyCols: 120,
|
|
97
|
+
lastPtyRows: 30,
|
|
98
|
+
batchBuffer: '',
|
|
99
|
+
batchScheduled: false,
|
|
100
|
+
sessionId: null, // claude session uuid once its first turn mints one (fed via setPtySessionId)
|
|
101
|
+
};
|
|
102
|
+
ptys.set(key, s);
|
|
103
|
+
}
|
|
104
|
+
return s;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Reverse index sessionId → PTY map key, so a chat send can be routed to the
|
|
108
|
+
// exact PTY whose claude owns that conversation (2026-10-05). Maintained by
|
|
109
|
+
// setPtySessionId; cleared when a record's sessionId is overwritten or reaped.
|
|
110
|
+
const sidToKey = new Map();
|
|
111
|
+
|
|
112
|
+
// Resolve the record the no-arg exports operate on: the ACTIVE one. Returns null when no
|
|
113
|
+
// PTY has ever spawned (so callers degrade to their pre-spawn no-op behavior).
|
|
114
|
+
function _active() {
|
|
115
|
+
return activePtyKey != null ? (ptys.get(activePtyKey) || null) : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Drop entries that have no live process and no buffered output — keeps the Map from
|
|
119
|
+
// growing one stale entry per spawned project over a long-lived server (mirrors
|
|
120
|
+
// scratch-pty-manager.maybeReap). Never reaps the ACTIVE entry.
|
|
121
|
+
function _maybeReap() {
|
|
122
|
+
if (ptys.size <= 8) return;
|
|
123
|
+
for (const [key, s] of ptys) {
|
|
124
|
+
if (key === activePtyKey) continue;
|
|
125
|
+
if (!s.ptyProcess && !s.outputBuffer) {
|
|
126
|
+
if (s.sessionId) sidToKey.delete(s.sessionId);
|
|
127
|
+
ptys.delete(key);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
58
132
|
export function _setPtyImportForTests(fn) {
|
|
59
133
|
_ptyImportForTests = fn;
|
|
60
134
|
}
|
|
@@ -110,19 +184,25 @@ export { findSafeSliceStart };
|
|
|
110
184
|
const SYNC_BEGIN = '\x1b[?2026h';
|
|
111
185
|
const SYNC_END = '\x1b[?2026l';
|
|
112
186
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
187
|
+
// Flush a record's batched PTY output to the global dataListeners — but ONLY while that
|
|
188
|
+
// record is the ACTIVE one (single main view): two concurrent projects' output must never
|
|
189
|
+
// interleave onto the one terminal stream. A background (non-active) record still
|
|
190
|
+
// accumulates outputBuffer/batchBuffer so it can be re-attached later; it just doesn't
|
|
191
|
+
// broadcast. `force` flushes the trailing half-sequence carry (process exit).
|
|
192
|
+
function flushBatch(s, force = false) {
|
|
193
|
+
s.batchScheduled = false;
|
|
194
|
+
if (!s.batchBuffer) return;
|
|
116
195
|
// Batch-boundary half-sequence carry: every batch is wrapped in SYNC markers, so if a
|
|
117
196
|
// batch boundary splits an escape sequence the injected markers would eat its ESC and
|
|
118
197
|
// render the tail literally (the root cause of fragments like `[9m`/`8;2;102m`). The
|
|
119
198
|
// half tail is carried to the next batch (PTY continuation always completes it); when
|
|
120
199
|
// force=true (process exit) nothing is carried and all residue is flushed.
|
|
121
|
-
let safe = batchBuffer;
|
|
200
|
+
let safe = s.batchBuffer;
|
|
122
201
|
let carry = '';
|
|
123
|
-
if (!force) [safe, carry] = splitTrailingIncomplete(batchBuffer);
|
|
124
|
-
batchBuffer = carry;
|
|
202
|
+
if (!force) [safe, carry] = splitTrailingIncomplete(s.batchBuffer);
|
|
203
|
+
s.batchBuffer = carry;
|
|
125
204
|
if (!safe) return;
|
|
205
|
+
if (s !== _active()) return; // background PTY: buffered for later re-attach, not broadcast
|
|
126
206
|
const chunk = SYNC_BEGIN + safe + SYNC_END;
|
|
127
207
|
for (const cb of dataListeners) {
|
|
128
208
|
try { cb(chunk); } catch { }
|
|
@@ -130,12 +210,13 @@ function flushBatch(force = false) {
|
|
|
130
210
|
}
|
|
131
211
|
|
|
132
212
|
// Inject a synthetic notice line into the embedded terminal (not claude output). Appending
|
|
133
|
-
// to outputBuffer lets newly-connected / reconnected clients see it in the
|
|
134
|
-
// (server.js's data-resync reads getOutputBuffer), then broadcast live to the
|
|
135
|
-
// dataListeners.
|
|
136
|
-
function emitSpawnNotice(line) {
|
|
213
|
+
// to the record's outputBuffer lets newly-connected / reconnected clients see it in the
|
|
214
|
+
// snapshot (server.js's data-resync reads getOutputBuffer), then broadcast live to the
|
|
215
|
+
// current dataListeners (only while the record is active).
|
|
216
|
+
function emitSpawnNotice(s, line) {
|
|
137
217
|
const chunk = `\x1b[2m${line}\x1b[0m\r\n`;
|
|
138
|
-
outputBuffer += chunk;
|
|
218
|
+
s.outputBuffer += chunk;
|
|
219
|
+
if (s !== _active()) return;
|
|
139
220
|
for (const cb of dataListeners) {
|
|
140
221
|
try { cb(SYNC_BEGIN + chunk + SYNC_END); } catch { }
|
|
141
222
|
}
|
|
@@ -243,28 +324,57 @@ export function _isSystemPromptFileRejected(claudePath) {
|
|
|
243
324
|
}
|
|
244
325
|
|
|
245
326
|
export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = null, isNpmVersion = false, serverPort = null, serverProtocol = 'http', internalToken = null) {
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
try {
|
|
327
|
+
// Mint a FRESH instanceKey for every spawn: the same cwd may host multiple concurrent
|
|
328
|
+
// records (2026-10-06), so we never reuse a cwd-derived key nor kill an existing same-cwd
|
|
329
|
+
// record here. (Self-heal / -c / injection-fallback RESPAWNS are different — they must
|
|
330
|
+
// reuse the SAME record/key, so they go through `_respawnInto`, not this public entry.)
|
|
331
|
+
// WARNING: this is the DELIBERATE-multi-instance entry — every call starts a NEW process
|
|
332
|
+
// even if one is already running for `cwd`. Callers that mean "re-open = reuse the live
|
|
333
|
+
// one" (e.g. the workspace launch route) must go through `ensurePtyForCwd` instead, or they
|
|
334
|
+
// will silently leak a duplicate process.
|
|
335
|
+
const key = _mintInstanceKey();
|
|
336
|
+
// Serialize concurrent spawns FOR THIS KEY (a fresh key means cross-instance spawns never
|
|
337
|
+
// falsely block each other; same-key contention only arises from a respawn racing a spawn).
|
|
338
|
+
while (_spawnInflight.has(key)) { try { await _spawnInflight.get(key); } catch { } }
|
|
339
|
+
const p = _spawnClaudeImpl(key, proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
|
|
340
|
+
_spawnInflight.set(key, p);
|
|
341
|
+
try { return await p; } finally { if (_spawnInflight.get(key) === p) _spawnInflight.delete(key); }
|
|
258
342
|
}
|
|
259
343
|
|
|
260
|
-
|
|
344
|
+
// Self-heal respawn: re-run claude in the SAME record (same instanceKey) after a boot crash
|
|
345
|
+
// / -c miss / rejected injection. Reusing the key keeps the record's identity (and its
|
|
346
|
+
// sidToKey mapping once a sid resolves) stable across the retry — a fresh key would orphan
|
|
347
|
+
// the old record and leave sidToKey pointing at a dead key, severing chat routing.
|
|
348
|
+
function _respawnInto(key, proxyPort, cwd, extraArgs = [], claudePath = null, isNpmVersion = false, serverPort = null, serverProtocol = 'http', internalToken = null) {
|
|
349
|
+
const p = _spawnClaudeImpl(key, proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
|
|
350
|
+
_spawnInflight.set(key, p);
|
|
351
|
+
return p.finally(() => { if (_spawnInflight.get(key) === p) _spawnInflight.delete(key); });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function _spawnClaudeImpl(key, proxyPort, cwd, extraArgs = [], claudePath = null, isNpmVersion = false, serverPort = null, serverProtocol = 'http', internalToken = null) {
|
|
355
|
+
const s = _getOrInit(key);
|
|
356
|
+
// NOTE: activePtyKey is set AFTER pty.spawn succeeds (below), not here — under
|
|
357
|
+
// concurrent spawns for DIFFERENT projects, the active PTY must be the one whose
|
|
358
|
+
// spawn actually completed (and completed LAST), not merely the one called last.
|
|
261
359
|
const pty = await getPty();
|
|
262
360
|
|
|
263
361
|
fixSpawnHelperPermissions();
|
|
264
362
|
|
|
265
|
-
// If claudePath was not provided,
|
|
363
|
+
// If claudePath was not provided, prefer the launcher-verified executable
|
|
364
|
+
// (cli.js probes resolvePreferredClaudeSelection at boot and pins it into
|
|
365
|
+
// CCV_CLAUDE_EXECUTABLE — e.g. a CodeFuse-managed build that this machine's
|
|
366
|
+
// security policy allows). Falling straight to resolveNativePath() can
|
|
367
|
+
// re-resolve to a DIFFERENT binary (the npm global install) that the
|
|
368
|
+
// launcher's selection had deliberately passed over — on managed machines
|
|
369
|
+
// that binary gets SIGKILLed on exec (Gatekeeper/EDR allowlisting), so a
|
|
370
|
+
// workspace launch dies instantly and the terminal falls back to a bare
|
|
371
|
+
// shell (the "[+] new project never starts claude" bug).
|
|
266
372
|
if (!claudePath) {
|
|
267
|
-
|
|
373
|
+
const pinned = process.env.CCV_CLAUDE_EXECUTABLE;
|
|
374
|
+
if (pinned && typeof pinned === 'string') {
|
|
375
|
+
try { if (statSync(pinned).isFile()) claudePath = pinned; } catch { }
|
|
376
|
+
}
|
|
377
|
+
if (!claudePath) claudePath = resolveNativePath();
|
|
268
378
|
if (!claudePath) {
|
|
269
379
|
throw new Error('claude not found');
|
|
270
380
|
}
|
|
@@ -278,6 +388,36 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
278
388
|
env.CCV_PROXY_MODE = '1'; // tell interceptor.js not to start a server again
|
|
279
389
|
env.CCV_LOG_DIR = LOG_DIR; // let the forked Claude Code process find the same
|
|
280
390
|
// profile.json etc. resources
|
|
391
|
+
// Self-report project (2026-10, multi-PTY): every main-PTY claude ALSO tags its
|
|
392
|
+
// requests with `x-ccv-project-dir` = its own cwd, so the writer routes its
|
|
393
|
+
// writes to ITS project — not the (possibly re-bound) server project. This is
|
|
394
|
+
// what lets a kept-alive OLD project's background claude keep writing to its
|
|
395
|
+
// own dir after the server binding moves away. Strip CR/LF/control chars
|
|
396
|
+
// defensively (header values may not contain them).
|
|
397
|
+
{
|
|
398
|
+
const headerCwd = String(cwd || process.cwd()).replace(/[\r\n\x00-\x1f\x7f]/g, '');
|
|
399
|
+
if (headerCwd) {
|
|
400
|
+
const projectHeader = `x-ccv-project-dir: ${headerCwd}`;
|
|
401
|
+
env.ANTHROPIC_CUSTOM_HEADERS = env.ANTHROPIC_CUSTOM_HEADERS
|
|
402
|
+
? `${env.ANTHROPIC_CUSTOM_HEADERS}\n${projectHeader}`
|
|
403
|
+
: projectHeader;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// Self-report instance (2026-10-06, multi-instance): alongside the project dir, tag every
|
|
407
|
+
// request with this PTY's own instanceKey (`x-ccv-instance`) so the interceptor/writer can
|
|
408
|
+
// pin the request — and later its resolved sessionId — to THIS exact process. This is what
|
|
409
|
+
// disambiguates two concurrent claude instances sharing one cwd (basename routing cannot
|
|
410
|
+
// tell them apart). Strip CR/LF/control chars defensively (header values may not contain
|
|
411
|
+
// them); the instanceKey is internally minted (`ccv-<hex>`) so it is already header-safe.
|
|
412
|
+
{
|
|
413
|
+
const instHeader = String(key || '').replace(/[\r\n\x00-\x1f\x7f]/g, '');
|
|
414
|
+
if (instHeader) {
|
|
415
|
+
const instanceHeader = `x-ccv-instance: ${instHeader}`;
|
|
416
|
+
env.ANTHROPIC_CUSTOM_HEADERS = env.ANTHROPIC_CUSTOM_HEADERS
|
|
417
|
+
? `${env.ANTHROPIC_CUSTOM_HEADERS}\n${instanceHeader}`
|
|
418
|
+
: instanceHeader;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
281
421
|
// Strip cc-viewer's internal short-circuit switch so it does not leak to the claude child
|
|
282
422
|
delete env.CCV_SKIP_THINKING_DISPLAY;
|
|
283
423
|
// Strip server-only mode markers: a spawned claude (especially teammate subprocesses,
|
|
@@ -460,49 +600,60 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
460
600
|
args = [claudePath, '--settings', settingsJson, ...launchArgs];
|
|
461
601
|
}
|
|
462
602
|
|
|
463
|
-
lastExitCode = null;
|
|
464
|
-
outputBuffer = '';
|
|
465
|
-
currentWorkspacePath = cwd || process.cwd();
|
|
466
|
-
lastWorkspacePath = currentWorkspacePath;
|
|
603
|
+
s.lastExitCode = null;
|
|
604
|
+
s.outputBuffer = '';
|
|
605
|
+
s.currentWorkspacePath = cwd || process.cwd();
|
|
606
|
+
s.lastWorkspacePath = s.currentWorkspacePath;
|
|
607
|
+
// Persist the real cwd on the record (2026-10-06): the Map key is now an opaque
|
|
608
|
+
// instanceKey, so basename/cwd reverse-lookups must read this field, never the key.
|
|
609
|
+
s.instanceKey = key;
|
|
610
|
+
s.cwd = s.currentWorkspacePath;
|
|
611
|
+
// A (re)spawn starts a fresh conversation — drop any sessionId a prior life
|
|
612
|
+
// on this record resolved to, so a stale sid never misroutes a send.
|
|
613
|
+
if (s.sessionId) { sidToKey.delete(s.sessionId); s.sessionId = null; }
|
|
467
614
|
// Boot-window anchor for the injection fallback tiers below (same clock as the
|
|
468
615
|
// comparison — _now(), never Date.now(), so tests can steer both ends together).
|
|
469
616
|
const spawnedAt = _now();
|
|
470
617
|
|
|
471
|
-
ptyProcess = pty.spawn(command, args, {
|
|
618
|
+
s.ptyProcess = pty.spawn(command, args, {
|
|
472
619
|
name: 'xterm-256color',
|
|
473
|
-
cols: lastPtyCols,
|
|
474
|
-
rows: lastPtyRows,
|
|
475
|
-
cwd: currentWorkspacePath,
|
|
620
|
+
cols: s.lastPtyCols,
|
|
621
|
+
rows: s.lastPtyRows,
|
|
622
|
+
cwd: s.currentWorkspacePath,
|
|
476
623
|
env,
|
|
477
624
|
});
|
|
478
|
-
|
|
625
|
+
// This spawn SUCCEEDED — it becomes the main view's active PTY. Set here (after
|
|
626
|
+
// pty.spawn returns) so that under concurrent spawns for different projects the
|
|
627
|
+
// active PTY is the one whose spawn completed LAST, not the one invoked last.
|
|
628
|
+
activePtyKey = key;
|
|
629
|
+
s.ptyKind = 'claude';
|
|
479
630
|
// --allow-dangerously-skip-permissions only enables a later toggle, so it must NOT count.
|
|
480
|
-
ptySkipPermissions = extraArgs.includes('--dangerously-skip-permissions');
|
|
631
|
+
s.ptySkipPermissions = extraArgs.includes('--dangerously-skip-permissions');
|
|
481
632
|
|
|
482
633
|
// PTY event handlers must be registered immediately after spawn (PR#128): if the child
|
|
483
634
|
// exits before onExit is mounted (missing binary / instant crash / rejected injection
|
|
484
635
|
// flag), the exit event is lost — after the handle is released the event loop may drain.
|
|
485
636
|
// The injection notice is moved to after registration.
|
|
486
|
-
ptyProcess.onData((data) => {
|
|
487
|
-
outputBuffer += data;
|
|
488
|
-
if (outputBuffer.length > MAX_BUFFER) {
|
|
489
|
-
const rawStart = outputBuffer.length - BUFFER_TRIM_TO;
|
|
490
|
-
const safeStart = findSafeSliceStart(outputBuffer, rawStart);
|
|
491
|
-
outputBuffer = outputBuffer.slice(safeStart);
|
|
492
|
-
}
|
|
493
|
-
batchBuffer += data;
|
|
494
|
-
if (!batchScheduled) {
|
|
495
|
-
batchScheduled = true;
|
|
496
|
-
setImmediate(flushBatch);
|
|
637
|
+
s.ptyProcess.onData((data) => {
|
|
638
|
+
s.outputBuffer += data;
|
|
639
|
+
if (s.outputBuffer.length > MAX_BUFFER) {
|
|
640
|
+
const rawStart = s.outputBuffer.length - BUFFER_TRIM_TO;
|
|
641
|
+
const safeStart = findSafeSliceStart(s.outputBuffer, rawStart);
|
|
642
|
+
s.outputBuffer = s.outputBuffer.slice(safeStart);
|
|
643
|
+
}
|
|
644
|
+
s.batchBuffer += data;
|
|
645
|
+
if (!s.batchScheduled) {
|
|
646
|
+
s.batchScheduled = true;
|
|
647
|
+
setImmediate(() => flushBatch(s));
|
|
497
648
|
}
|
|
498
649
|
});
|
|
499
650
|
|
|
500
|
-
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
501
|
-
flushBatch(true);
|
|
502
|
-
lastExitCode = exitCode;
|
|
503
|
-
ptyProcess = null;
|
|
504
|
-
ptyKind = null;
|
|
505
|
-
ptySkipPermissions = false;
|
|
651
|
+
s.ptyProcess.onExit(({ exitCode, signal }) => {
|
|
652
|
+
flushBatch(s, true);
|
|
653
|
+
s.lastExitCode = exitCode;
|
|
654
|
+
s.ptyProcess = null;
|
|
655
|
+
s.ptyKind = null;
|
|
656
|
+
s.ptySkipPermissions = false;
|
|
506
657
|
// Boot-period death: an exit within the window after spawn. Any exit outside the window
|
|
507
658
|
// is never part of the "injection dragged the boot into a crash" fallback scope. A
|
|
508
659
|
// single _now() read (review): tiers 1/2 share the same instant, so the injected fake
|
|
@@ -515,11 +666,13 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
515
666
|
// death is transparent to consumers. Once the new pty starts normally it reports its own
|
|
516
667
|
// state/exit. This keeps the frontend from seeing a spurious exit event.
|
|
517
668
|
const hasContinue = extraArgs.includes('-c') || extraArgs.includes('--continue');
|
|
518
|
-
if (hasContinue && exitCode !== 0 && outputBuffer.includes('No conversation found')) {
|
|
669
|
+
if (hasContinue && exitCode !== 0 && s.outputBuffer.includes('No conversation found')) {
|
|
519
670
|
console.error('[CC Viewer] -c failed (no conversation), retrying without -c');
|
|
520
671
|
const retryArgs = extraArgs.filter(a => a !== '-c' && a !== '--continue');
|
|
521
672
|
_suppressNextSpawnNotice = true;
|
|
522
|
-
|
|
673
|
+
// Respawn into the SAME record/key (closure-captured), not a fresh spawn — keeps the
|
|
674
|
+
// record identity + future sid mapping stable across the retry.
|
|
675
|
+
_respawnInto(key, proxyPort, cwd, retryArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
|
|
523
676
|
return;
|
|
524
677
|
}
|
|
525
678
|
|
|
@@ -533,12 +686,12 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
533
686
|
const weInjectedFlag = shouldInjectThinkingDisplay
|
|
534
687
|
&& !extraArgs.some(a => a === '--thinking-display' || (typeof a === 'string' && a.startsWith('--thinking-display=')));
|
|
535
688
|
const flagRejected = weInjectedFlag && exitCode !== 0
|
|
536
|
-
&& /unknown option ['"]--thinking-display/i.test(outputBuffer);
|
|
689
|
+
&& /unknown option ['"]--thinking-display/i.test(s.outputBuffer);
|
|
537
690
|
if (flagRejected) {
|
|
538
691
|
console.error('[CC Viewer] claude rejected --thinking-display, marking as unsupported and retrying without flag');
|
|
539
692
|
_thinkingDisplayRejectedPaths.add(claudePath);
|
|
540
693
|
_suppressNextSpawnNotice = true;
|
|
541
|
-
|
|
694
|
+
_respawnInto(key, proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
|
|
542
695
|
return;
|
|
543
696
|
}
|
|
544
697
|
|
|
@@ -569,7 +722,7 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
569
722
|
// root cause is something else (e.g. an expired API key), the first error already
|
|
570
723
|
// streamed into the terminal scrollback without loss, and the second death broadcasts
|
|
571
724
|
// as usual.
|
|
572
|
-
const unknownSysFileFlag = /unknown option ['"]--(append-)?system-prompt-file/i.test(outputBuffer);
|
|
725
|
+
const unknownSysFileFlag = /unknown option ['"]--(append-)?system-prompt-file/i.test(s.outputBuffer);
|
|
573
726
|
const injectedBootCrash = !insideLogDir && !signal && diedInBootWindow;
|
|
574
727
|
const sysFileRejected = sysPrompt.loaded.length > 0 && exitCode !== 0
|
|
575
728
|
&& (unknownSysFileFlag || injectedBootCrash);
|
|
@@ -584,8 +737,8 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
584
737
|
_suppressNextSpawnNotice = true;
|
|
585
738
|
// Wording leaves room (review): a boot-period death may be unrelated to the injection
|
|
586
739
|
// (API key / network etc.); do not assert causation.
|
|
587
|
-
emitSpawnNotice(`[CC Viewer] claude exited during boot (code ${exitCode}); the injected system prompt may or may not be the cause — retrying once without ${sysPrompt.loaded.join(', ')}`);
|
|
588
|
-
|
|
740
|
+
emitSpawnNotice(s, `[CC Viewer] claude exited during boot (code ${exitCode}); the injected system prompt may or may not be the cause — retrying once without ${sysPrompt.loaded.join(', ')}`);
|
|
741
|
+
_respawnInto(key, proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
|
|
589
742
|
return;
|
|
590
743
|
}
|
|
591
744
|
|
|
@@ -598,13 +751,21 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
598
751
|
// !insideLogDir: an IM worker's pty data stream may be relayed via the bridge, so the
|
|
599
752
|
// diagnostic line must not leak into the IM session (review).
|
|
600
753
|
if (sysPrompt.loaded.length > 0 && exitCode === 0 && diedInBootWindow && !insideLogDir) {
|
|
601
|
-
emitSpawnNotice(`[CC Viewer] claude exited ${Math.round(elapsedMs / 1000)}s after launch with an injected system prompt (${sysPrompt.loaded.join(', ')}). If this keeps happening the injected prompt may be incompatible — remove the entry or set CCV_DISABLE_AUTO_SYSTEM_PROMPT=1 to skip injection.`);
|
|
754
|
+
emitSpawnNotice(s, `[CC Viewer] claude exited ${Math.round(elapsedMs / 1000)}s after launch with an injected system prompt (${sysPrompt.loaded.join(', ')}). If this keeps happening the injected prompt may be incompatible — remove the entry or set CCV_DISABLE_AUTO_SYSTEM_PROMPT=1 to skip injection.`);
|
|
602
755
|
}
|
|
603
756
|
|
|
604
|
-
// Keep lastWorkspacePath (do not clear) for respawn
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
757
|
+
// Keep lastWorkspacePath (do not clear) for respawn; also keep record.cwd (the persistent
|
|
758
|
+
// real dir) so basename/cwd reverse-lookups still resolve this record after exit.
|
|
759
|
+
s.currentWorkspacePath = null;
|
|
760
|
+
// Only broadcast the ACTIVE PTY's exit to the global listeners. A background
|
|
761
|
+
// (kept-alive, background) PTY exiting must not push the "exited" banner
|
|
762
|
+
// into the foreground project's terminal, nor fire the whole-process cleanup
|
|
763
|
+
// in codefuse mode. Background exits just record lastExitCode (listLivePtys
|
|
764
|
+
// still observes them).
|
|
765
|
+
if (s === _active()) {
|
|
766
|
+
for (const cb of exitListeners) {
|
|
767
|
+
try { cb(exitCode); } catch { }
|
|
768
|
+
}
|
|
608
769
|
}
|
|
609
770
|
});
|
|
610
771
|
|
|
@@ -614,7 +775,7 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
614
775
|
// the child exits before the handlers are mounted.
|
|
615
776
|
if (sysPrompt.loaded.length && !sysPrompt.pinned && !_suppressNextSpawnNotice) {
|
|
616
777
|
const modelSuffix = sysPrompt.model ? ` (model match: ${sysPrompt.model})` : '';
|
|
617
|
-
emitSpawnNotice(`[CC Viewer] loaded ${sysPrompt.loaded.join(', ')} as system prompt${modelSuffix}`);
|
|
778
|
+
emitSpawnNotice(s, `[CC Viewer] loaded ${sysPrompt.loaded.join(', ')} as system prompt${modelSuffix}`);
|
|
618
779
|
}
|
|
619
780
|
// Pin visibility: a snapshot hit re-injects verbatim; a no-record resume (F2)
|
|
620
781
|
// injects nothing — surfaced ONLY when injection is configured right now
|
|
@@ -622,9 +783,9 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
622
783
|
// Mutually exclusive with the loaded notice above (the pinned path never prints it).
|
|
623
784
|
if (sysPrompt.pinned && !_suppressNextSpawnNotice) {
|
|
624
785
|
if (sysPrompt.noRecord) {
|
|
625
|
-
if (sysPrompt.noRecordNotice) emitSpawnNotice(`[CC Viewer] ${t('cli.systemPromptResumeNoSnapshot')}`);
|
|
786
|
+
if (sysPrompt.noRecordNotice) emitSpawnNotice(s, `[CC Viewer] ${t('cli.systemPromptResumeNoSnapshot')}`);
|
|
626
787
|
} else if (sysPrompt.loaded.length) {
|
|
627
|
-
emitSpawnNotice(`[CC Viewer] ${t('cli.systemPromptPinned', { files: sysPrompt.loaded.join(', ') })}`);
|
|
788
|
+
emitSpawnNotice(s, `[CC Viewer] ${t('cli.systemPromptPinned', { files: sysPrompt.loaded.join(', ') })}`);
|
|
628
789
|
}
|
|
629
790
|
}
|
|
630
791
|
// Settings-merge failures surface via emitSpawnNotice too: console.warn only reaches
|
|
@@ -632,16 +793,18 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
632
793
|
// above stays English for greppable server logs). Must be emitted after spawn — the
|
|
633
794
|
// outputBuffer reset right before pty.spawn would swallow an earlier write.
|
|
634
795
|
if (settingsMerge.warningDetail && !_suppressNextSpawnNotice) {
|
|
635
|
-
emitSpawnNotice(`[CC Viewer] ${t('cli.settingsMergeFailed', settingsMerge.warningDetail)}`);
|
|
796
|
+
emitSpawnNotice(s, `[CC Viewer] ${t('cli.settingsMergeFailed', settingsMerge.warningDetail)}`);
|
|
636
797
|
}
|
|
637
798
|
_suppressNextSpawnNotice = false;
|
|
799
|
+
_maybeReap();
|
|
638
800
|
|
|
639
|
-
return ptyProcess;
|
|
801
|
+
return s.ptyProcess;
|
|
640
802
|
}
|
|
641
803
|
|
|
642
804
|
export function writeToPty(data) {
|
|
643
|
-
|
|
644
|
-
|
|
805
|
+
const s = _active();
|
|
806
|
+
if (s && s.ptyProcess) {
|
|
807
|
+
s.ptyProcess.write(data);
|
|
645
808
|
return true;
|
|
646
809
|
}
|
|
647
810
|
return false;
|
|
@@ -658,8 +821,9 @@ export function writeToPty(data) {
|
|
|
658
821
|
export function writeToPtySequential(chunks, onComplete, opts = {}) {
|
|
659
822
|
const timeoutMs = opts.timeoutMs || 4000;
|
|
660
823
|
const settleMs = opts.settleMs || 150;
|
|
824
|
+
const s = _active();
|
|
661
825
|
|
|
662
|
-
if (!ptyProcess || !chunks || chunks.length === 0) {
|
|
826
|
+
if (!s || !s.ptyProcess || !chunks || chunks.length === 0) {
|
|
663
827
|
if (onComplete) onComplete(false);
|
|
664
828
|
return;
|
|
665
829
|
}
|
|
@@ -675,7 +839,7 @@ export function writeToPtySequential(chunks, onComplete, opts = {}) {
|
|
|
675
839
|
};
|
|
676
840
|
|
|
677
841
|
const sendNext = () => {
|
|
678
|
-
if (idx >= chunks.length || !ptyProcess) {
|
|
842
|
+
if (idx >= chunks.length || !s.ptyProcess) {
|
|
679
843
|
cleanup();
|
|
680
844
|
// Report success only if every chunk was sent. A PTY that died mid-sequence (idx <
|
|
681
845
|
// length) is a partial/failed injection — callers (e.g. the DingTalk bridge) must learn
|
|
@@ -698,7 +862,7 @@ export function writeToPtySequential(chunks, onComplete, opts = {}) {
|
|
|
698
862
|
return;
|
|
699
863
|
}
|
|
700
864
|
try {
|
|
701
|
-
ptyProcess.write(chunk);
|
|
865
|
+
s.ptyProcess.write(chunk);
|
|
702
866
|
} catch (e) {
|
|
703
867
|
cleanup();
|
|
704
868
|
if (onComplete) onComplete(false);
|
|
@@ -721,16 +885,43 @@ export function writeToPtySequential(chunks, onComplete, opts = {}) {
|
|
|
721
885
|
* After the process exits, auto-spawn an interactive shell so the terminal becomes usable
|
|
722
886
|
* again. Returns true if spawned successfully, false if unnecessary or failed.
|
|
723
887
|
*/
|
|
888
|
+
// Module-level single in-flight guard for spawnShell. spawnShell normally derives its key
|
|
889
|
+
// from the CURRENT activePtyKey at call time — but when nothing is active yet (fresh server,
|
|
890
|
+
// or after a full reset), two concurrent calls would each mint a DIFFERENT instanceKey before
|
|
891
|
+
// the first reaches its `await getPty()`, so a per-key inflight map cannot dedupe them and
|
|
892
|
+
// two shells would open. A single shared promise serializes that whole spawn regardless of
|
|
893
|
+
// which key it lands on (there is only ever one "current shell" the terminal wants).
|
|
894
|
+
let _shellInflight = null;
|
|
895
|
+
|
|
896
|
+
// Per-cwd in-flight guard for the launch route's ensurePtyForCwd (2026-10-06). Unlike
|
|
897
|
+
// `_spawnInflight` (keyed by a freshly-minted instanceKey, so it cannot dedupe two concurrent
|
|
898
|
+
// spawns of the SAME cwd), this serializes the live-scan→spawn sequence per cwd so a duplicate
|
|
899
|
+
// process is not started by a double launch.
|
|
900
|
+
const _cwdLaunchInflight = new Map();
|
|
901
|
+
|
|
724
902
|
export async function spawnShell() {
|
|
725
|
-
|
|
726
|
-
if (
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
903
|
+
const s = _active();
|
|
904
|
+
if (s && s.ptyProcess) return false; // a process is already running
|
|
905
|
+
if (_shellInflight) return _shellInflight; // reuse the in-flight shell spawn to avoid a double-open
|
|
906
|
+
// Reuse the ACTIVE record's key when there is one (shell re-opens into the dead claude's
|
|
907
|
+
// record). Only when nothing has ever spawned do we mint a fresh instanceKey — never a
|
|
908
|
+
// cwd-derived key (the Map is keyed by instanceKey, 2026-10-06; a cwd key would break the
|
|
909
|
+
// "key is always an instanceKey" invariant).
|
|
910
|
+
const key = activePtyKey != null ? activePtyKey : _mintInstanceKey();
|
|
911
|
+
const p = (async () => {
|
|
912
|
+
if (_spawnInflight.has(key)) return _spawnInflight.get(key);
|
|
913
|
+
const inner = _spawnShellImpl(key);
|
|
914
|
+
_spawnInflight.set(key, inner);
|
|
915
|
+
try { return await inner; } finally { if (_spawnInflight.get(key) === inner) _spawnInflight.delete(key); }
|
|
916
|
+
})();
|
|
917
|
+
_shellInflight = p;
|
|
918
|
+
try { return await p; } finally { if (_shellInflight === p) _shellInflight = null; }
|
|
730
919
|
}
|
|
731
920
|
|
|
732
|
-
async function _spawnShellImpl() {
|
|
733
|
-
const
|
|
921
|
+
async function _spawnShellImpl(key) {
|
|
922
|
+
const s = _getOrInit(key);
|
|
923
|
+
// activePtyKey set after pty.spawn succeeds (below), mirroring _spawnClaudeImpl.
|
|
924
|
+
const cwd = s.lastWorkspacePath || s.cwd || process.cwd();
|
|
734
925
|
|
|
735
926
|
const pty = await getPty();
|
|
736
927
|
|
|
@@ -738,8 +929,12 @@ async function _spawnShellImpl() {
|
|
|
738
929
|
|
|
739
930
|
const shell = process.env.SHELL || (process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh');
|
|
740
931
|
|
|
741
|
-
lastExitCode = null;
|
|
742
|
-
currentWorkspacePath = cwd;
|
|
932
|
+
s.lastExitCode = null;
|
|
933
|
+
s.currentWorkspacePath = cwd;
|
|
934
|
+
// Persist identity on the record (mirrors _spawnClaudeImpl): the Map key is an opaque
|
|
935
|
+
// instanceKey, so cwd must live on the record for basename/cwd reverse-lookups.
|
|
936
|
+
s.instanceKey = key;
|
|
937
|
+
s.cwd = cwd;
|
|
743
938
|
|
|
744
939
|
// Clean env: remove cc-viewer specific vars so child shells don't inherit them
|
|
745
940
|
// (prevents CCVIEWER_PORT/CCVIEWER_PROTOCOL leaking to non-cc-viewer Claude instances;
|
|
@@ -758,39 +953,44 @@ async function _spawnShellImpl() {
|
|
|
758
953
|
applyClaudeAltScreenPref(shellEnv);
|
|
759
954
|
const shellSpawn = prepareEmbeddedShellSpawn(shell, shellEnv);
|
|
760
955
|
|
|
761
|
-
ptyProcess = pty.spawn(shellSpawn.command, shellSpawn.args, {
|
|
956
|
+
s.ptyProcess = pty.spawn(shellSpawn.command, shellSpawn.args, {
|
|
762
957
|
name: 'xterm-256color',
|
|
763
|
-
cols: lastPtyCols,
|
|
764
|
-
rows: lastPtyRows,
|
|
958
|
+
cols: s.lastPtyCols,
|
|
959
|
+
rows: s.lastPtyRows,
|
|
765
960
|
cwd,
|
|
766
961
|
env: shellSpawn.env,
|
|
767
962
|
});
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
const
|
|
776
|
-
|
|
963
|
+
activePtyKey = key; // spawn succeeded → becomes the active PTY
|
|
964
|
+
s.ptyKind = 'shell';
|
|
965
|
+
s.ptySkipPermissions = false;
|
|
966
|
+
|
|
967
|
+
s.ptyProcess.onData((data) => {
|
|
968
|
+
s.outputBuffer += data;
|
|
969
|
+
if (s.outputBuffer.length > MAX_BUFFER) {
|
|
970
|
+
const rawStart = s.outputBuffer.length - BUFFER_TRIM_TO;
|
|
971
|
+
const safeStart = findSafeSliceStart(s.outputBuffer, rawStart);
|
|
972
|
+
s.outputBuffer = s.outputBuffer.slice(safeStart);
|
|
777
973
|
}
|
|
778
|
-
batchBuffer += data;
|
|
779
|
-
if (!batchScheduled) {
|
|
780
|
-
batchScheduled = true;
|
|
781
|
-
setImmediate(flushBatch);
|
|
974
|
+
s.batchBuffer += data;
|
|
975
|
+
if (!s.batchScheduled) {
|
|
976
|
+
s.batchScheduled = true;
|
|
977
|
+
setImmediate(() => flushBatch(s));
|
|
782
978
|
}
|
|
783
979
|
});
|
|
784
980
|
|
|
785
|
-
ptyProcess.onExit(({ exitCode }) => {
|
|
786
|
-
flushBatch(true);
|
|
787
|
-
lastExitCode = exitCode;
|
|
788
|
-
ptyProcess = null;
|
|
789
|
-
ptyKind = null;
|
|
790
|
-
ptySkipPermissions = false;
|
|
791
|
-
currentWorkspacePath = null;
|
|
792
|
-
|
|
793
|
-
|
|
981
|
+
s.ptyProcess.onExit(({ exitCode }) => {
|
|
982
|
+
flushBatch(s, true);
|
|
983
|
+
s.lastExitCode = exitCode;
|
|
984
|
+
s.ptyProcess = null;
|
|
985
|
+
s.ptyKind = null;
|
|
986
|
+
s.ptySkipPermissions = false;
|
|
987
|
+
s.currentWorkspacePath = null;
|
|
988
|
+
// Same active-scope guard as the main spawn's onExit: only the foreground
|
|
989
|
+
// (active) PTY's exit is broadcast; a background shell exit stays silent.
|
|
990
|
+
if (s === _active()) {
|
|
991
|
+
for (const cb of exitListeners) {
|
|
992
|
+
try { cb(exitCode); } catch { }
|
|
993
|
+
}
|
|
794
994
|
}
|
|
795
995
|
});
|
|
796
996
|
|
|
@@ -809,37 +1009,376 @@ function _clampDim(v, min, max, fallback) {
|
|
|
809
1009
|
}
|
|
810
1010
|
|
|
811
1011
|
export function resizePty(cols, rows) {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1012
|
+
const s = _active();
|
|
1013
|
+
if (!s) return;
|
|
1014
|
+
s.lastPtyCols = _clampDim(cols, PTY_COLS_MIN, PTY_COLS_MAX, s.lastPtyCols);
|
|
1015
|
+
s.lastPtyRows = _clampDim(rows, PTY_ROWS_MIN, PTY_ROWS_MAX, s.lastPtyRows);
|
|
1016
|
+
if (s.ptyProcess) {
|
|
1017
|
+
try { s.ptyProcess.resize(s.lastPtyCols, s.lastPtyRows); } catch { }
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Kill a specific record's PTY (the shared kill path for the no-arg killPty — active —
|
|
1022
|
+
// and killPtyFor). Keeps the record (and its lastWorkspacePath / scrollback / cwd) so a
|
|
1023
|
+
// later re-spawn / re-attach resumes cleanly.
|
|
1024
|
+
function _killPtyRecord(s) {
|
|
1025
|
+
if (!s || !s.ptyProcess) return;
|
|
1026
|
+
flushBatch(s, true);
|
|
1027
|
+
s.batchBuffer = '';
|
|
1028
|
+
s.batchScheduled = false;
|
|
1029
|
+
// Windows: node-pty's ConPTY kill has a known synchronous-hang issue
|
|
1030
|
+
// (microsoft/node-pty#454); a hang would also take down the Ctrl+C exit-chain watchdog.
|
|
1031
|
+
// Instead use spawnSync taskkill /T /F to reap the whole process tree (ConPTY agent +
|
|
1032
|
+
// claude), bounded (timeout 2s) and providing "dead on return" semantics (which
|
|
1033
|
+
// spawnClaude's internal kill→respawn and workspaces stop→launch rely on). ptyProcess
|
|
1034
|
+
// .kill() is fully skipped on win32. Non-Windows behavior is unchanged.
|
|
1035
|
+
if (!killPtyTree(s.ptyProcess.pid)) {
|
|
1036
|
+
try { s.ptyProcess.kill(); } catch { }
|
|
816
1037
|
}
|
|
1038
|
+
s.ptyProcess = null;
|
|
1039
|
+
s.ptyKind = null;
|
|
1040
|
+
s.ptySkipPermissions = false;
|
|
817
1041
|
}
|
|
818
1042
|
|
|
819
1043
|
export function killPty() {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1044
|
+
_killPtyRecord(_active());
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Kill a SPECIFIC project's main PTY (multi-PTY, 2026-10) — the per-project
|
|
1049
|
+
* counterpart of killPty()/killAllMain(), powering the web header's per-tab
|
|
1050
|
+
* close button (POST /api/live-processes/close). Resolution mirrors
|
|
1051
|
+
* attachPtyFor (exact match on the record's stored cwd, else project-name
|
|
1052
|
+
* reverse lookup). Never
|
|
1053
|
+
* spawns; the record (and its scrollback/lastWorkspacePath) is kept so a
|
|
1054
|
+
* later re-launch resumes cleanly — same semantics as _killPtyRecord.
|
|
1055
|
+
*
|
|
1056
|
+
* When the killed record was the ACTIVE one, the attachment re-anchors to
|
|
1057
|
+
* another still-RUNNING record (first in Map order) so the shared terminal
|
|
1058
|
+
* stream follows a live project instead of going dark; with none left alive
|
|
1059
|
+
* the key stays on the dead record (no-arg readers see running:false, the
|
|
1060
|
+
* same dead-state a natural exit produces). Records whose process already
|
|
1061
|
+
* exited are never re-anchor targets.
|
|
1062
|
+
*
|
|
1063
|
+
* @returns {{ ok:boolean, key?:string, killedActive?:boolean,
|
|
1064
|
+
* reattachedTo?:string|null, reason?:string }}
|
|
1065
|
+
*/
|
|
1066
|
+
export function killPtyFor({ cwd, project, instanceKey } = {}) {
|
|
1067
|
+
// Multi-instance forced disambiguation (2026-10-06): a project-name-only close that matches
|
|
1068
|
+
// TWO concurrently-live same-basename records is ambiguous — killing "the first live one"
|
|
1069
|
+
// would kill an arbitrary process. Refuse and surface the live candidates so the caller
|
|
1070
|
+
// re-issues with an instanceKey. Single-match / instance-Keyed calls are unaffected.
|
|
1071
|
+
if (!instanceKey && typeof project === 'string' && project) {
|
|
1072
|
+
const live = _liveInstancesForProject(project);
|
|
1073
|
+
if (live.length > 1) return { ok: false, reason: 'ambiguous', candidates: live };
|
|
1074
|
+
}
|
|
1075
|
+
const key = _resolveKey({ cwd, project, instanceKey });
|
|
1076
|
+
if (!key) return { ok: false, reason: 'not-found' };
|
|
1077
|
+
const s = ptys.get(key);
|
|
1078
|
+
if (!s) return { ok: false, reason: 'not-found' };
|
|
1079
|
+
_killPtyRecord(s);
|
|
1080
|
+
const killedActive = key === activePtyKey;
|
|
1081
|
+
let reattachedTo = null;
|
|
1082
|
+
if (killedActive) {
|
|
1083
|
+
for (const [k, other] of ptys) {
|
|
1084
|
+
if (k !== key && other && other.ptyProcess) { reattachedTo = k; break; }
|
|
1085
|
+
}
|
|
1086
|
+
if (reattachedTo) activePtyKey = reattachedTo;
|
|
1087
|
+
}
|
|
1088
|
+
return { ok: true, key, killedActive, reattachedTo };
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Kill EVERY main PTY across all projects (workspaces stop / process teardown). The no-arg
|
|
1092
|
+
// killPty only kills the ACTIVE one; a kept-alive background project's PTY must also be
|
|
1093
|
+
// reaped or it leaks past the workspace session.
|
|
1094
|
+
export function killAllMain() {
|
|
1095
|
+
for (const s of ptys.values()) _killPtyRecord(s);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Resolve a PTY-map key from an exact `cwd` or a `project` name (reverse lookup
|
|
1100
|
+
* via projectKeyForCwd, the same mapping the live-processes route uses). Shared
|
|
1101
|
+
* by attachPtyFor / killPtyFor — returns null when nothing matches so callers
|
|
1102
|
+
* can degrade with their own not-found shape.
|
|
1103
|
+
*
|
|
1104
|
+
* The Map is keyed by an opaque `instanceKey` (2026-10-06), NOT cwd, so the exact-cwd
|
|
1105
|
+
* branch can no longer do `ptys.has(cwd)` — it scans records for a matching `record.cwd`.
|
|
1106
|
+
* A single cwd may host multiple live records (multi-instance); ties break
|
|
1107
|
+
* live-before-exited, then active-before-background (mirrors the basename branch).
|
|
1108
|
+
*/
|
|
1109
|
+
function _resolveKey({ cwd, project, instanceKey } = {}) {
|
|
1110
|
+
let key = null;
|
|
1111
|
+
// Multi-instance (2026-10-06): an exact instanceKey hits its record directly (the Map key).
|
|
1112
|
+
// Validated to the minted shape before use (defense-in-depth).
|
|
1113
|
+
if (typeof instanceKey === 'string' && /^ccv-[0-9a-f]+$/.test(instanceKey) && ptys.has(instanceKey)) {
|
|
1114
|
+
return instanceKey;
|
|
1115
|
+
}
|
|
1116
|
+
if (typeof cwd === 'string' && cwd) {
|
|
1117
|
+
// Exact-cwd branch: match on the record's persistent cwd, not the (opaque) key.
|
|
1118
|
+
let firstMatch = null;
|
|
1119
|
+
for (const [k, s] of ptys) {
|
|
1120
|
+
if (s.cwd !== cwd) continue;
|
|
1121
|
+
if (!firstMatch) firstMatch = k;
|
|
1122
|
+
if (s.ptyProcess && k === activePtyKey) { key = k; break; } // active + live: best
|
|
1123
|
+
if (s.ptyProcess && !key) key = k; // any live record
|
|
1124
|
+
}
|
|
1125
|
+
if (!key) key = firstMatch;
|
|
1126
|
+
}
|
|
1127
|
+
if (!key && typeof project === 'string' && project) {
|
|
1128
|
+
// Basename collisions: two records can share one project name (same
|
|
1129
|
+
// basename, different dirs). Prefer a RUNNING record over an exited one,
|
|
1130
|
+
// and the current active over a background one — never blindly take the
|
|
1131
|
+
// first insertion (which could be a dead/duplicate record). Known blind
|
|
1132
|
+
// spot: two simultaneously-live same-name records still resolve to the
|
|
1133
|
+
// first live one (full disambiguation via instanceKey lands in the view-layer phase).
|
|
1134
|
+
let firstMatch = null;
|
|
1135
|
+
for (const [k, s] of ptys) {
|
|
1136
|
+
const recCwd = s.cwd || s.currentWorkspacePath || s.lastWorkspacePath;
|
|
1137
|
+
if (projectKeyForCwd(recCwd) !== project) continue;
|
|
1138
|
+
if (!firstMatch) firstMatch = k;
|
|
1139
|
+
if (s.ptyProcess && k === activePtyKey) { key = k; break; } // active + live: best
|
|
1140
|
+
if (s.ptyProcess && !key) key = k; // any live record
|
|
832
1141
|
}
|
|
833
|
-
|
|
834
|
-
ptyKind = null;
|
|
835
|
-
ptySkipPermissions = false;
|
|
1142
|
+
if (!key) key = firstMatch;
|
|
836
1143
|
}
|
|
1144
|
+
return key;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Count the LIVE records whose basename matches `project` (multi-instance disambiguation,
|
|
1149
|
+
* 2026-10-06). When a project-name-only attach/kill arrives while TWO same-cwd (or
|
|
1150
|
+
* same-basename) instances are concurrently live, resolving to "the first live one" would
|
|
1151
|
+
* silently act on an arbitrary process — the caller MUST name an instanceKey instead. Returns
|
|
1152
|
+
* the live candidates so the caller can surface them for disambiguation.
|
|
1153
|
+
*/
|
|
1154
|
+
function _liveInstancesForProject(project) {
|
|
1155
|
+
const live = [];
|
|
1156
|
+
if (typeof project !== 'string' || !project) return live;
|
|
1157
|
+
for (const [k, s] of ptys) {
|
|
1158
|
+
if (!s || !s.ptyProcess) continue; // LIVE only
|
|
1159
|
+
const recCwd = s.cwd || s.currentWorkspacePath || s.lastWorkspacePath;
|
|
1160
|
+
if (projectKeyForCwd(recCwd) === project) live.push({ key: k, instanceKey: k, cwd: recCwd });
|
|
1161
|
+
}
|
|
1162
|
+
return live;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/** Exported read-only view of _liveInstancesForProject for the /api/resume-session route's
|
|
1166
|
+
* multi-instance ambiguity guard (mirror of killPtyFor/attachPtyFor). */
|
|
1167
|
+
export function liveInstancesForProject(project) {
|
|
1168
|
+
return _liveInstancesForProject(project);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Record the claude session uuid a PTY's conversation resolved to, so chat
|
|
1173
|
+
* sends can be routed by sessionId (2026-10-05). Called by the v2 writer once
|
|
1174
|
+
* a request's metadata yields a sid. Replaces any prior sid mapping for the
|
|
1175
|
+
* record (a /clear or -c moves the PTY to a new session).
|
|
1176
|
+
*/
|
|
1177
|
+
export function setPtySessionId(project, sessionId) {
|
|
1178
|
+
if (typeof sessionId !== 'string' || !sessionId) return false;
|
|
1179
|
+
const key = _resolveKey({ project });
|
|
1180
|
+
if (!key) return false;
|
|
1181
|
+
const s = ptys.get(key);
|
|
1182
|
+
if (!s) return false;
|
|
1183
|
+
if (s.sessionId && s.sessionId !== sessionId) sidToKey.delete(s.sessionId);
|
|
1184
|
+
s.sessionId = sessionId;
|
|
1185
|
+
sidToKey.set(sessionId, key);
|
|
1186
|
+
return true;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Record the claude session uuid for a PTY addressed by its exact instanceKey
|
|
1191
|
+
* (2026-10-06, multi-instance). Unlike `setPtySessionId` (basename `project`, which cannot
|
|
1192
|
+
* tell two same-cwd instances apart), this pins the sid to THIS process's record via the
|
|
1193
|
+
* self-reported `x-ccv-instance` header. The instanceKey is validated before use as a Map
|
|
1194
|
+
* key (defense-in-depth, mirroring the `_resumeProject` sanitize in interceptor.js). On a
|
|
1195
|
+
* /clear or -c the record moves to a new sid — drop the stale sid index entry first
|
|
1196
|
+
* (mirrors setPtySessionId).
|
|
1197
|
+
*/
|
|
1198
|
+
export function setPtySessionIdForInstance(instanceKey, sessionId) {
|
|
1199
|
+
if (typeof sessionId !== 'string' || !sessionId) return false;
|
|
1200
|
+
if (typeof instanceKey !== 'string' || !/^ccv-[0-9a-f]+$/.test(instanceKey)) return false;
|
|
1201
|
+
const s = ptys.get(instanceKey);
|
|
1202
|
+
if (!s) return false;
|
|
1203
|
+
if (s.sessionId && s.sessionId !== sessionId) sidToKey.delete(s.sessionId);
|
|
1204
|
+
s.sessionId = sessionId;
|
|
1205
|
+
sidToKey.set(sessionId, instanceKey);
|
|
1206
|
+
return true;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Resolve the PTY-map key for a chat send anchor. Order:
|
|
1211
|
+
* 1. project + sessionId TOGETHER: sessionId is trusted only when the record
|
|
1212
|
+
* that `project` resolves to actually owns that sid — during a view switch
|
|
1213
|
+
* the frontend may briefly carry the DEPARTING project's sid alongside the
|
|
1214
|
+
* NEW project, and sid-first routing would send the message back to the old
|
|
1215
|
+
* project's PTY (the exact bleed this fixes). A sid that contradicts the
|
|
1216
|
+
* project is dropped to the project route.
|
|
1217
|
+
* 2. sessionId alone (no project): exact conversation → its PTY.
|
|
1218
|
+
* 3. project alone: basename reverse lookup (the multi-project view anchor).
|
|
1219
|
+
* 4. the active record (legacy no-anchor callers).
|
|
1220
|
+
* Returns null only when nothing exists at all.
|
|
1221
|
+
*
|
|
1222
|
+
* Pure resolution — does NOT move `activePtyKey`. A chat send is a targeted
|
|
1223
|
+
* write to one conversation's PTY; re-anchoring the global active pointer here
|
|
1224
|
+
* would make resize/scrollback/getPtyState follow whichever project was last
|
|
1225
|
+
* SENT to, a new cross-project bleed (review P1, 2026-10-05).
|
|
1226
|
+
*/
|
|
1227
|
+
function _resolveKeyByAnchor({ sessionId, project, instanceKey } = {}) {
|
|
1228
|
+
// Multi-instance: an explicit instanceKey is the strongest anchor — it pins THIS exact
|
|
1229
|
+
// process (disambiguating two same-cwd instances that basename routing cannot).
|
|
1230
|
+
if (typeof instanceKey === 'string' && /^ccv-[0-9a-f]+$/.test(instanceKey)) {
|
|
1231
|
+
const rec = ptys.get(instanceKey);
|
|
1232
|
+
if (rec && rec.ptyProcess) return instanceKey;
|
|
1233
|
+
}
|
|
1234
|
+
const projKey = (typeof project === 'string' && project) ? _resolveKey({ project }) : null;
|
|
1235
|
+
if (typeof sessionId === 'string' && sessionId) {
|
|
1236
|
+
const key = sidToKey.get(sessionId);
|
|
1237
|
+
// Only honor the sid route when the record is still LIVE (has a process). A record killed
|
|
1238
|
+
// via killPtyFor keeps its sid mapping (only respawn/overwrite/reap clear it), so without
|
|
1239
|
+
// the liveness check an anchored send to a just-closed project would resolve to the dead
|
|
1240
|
+
// record and silently drop the message (writeToPtyFor finds no ptyProcess and returns
|
|
1241
|
+
// false with no fallback).
|
|
1242
|
+
const rec = key ? ptys.get(key) : null;
|
|
1243
|
+
if (rec && rec.ptyProcess) {
|
|
1244
|
+
// Consistency check: when the anchor also names a project, the sid must
|
|
1245
|
+
// belong to that project's record — else it is a stale cross-view sid.
|
|
1246
|
+
if (!projKey || projKey === key) return key;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
if (projKey) return projKey;
|
|
1250
|
+
return activePtyKey != null && ptys.has(activePtyKey) ? activePtyKey : null;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** writeToPty variant routed by an explicit anchor instead of the global active pointer. */
|
|
1254
|
+
export function writeToPtyFor(data, { sessionId, project, instanceKey } = {}) {
|
|
1255
|
+
const key = _resolveKeyByAnchor({ sessionId, project, instanceKey });
|
|
1256
|
+
const s = key ? ptys.get(key) : null;
|
|
1257
|
+
if (s && s.ptyProcess) {
|
|
1258
|
+
s.ptyProcess.write(data);
|
|
1259
|
+
return true;
|
|
1260
|
+
}
|
|
1261
|
+
return false;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** writeToPtySequential variant routed by an explicit anchor. */
|
|
1265
|
+
export function writeToPtySequentialFor(chunks, onComplete, opts = {}, anchor = {}) {
|
|
1266
|
+
const key = _resolveKeyByAnchor(anchor);
|
|
1267
|
+
const s = key ? ptys.get(key) : null;
|
|
1268
|
+
if (!s || !s.ptyProcess || !chunks || chunks.length === 0) {
|
|
1269
|
+
if (onComplete) onComplete(false);
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
const timeoutMs = opts.timeoutMs || 4000;
|
|
1273
|
+
const settleMs = opts.settleMs || 150;
|
|
1274
|
+
let idx = 0;
|
|
1275
|
+
let dataListener = null;
|
|
1276
|
+
const cleanup = () => {
|
|
1277
|
+
if (dataListener) {
|
|
1278
|
+
dataListeners = dataListeners.filter(l => l !== dataListener);
|
|
1279
|
+
dataListener = null;
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
const sendNext = () => {
|
|
1283
|
+
if (idx >= chunks.length || !s.ptyProcess) {
|
|
1284
|
+
cleanup();
|
|
1285
|
+
if (onComplete) onComplete(idx >= chunks.length);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
const chunk = chunks[idx];
|
|
1289
|
+
idx++;
|
|
1290
|
+
if (typeof chunk !== 'string') {
|
|
1291
|
+
cleanup();
|
|
1292
|
+
if (onComplete) onComplete(false);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
try {
|
|
1296
|
+
s.ptyProcess.write(chunk);
|
|
1297
|
+
} catch (e) {
|
|
1298
|
+
cleanup();
|
|
1299
|
+
if (onComplete) onComplete(false);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const isToggleOrSubmit = chunk === ' ' || chunk === '\r'
|
|
1303
|
+
|| chunk === '\x1b[C' || chunk === '\x1b[A' || chunk === '\x1b[B';
|
|
1304
|
+
const isPasteEnd = chunk.endsWith('\x1b[201~');
|
|
1305
|
+
const delay = (isToggleOrSubmit || isPasteEnd) ? settleMs : 80;
|
|
1306
|
+
setTimeout(sendNext, delay);
|
|
1307
|
+
};
|
|
1308
|
+
sendNext();
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Attach the main view to a specific project's PTY (multi-PTY, 2026-10): moves
|
|
1313
|
+
* `activePtyKey` off "last spawned wins" onto the project the user is actually
|
|
1314
|
+
* VIEWING. Without this, every no-arg export (writeToPty / spawnShell /
|
|
1315
|
+
* getPtyState / getOutputBuffer) stays pinned to the most recently spawned
|
|
1316
|
+
* project — a parallel project launched via [+] shows the wrong project's
|
|
1317
|
+
* scrollback and keystrokes land in the other project's claude (the "terminal
|
|
1318
|
+
* never starts" bug).
|
|
1319
|
+
*
|
|
1320
|
+
* Resolution: exact `cwd` match on the record's stored cwd first; else a
|
|
1321
|
+
* `project` name reverse-lookup (record cwd → projectKeyForCwd, the same
|
|
1322
|
+
* mapping the live-processes route uses). Idempotent: re-attaching the
|
|
1323
|
+
* already-active key is a no-op; unknown targets change nothing so callers
|
|
1324
|
+
* can degrade silently. Never spawns/kills; the record's outputBuffer is
|
|
1325
|
+
* untouched — the WS layer replays it as the post-attach snapshot (the batch
|
|
1326
|
+
* channel must NOT re-deliver it: background output already lives in
|
|
1327
|
+
* outputBuffer, so a force-flush here would double-print it in the terminal).
|
|
1328
|
+
*/
|
|
1329
|
+
export function attachPtyFor({ cwd, project, instanceKey } = {}) {
|
|
1330
|
+
// Multi-instance forced disambiguation (2026-10-06): a project-name-only attach matching TWO
|
|
1331
|
+
// concurrently-live same-basename records is ambiguous — attaching "the first live one" would
|
|
1332
|
+
// pin the terminal to an arbitrary process. Refuse and surface the live candidates so the
|
|
1333
|
+
// caller re-issues with an instanceKey. Single-match / instance-keyed calls are unaffected.
|
|
1334
|
+
if (!instanceKey && typeof project === 'string' && project) {
|
|
1335
|
+
const live = _liveInstancesForProject(project);
|
|
1336
|
+
if (live.length > 1) return { ok: false, reason: 'ambiguous', candidates: live };
|
|
1337
|
+
}
|
|
1338
|
+
const key = _resolveKey({ cwd, project, instanceKey });
|
|
1339
|
+
if (!key) return { ok: false, reason: 'not-found' };
|
|
1340
|
+
const s = ptys.get(key);
|
|
1341
|
+
if (!s) return { ok: false, reason: 'not-found' };
|
|
1342
|
+
const switched = key !== activePtyKey;
|
|
1343
|
+
activePtyKey = key;
|
|
1344
|
+
return {
|
|
1345
|
+
ok: true,
|
|
1346
|
+
key,
|
|
1347
|
+
switched,
|
|
1348
|
+
running: !!s.ptyProcess,
|
|
1349
|
+
ptyKind: s.ptyKind || null,
|
|
1350
|
+
exitCode: s.lastExitCode,
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* parallel-project chips. Returns [{ key, instanceKey, cwd, ptyKind, pid, isActive }] for
|
|
1356
|
+
* records whose process is still running (ptyProcess != null); exited-but-
|
|
1357
|
+
* unreaped records are excluded. `key`/`instanceKey` are the opaque per-spawn instanceKey
|
|
1358
|
+
* the Map is keyed on; `cwd` is the record's real working dir (never the key). `isActive`
|
|
1359
|
+
* marks the PTY the main view is attached to. Read-only.
|
|
1360
|
+
*/
|
|
1361
|
+
export function listLivePtys() {
|
|
1362
|
+
const out = [];
|
|
1363
|
+
for (const [key, s] of ptys) {
|
|
1364
|
+
if (!s || !s.ptyProcess) continue;
|
|
1365
|
+
out.push({
|
|
1366
|
+
key,
|
|
1367
|
+
instanceKey: key,
|
|
1368
|
+
cwd: s.cwd || s.currentWorkspacePath || '',
|
|
1369
|
+
ptyKind: s.ptyKind || null,
|
|
1370
|
+
pid: s.ptyProcess.pid,
|
|
1371
|
+
isActive: key === activePtyKey,
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
return out;
|
|
837
1375
|
}
|
|
838
1376
|
|
|
839
1377
|
export function onPtyData(cb) {
|
|
840
1378
|
dataListeners.push(cb);
|
|
841
1379
|
return () => {
|
|
842
1380
|
dataListeners = dataListeners.filter(l => l !== cb);
|
|
1381
|
+
_maybeReap(); // a listener dropping off is a natural point to reclaim idle records
|
|
843
1382
|
};
|
|
844
1383
|
}
|
|
845
1384
|
|
|
@@ -847,38 +1386,130 @@ export function onPtyExit(cb) {
|
|
|
847
1386
|
exitListeners.push(cb);
|
|
848
1387
|
return () => {
|
|
849
1388
|
exitListeners = exitListeners.filter(l => l !== cb);
|
|
1389
|
+
_maybeReap();
|
|
850
1390
|
};
|
|
851
1391
|
}
|
|
852
1392
|
|
|
853
1393
|
export function getPtyPid() {
|
|
854
|
-
|
|
1394
|
+
const s = _active();
|
|
1395
|
+
return s && s.ptyProcess ? s.ptyProcess.pid : null;
|
|
855
1396
|
}
|
|
856
1397
|
|
|
857
1398
|
export function getPtyState() {
|
|
1399
|
+
const s = _active();
|
|
858
1400
|
return {
|
|
859
|
-
running: !!ptyProcess,
|
|
860
|
-
exitCode: lastExitCode,
|
|
1401
|
+
running: !!(s && s.ptyProcess),
|
|
1402
|
+
exitCode: s ? s.lastExitCode : null,
|
|
861
1403
|
};
|
|
862
1404
|
}
|
|
863
1405
|
|
|
864
1406
|
/** Kind of the active PTY: 'claude' | 'shell' | null. */
|
|
865
1407
|
export function getPtyKind() {
|
|
866
|
-
|
|
1408
|
+
const s = _active();
|
|
1409
|
+
return s ? s.ptyKind : null;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Kind of an ANCHOR-SPECIFIC PTY: 'claude' | 'shell' | null (2026-10-06, for /api/resume-session).
|
|
1414
|
+
* Unlike getPtyKind (active-scoped) and _resolveKeyByAnchor (which falls back to activePtyKey),
|
|
1415
|
+
* this resolves STRICTLY via _resolveKey — a provided-but-unresolved project/instanceKey returns
|
|
1416
|
+
* null rather than the active PTY's kind. The resume route depends on this to never inject
|
|
1417
|
+
* `/resume <uuid>` into the wrong (active) project's conversation when the anchor doesn't resolve.
|
|
1418
|
+
*/
|
|
1419
|
+
export function getPtyKindFor({ project, instanceKey } = {}) {
|
|
1420
|
+
const key = _resolveKey({ project, instanceKey });
|
|
1421
|
+
const s = key ? ptys.get(key) : null;
|
|
1422
|
+
return (s && s.ptyProcess) ? s.ptyKind : null;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* True iff `project` names a currently-LIVE ccv claude PTY (main interactive, ptyKind 'claude').
|
|
1427
|
+
* Used by interceptor.markSessionStart to decide whether a SessionStart hook from a non-bound
|
|
1428
|
+
* cwd belongs to a parallel project this server manages (and so its resume should re-bind the
|
|
1429
|
+
* writer keyed by that project) versus an unrelated process to ignore.
|
|
1430
|
+
*/
|
|
1431
|
+
export function isLiveClaudeProject(project) {
|
|
1432
|
+
if (typeof project !== 'string' || !project) return false;
|
|
1433
|
+
for (const [, s] of ptys) {
|
|
1434
|
+
if (!s || !s.ptyProcess || s.ptyKind !== 'claude') continue;
|
|
1435
|
+
const recCwd = s.cwd || s.currentWorkspacePath || s.lastWorkspacePath;
|
|
1436
|
+
if (projectKeyForCwd(recCwd) === project) return true;
|
|
1437
|
+
}
|
|
1438
|
+
return false;
|
|
867
1439
|
}
|
|
868
1440
|
|
|
869
1441
|
/** True iff the active Claude session was launched with --dangerously-skip-permissions. */
|
|
870
1442
|
export function getPtySkipPermissions() {
|
|
871
|
-
|
|
1443
|
+
const s = _active();
|
|
1444
|
+
return !!(s && s.ptyKind === 'claude' && s.ptySkipPermissions);
|
|
872
1445
|
}
|
|
873
1446
|
|
|
874
1447
|
export function getCurrentWorkspace() {
|
|
1448
|
+
const s = _active();
|
|
875
1449
|
return {
|
|
876
|
-
running: !!ptyProcess,
|
|
877
|
-
exitCode: lastExitCode,
|
|
878
|
-
cwd: currentWorkspacePath,
|
|
1450
|
+
running: !!(s && s.ptyProcess),
|
|
1451
|
+
exitCode: s ? s.lastExitCode : null,
|
|
1452
|
+
cwd: s ? s.currentWorkspacePath : null,
|
|
879
1453
|
};
|
|
880
1454
|
}
|
|
881
1455
|
|
|
882
1456
|
export function getOutputBuffer() {
|
|
883
|
-
|
|
1457
|
+
const s = _active();
|
|
1458
|
+
return s ? s.outputBuffer : '';
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* Launch-or-reattach for a workspace cwd (2026-10-06, multi-instance). Now that spawnClaude
|
|
1463
|
+
* mints a fresh instanceKey per call and no longer kills an existing same-cwd record, an
|
|
1464
|
+
* explicit re-launch of an ALREADY-LIVE cwd would silently start a duplicate process (the
|
|
1465
|
+
* old mutual-kill used to replace it). This gate restores the intended "re-open = reuse"
|
|
1466
|
+
* semantics for the launch path: if a claude is already running for `cwd`, attach the main
|
|
1467
|
+
* view to it and report `attached:true` WITHOUT spawning; otherwise spawn fresh.
|
|
1468
|
+
*
|
|
1469
|
+
* Deliberate multi-instance (a second concurrent claude in the same cwd) still goes through
|
|
1470
|
+
* the public `spawnClaude` — this helper is only the de-dup guard for the launch route.
|
|
1471
|
+
*
|
|
1472
|
+
* @returns {Promise<{ spawned:boolean, attached:boolean, key:string|null, running:boolean }>}
|
|
1473
|
+
*/
|
|
1474
|
+
export async function ensurePtyForCwd({ cwd, proxyPort, extraArgs = [], claudePath = null, isNpmVersion = false, serverPort = null, serverProtocol = 'http', internalToken = null } = {}) {
|
|
1475
|
+
if (typeof cwd !== 'string' || !cwd) return { spawned: false, attached: false, key: null, running: false };
|
|
1476
|
+
// Serialize concurrent launches for the SAME cwd (review P1 / TOCTOU): the live-scan below
|
|
1477
|
+
// + `spawnClaude` are two separate steps, so two simultaneous launches of one cwd could both
|
|
1478
|
+
// miss the scan and both mint a fresh instanceKey (a duplicate process). `_spawnInflight` is
|
|
1479
|
+
// keyed by instanceKey and cannot dedupe across distinct minted keys, so we hold a per-cwd
|
|
1480
|
+
// in-flight promise here — the second caller waits for the first, then re-scans and finds
|
|
1481
|
+
// the now-live record (attaching instead of spawning).
|
|
1482
|
+
while (_cwdLaunchInflight.has(cwd)) { try { await _cwdLaunchInflight.get(cwd); } catch { } }
|
|
1483
|
+
// Prefer an already-LIVE claude for this exact cwd (not a shell). Re-attaching rather than
|
|
1484
|
+
// re-spawning preserves the running session and avoids a duplicate process.
|
|
1485
|
+
for (const [k, s] of ptys) {
|
|
1486
|
+
if (s && s.cwd === cwd && s.ptyProcess && s.ptyKind === 'claude') {
|
|
1487
|
+
activePtyKey = k; // attach the main view to the live record
|
|
1488
|
+
return { spawned: false, attached: true, key: k, running: true };
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
const p = spawnClaude(proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
|
|
1492
|
+
_cwdLaunchInflight.set(cwd, p);
|
|
1493
|
+
try {
|
|
1494
|
+
await p;
|
|
1495
|
+
return { spawned: true, attached: false, key: activePtyKey, running: true };
|
|
1496
|
+
} finally {
|
|
1497
|
+
if (_cwdLaunchInflight.get(cwd) === p) _cwdLaunchInflight.delete(cwd);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// Test only: clear ALL multi-PTY state (the Map, the active pointer, and the global
|
|
1502
|
+
// listener arrays) so suites that spawn real/mock PTYs don't leak records between cases.
|
|
1503
|
+
// The old singleton model reset via killPty() nulling one record; with a Map that no longer
|
|
1504
|
+
// isolates tests, so they call this in beforeEach/afterEach instead.
|
|
1505
|
+
export function _resetForTests() {
|
|
1506
|
+
for (const s of ptys.values()) _killPtyRecord(s);
|
|
1507
|
+
ptys.clear();
|
|
1508
|
+
sidToKey.clear();
|
|
1509
|
+
activePtyKey = null;
|
|
1510
|
+
dataListeners = [];
|
|
1511
|
+
exitListeners = [];
|
|
1512
|
+
_spawnInflight.clear();
|
|
1513
|
+
_shellInflight = null;
|
|
1514
|
+
_cwdLaunchInflight.clear();
|
|
884
1515
|
}
|