cc-viewer 1.9.0 → 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/README.md +3 -1
- package/cli.js +33 -4
- package/concepts/ar/UltraPlan.md +1 -1
- package/concepts/da/UltraPlan.md +1 -1
- package/concepts/de/UltraPlan.md +1 -1
- package/concepts/en/UltraPlan.md +1 -1
- package/concepts/es/UltraPlan.md +1 -1
- package/concepts/fr/UltraPlan.md +1 -1
- package/concepts/it/UltraPlan.md +1 -1
- package/concepts/ja/UltraPlan.md +1 -1
- package/concepts/ko/UltraPlan.md +1 -1
- package/concepts/no/UltraPlan.md +1 -1
- package/concepts/pl/UltraPlan.md +1 -1
- package/concepts/pt-BR/UltraPlan.md +1 -1
- package/concepts/ru/UltraPlan.md +1 -1
- package/concepts/th/UltraPlan.md +1 -1
- package/concepts/tr/UltraPlan.md +1 -1
- package/concepts/uk/UltraPlan.md +1 -1
- package/concepts/zh/UltraPlan.md +1 -1
- package/concepts/zh-TW/UltraPlan.md +1 -1
- 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-3j8rS3IT.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 +192 -24
- package/server/lib/ask/ask-store.js +7 -3
- package/server/lib/async-file-lock.js +29 -5
- package/server/lib/auth.js +71 -54
- package/server/lib/chat-queue.js +67 -4
- package/server/lib/config-backup.js +8 -5
- package/server/lib/credential-access.js +233 -0
- package/server/lib/credential-migrate.js +147 -0
- package/server/lib/credential-store.js +120 -0
- package/server/lib/credential-vault.js +112 -0
- package/server/lib/file-access-policy.js +16 -0
- package/server/lib/git-diff.js +49 -20
- package/server/lib/im/im-config.js +81 -31
- package/server/lib/im-deny.js +22 -8
- package/server/lib/json-store.js +264 -0
- package/server/lib/log-watcher.js +9 -1
- package/server/lib/prefs-store.js +8 -33
- package/server/lib/same-origin.js +22 -0
- package/server/lib/session-pin-store.js +7 -14
- 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 +800 -163
- package/server/routes/ask-perm.js +21 -1
- package/server/routes/auth.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/im.js +15 -2
- package/server/routes/preferences.js +59 -46
- 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 +199 -25
- package/server/system-prompt-templates/presets/GLM-5.2.md +0 -1
- package/server/system-prompt-templates/presets/GLM-5.3.md +0 -1
- package/server/system-prompt-templates/presets/Qwen-3.md +0 -1
- package/server/system-prompt-templates/presets/deepseek-v4-flash.md +0 -1
- package/server/system-prompt-templates/presets/deepseek-v4-pro.md +0 -1
- package/server/system-prompt-templates/presets/kimi-k2.7-code.md +0 -1
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
- package/server/system-prompt-templates/systemPromptModel.md +0 -1
- package/server/workspace-registry.js +41 -35
- package/ultraAgents/code-expert.json +1 -1
- package/dist/assets/App-M61pqQEx.css +0 -1
- package/dist/assets/App-_HfxMJvv.js +0 -2
- package/dist/assets/Mobile-DaRLB7Gh.js +0 -1
- package/dist/assets/ProxyStatsModal-dlPIDlx-.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-CjbBUDGP.js +0 -2
- package/dist/assets/seqResourceLoaders-B_FSQh3U.css +0 -41
- package/dist/assets/seqResourceLoaders-hqLhsiGO.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,
|
|
@@ -353,13 +493,19 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
353
493
|
'Bash(git push:*)', 'Bash(npm publish:*)', 'Bash(ssh:*)', 'Bash(scp:*)',
|
|
354
494
|
`Read(${home}/.ssh/**)`, `Edit(${home}/.ssh/**)`, `Write(${home}/.ssh/**)`,
|
|
355
495
|
`Read(${home}/.aws/**)`, `Edit(${home}/.aws/**)`, `Write(${home}/.aws/**)`,
|
|
356
|
-
// File-precise: protect the deny mechanism itself (settings/hooks)
|
|
357
|
-
// credential store (preferences.json),
|
|
358
|
-
//
|
|
359
|
-
// writable.
|
|
496
|
+
// File-precise: protect the deny mechanism itself (settings/hooks), the IM
|
|
497
|
+
// credential store (preferences.json), and the credential vault (credentials.json +
|
|
498
|
+
// master.key), but do not block all of ~/.claude — the worker's working directory
|
|
499
|
+
// sits under ~/.claude/cc-viewer/IM_<id>/ and must stay writable.
|
|
360
500
|
`Edit(${home}/.claude/settings.json)`, `Write(${home}/.claude/settings.json)`,
|
|
361
501
|
`Edit(${home}/.claude/settings.local.json)`, `Write(${home}/.claude/settings.local.json)`,
|
|
362
502
|
`Edit(${home}/.claude/cc-viewer/preferences.json)`, `Write(${home}/.claude/cc-viewer/preferences.json)`,
|
|
503
|
+
`Read(${home}/.claude/cc-viewer/credentials.json)`, `Edit(${home}/.claude/cc-viewer/credentials.json)`, `Write(${home}/.claude/cc-viewer/credentials.json)`,
|
|
504
|
+
`Read(${home}/.claude/cc-viewer/master.key)`, `Edit(${home}/.claude/cc-viewer/master.key)`, `Write(${home}/.claude/cc-viewer/master.key)`,
|
|
505
|
+
// Backup copies of the vault (cc-viewer-config-backups/<ts>/) hold the same decryption
|
|
506
|
+
// kit (credentials.json + master.key) — deny the whole subtree or an IM worker could read
|
|
507
|
+
// a rolled backup and decrypt every secret.
|
|
508
|
+
`Read(${home}/.claude/cc-viewer-config-backups/**)`, `Edit(${home}/.claude/cc-viewer-config-backups/**)`, `Write(${home}/.claude/cc-viewer-config-backups/**)`,
|
|
363
509
|
],
|
|
364
510
|
};
|
|
365
511
|
}
|
|
@@ -454,49 +600,60 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
454
600
|
args = [claudePath, '--settings', settingsJson, ...launchArgs];
|
|
455
601
|
}
|
|
456
602
|
|
|
457
|
-
lastExitCode = null;
|
|
458
|
-
outputBuffer = '';
|
|
459
|
-
currentWorkspacePath = cwd || process.cwd();
|
|
460
|
-
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; }
|
|
461
614
|
// Boot-window anchor for the injection fallback tiers below (same clock as the
|
|
462
615
|
// comparison — _now(), never Date.now(), so tests can steer both ends together).
|
|
463
616
|
const spawnedAt = _now();
|
|
464
617
|
|
|
465
|
-
ptyProcess = pty.spawn(command, args, {
|
|
618
|
+
s.ptyProcess = pty.spawn(command, args, {
|
|
466
619
|
name: 'xterm-256color',
|
|
467
|
-
cols: lastPtyCols,
|
|
468
|
-
rows: lastPtyRows,
|
|
469
|
-
cwd: currentWorkspacePath,
|
|
620
|
+
cols: s.lastPtyCols,
|
|
621
|
+
rows: s.lastPtyRows,
|
|
622
|
+
cwd: s.currentWorkspacePath,
|
|
470
623
|
env,
|
|
471
624
|
});
|
|
472
|
-
|
|
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';
|
|
473
630
|
// --allow-dangerously-skip-permissions only enables a later toggle, so it must NOT count.
|
|
474
|
-
ptySkipPermissions = extraArgs.includes('--dangerously-skip-permissions');
|
|
631
|
+
s.ptySkipPermissions = extraArgs.includes('--dangerously-skip-permissions');
|
|
475
632
|
|
|
476
633
|
// PTY event handlers must be registered immediately after spawn (PR#128): if the child
|
|
477
634
|
// exits before onExit is mounted (missing binary / instant crash / rejected injection
|
|
478
635
|
// flag), the exit event is lost — after the handle is released the event loop may drain.
|
|
479
636
|
// The injection notice is moved to after registration.
|
|
480
|
-
ptyProcess.onData((data) => {
|
|
481
|
-
outputBuffer += data;
|
|
482
|
-
if (outputBuffer.length > MAX_BUFFER) {
|
|
483
|
-
const rawStart = outputBuffer.length - BUFFER_TRIM_TO;
|
|
484
|
-
const safeStart = findSafeSliceStart(outputBuffer, rawStart);
|
|
485
|
-
outputBuffer = outputBuffer.slice(safeStart);
|
|
486
|
-
}
|
|
487
|
-
batchBuffer += data;
|
|
488
|
-
if (!batchScheduled) {
|
|
489
|
-
batchScheduled = true;
|
|
490
|
-
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));
|
|
491
648
|
}
|
|
492
649
|
});
|
|
493
650
|
|
|
494
|
-
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
495
|
-
flushBatch(true);
|
|
496
|
-
lastExitCode = exitCode;
|
|
497
|
-
ptyProcess = null;
|
|
498
|
-
ptyKind = null;
|
|
499
|
-
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;
|
|
500
657
|
// Boot-period death: an exit within the window after spawn. Any exit outside the window
|
|
501
658
|
// is never part of the "injection dragged the boot into a crash" fallback scope. A
|
|
502
659
|
// single _now() read (review): tiers 1/2 share the same instant, so the injected fake
|
|
@@ -509,11 +666,13 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
509
666
|
// death is transparent to consumers. Once the new pty starts normally it reports its own
|
|
510
667
|
// state/exit. This keeps the frontend from seeing a spurious exit event.
|
|
511
668
|
const hasContinue = extraArgs.includes('-c') || extraArgs.includes('--continue');
|
|
512
|
-
if (hasContinue && exitCode !== 0 && outputBuffer.includes('No conversation found')) {
|
|
669
|
+
if (hasContinue && exitCode !== 0 && s.outputBuffer.includes('No conversation found')) {
|
|
513
670
|
console.error('[CC Viewer] -c failed (no conversation), retrying without -c');
|
|
514
671
|
const retryArgs = extraArgs.filter(a => a !== '-c' && a !== '--continue');
|
|
515
672
|
_suppressNextSpawnNotice = true;
|
|
516
|
-
|
|
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);
|
|
517
676
|
return;
|
|
518
677
|
}
|
|
519
678
|
|
|
@@ -527,12 +686,12 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
527
686
|
const weInjectedFlag = shouldInjectThinkingDisplay
|
|
528
687
|
&& !extraArgs.some(a => a === '--thinking-display' || (typeof a === 'string' && a.startsWith('--thinking-display=')));
|
|
529
688
|
const flagRejected = weInjectedFlag && exitCode !== 0
|
|
530
|
-
&& /unknown option ['"]--thinking-display/i.test(outputBuffer);
|
|
689
|
+
&& /unknown option ['"]--thinking-display/i.test(s.outputBuffer);
|
|
531
690
|
if (flagRejected) {
|
|
532
691
|
console.error('[CC Viewer] claude rejected --thinking-display, marking as unsupported and retrying without flag');
|
|
533
692
|
_thinkingDisplayRejectedPaths.add(claudePath);
|
|
534
693
|
_suppressNextSpawnNotice = true;
|
|
535
|
-
|
|
694
|
+
_respawnInto(key, proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
|
|
536
695
|
return;
|
|
537
696
|
}
|
|
538
697
|
|
|
@@ -563,7 +722,7 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
563
722
|
// root cause is something else (e.g. an expired API key), the first error already
|
|
564
723
|
// streamed into the terminal scrollback without loss, and the second death broadcasts
|
|
565
724
|
// as usual.
|
|
566
|
-
const unknownSysFileFlag = /unknown option ['"]--(append-)?system-prompt-file/i.test(outputBuffer);
|
|
725
|
+
const unknownSysFileFlag = /unknown option ['"]--(append-)?system-prompt-file/i.test(s.outputBuffer);
|
|
567
726
|
const injectedBootCrash = !insideLogDir && !signal && diedInBootWindow;
|
|
568
727
|
const sysFileRejected = sysPrompt.loaded.length > 0 && exitCode !== 0
|
|
569
728
|
&& (unknownSysFileFlag || injectedBootCrash);
|
|
@@ -578,8 +737,8 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
578
737
|
_suppressNextSpawnNotice = true;
|
|
579
738
|
// Wording leaves room (review): a boot-period death may be unrelated to the injection
|
|
580
739
|
// (API key / network etc.); do not assert causation.
|
|
581
|
-
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(', ')}`);
|
|
582
|
-
|
|
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);
|
|
583
742
|
return;
|
|
584
743
|
}
|
|
585
744
|
|
|
@@ -592,13 +751,21 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
592
751
|
// !insideLogDir: an IM worker's pty data stream may be relayed via the bridge, so the
|
|
593
752
|
// diagnostic line must not leak into the IM session (review).
|
|
594
753
|
if (sysPrompt.loaded.length > 0 && exitCode === 0 && diedInBootWindow && !insideLogDir) {
|
|
595
|
-
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.`);
|
|
596
755
|
}
|
|
597
756
|
|
|
598
|
-
// Keep lastWorkspacePath (do not clear) for respawn
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
+
}
|
|
602
769
|
}
|
|
603
770
|
});
|
|
604
771
|
|
|
@@ -608,7 +775,7 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
608
775
|
// the child exits before the handlers are mounted.
|
|
609
776
|
if (sysPrompt.loaded.length && !sysPrompt.pinned && !_suppressNextSpawnNotice) {
|
|
610
777
|
const modelSuffix = sysPrompt.model ? ` (model match: ${sysPrompt.model})` : '';
|
|
611
|
-
emitSpawnNotice(`[CC Viewer] loaded ${sysPrompt.loaded.join(', ')} as system prompt${modelSuffix}`);
|
|
778
|
+
emitSpawnNotice(s, `[CC Viewer] loaded ${sysPrompt.loaded.join(', ')} as system prompt${modelSuffix}`);
|
|
612
779
|
}
|
|
613
780
|
// Pin visibility: a snapshot hit re-injects verbatim; a no-record resume (F2)
|
|
614
781
|
// injects nothing — surfaced ONLY when injection is configured right now
|
|
@@ -616,9 +783,9 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
616
783
|
// Mutually exclusive with the loaded notice above (the pinned path never prints it).
|
|
617
784
|
if (sysPrompt.pinned && !_suppressNextSpawnNotice) {
|
|
618
785
|
if (sysPrompt.noRecord) {
|
|
619
|
-
if (sysPrompt.noRecordNotice) emitSpawnNotice(`[CC Viewer] ${t('cli.systemPromptResumeNoSnapshot')}`);
|
|
786
|
+
if (sysPrompt.noRecordNotice) emitSpawnNotice(s, `[CC Viewer] ${t('cli.systemPromptResumeNoSnapshot')}`);
|
|
620
787
|
} else if (sysPrompt.loaded.length) {
|
|
621
|
-
emitSpawnNotice(`[CC Viewer] ${t('cli.systemPromptPinned', { files: sysPrompt.loaded.join(', ') })}`);
|
|
788
|
+
emitSpawnNotice(s, `[CC Viewer] ${t('cli.systemPromptPinned', { files: sysPrompt.loaded.join(', ') })}`);
|
|
622
789
|
}
|
|
623
790
|
}
|
|
624
791
|
// Settings-merge failures surface via emitSpawnNotice too: console.warn only reaches
|
|
@@ -626,16 +793,18 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
626
793
|
// above stays English for greppable server logs). Must be emitted after spawn — the
|
|
627
794
|
// outputBuffer reset right before pty.spawn would swallow an earlier write.
|
|
628
795
|
if (settingsMerge.warningDetail && !_suppressNextSpawnNotice) {
|
|
629
|
-
emitSpawnNotice(`[CC Viewer] ${t('cli.settingsMergeFailed', settingsMerge.warningDetail)}`);
|
|
796
|
+
emitSpawnNotice(s, `[CC Viewer] ${t('cli.settingsMergeFailed', settingsMerge.warningDetail)}`);
|
|
630
797
|
}
|
|
631
798
|
_suppressNextSpawnNotice = false;
|
|
799
|
+
_maybeReap();
|
|
632
800
|
|
|
633
|
-
return ptyProcess;
|
|
801
|
+
return s.ptyProcess;
|
|
634
802
|
}
|
|
635
803
|
|
|
636
804
|
export function writeToPty(data) {
|
|
637
|
-
|
|
638
|
-
|
|
805
|
+
const s = _active();
|
|
806
|
+
if (s && s.ptyProcess) {
|
|
807
|
+
s.ptyProcess.write(data);
|
|
639
808
|
return true;
|
|
640
809
|
}
|
|
641
810
|
return false;
|
|
@@ -652,8 +821,9 @@ export function writeToPty(data) {
|
|
|
652
821
|
export function writeToPtySequential(chunks, onComplete, opts = {}) {
|
|
653
822
|
const timeoutMs = opts.timeoutMs || 4000;
|
|
654
823
|
const settleMs = opts.settleMs || 150;
|
|
824
|
+
const s = _active();
|
|
655
825
|
|
|
656
|
-
if (!ptyProcess || !chunks || chunks.length === 0) {
|
|
826
|
+
if (!s || !s.ptyProcess || !chunks || chunks.length === 0) {
|
|
657
827
|
if (onComplete) onComplete(false);
|
|
658
828
|
return;
|
|
659
829
|
}
|
|
@@ -669,7 +839,7 @@ export function writeToPtySequential(chunks, onComplete, opts = {}) {
|
|
|
669
839
|
};
|
|
670
840
|
|
|
671
841
|
const sendNext = () => {
|
|
672
|
-
if (idx >= chunks.length || !ptyProcess) {
|
|
842
|
+
if (idx >= chunks.length || !s.ptyProcess) {
|
|
673
843
|
cleanup();
|
|
674
844
|
// Report success only if every chunk was sent. A PTY that died mid-sequence (idx <
|
|
675
845
|
// length) is a partial/failed injection — callers (e.g. the DingTalk bridge) must learn
|
|
@@ -692,7 +862,7 @@ export function writeToPtySequential(chunks, onComplete, opts = {}) {
|
|
|
692
862
|
return;
|
|
693
863
|
}
|
|
694
864
|
try {
|
|
695
|
-
ptyProcess.write(chunk);
|
|
865
|
+
s.ptyProcess.write(chunk);
|
|
696
866
|
} catch (e) {
|
|
697
867
|
cleanup();
|
|
698
868
|
if (onComplete) onComplete(false);
|
|
@@ -715,16 +885,43 @@ export function writeToPtySequential(chunks, onComplete, opts = {}) {
|
|
|
715
885
|
* After the process exits, auto-spawn an interactive shell so the terminal becomes usable
|
|
716
886
|
* again. Returns true if spawned successfully, false if unnecessary or failed.
|
|
717
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
|
+
|
|
718
902
|
export async function spawnShell() {
|
|
719
|
-
|
|
720
|
-
if (
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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; }
|
|
724
919
|
}
|
|
725
920
|
|
|
726
|
-
async function _spawnShellImpl() {
|
|
727
|
-
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();
|
|
728
925
|
|
|
729
926
|
const pty = await getPty();
|
|
730
927
|
|
|
@@ -732,8 +929,12 @@ async function _spawnShellImpl() {
|
|
|
732
929
|
|
|
733
930
|
const shell = process.env.SHELL || (process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh');
|
|
734
931
|
|
|
735
|
-
lastExitCode = null;
|
|
736
|
-
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;
|
|
737
938
|
|
|
738
939
|
// Clean env: remove cc-viewer specific vars so child shells don't inherit them
|
|
739
940
|
// (prevents CCVIEWER_PORT/CCVIEWER_PROTOCOL leaking to non-cc-viewer Claude instances;
|
|
@@ -752,39 +953,44 @@ async function _spawnShellImpl() {
|
|
|
752
953
|
applyClaudeAltScreenPref(shellEnv);
|
|
753
954
|
const shellSpawn = prepareEmbeddedShellSpawn(shell, shellEnv);
|
|
754
955
|
|
|
755
|
-
ptyProcess = pty.spawn(shellSpawn.command, shellSpawn.args, {
|
|
956
|
+
s.ptyProcess = pty.spawn(shellSpawn.command, shellSpawn.args, {
|
|
756
957
|
name: 'xterm-256color',
|
|
757
|
-
cols: lastPtyCols,
|
|
758
|
-
rows: lastPtyRows,
|
|
958
|
+
cols: s.lastPtyCols,
|
|
959
|
+
rows: s.lastPtyRows,
|
|
759
960
|
cwd,
|
|
760
961
|
env: shellSpawn.env,
|
|
761
962
|
});
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const
|
|
770
|
-
|
|
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);
|
|
771
973
|
}
|
|
772
|
-
batchBuffer += data;
|
|
773
|
-
if (!batchScheduled) {
|
|
774
|
-
batchScheduled = true;
|
|
775
|
-
setImmediate(flushBatch);
|
|
974
|
+
s.batchBuffer += data;
|
|
975
|
+
if (!s.batchScheduled) {
|
|
976
|
+
s.batchScheduled = true;
|
|
977
|
+
setImmediate(() => flushBatch(s));
|
|
776
978
|
}
|
|
777
979
|
});
|
|
778
980
|
|
|
779
|
-
ptyProcess.onExit(({ exitCode }) => {
|
|
780
|
-
flushBatch(true);
|
|
781
|
-
lastExitCode = exitCode;
|
|
782
|
-
ptyProcess = null;
|
|
783
|
-
ptyKind = null;
|
|
784
|
-
ptySkipPermissions = false;
|
|
785
|
-
currentWorkspacePath = null;
|
|
786
|
-
|
|
787
|
-
|
|
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
|
+
}
|
|
788
994
|
}
|
|
789
995
|
});
|
|
790
996
|
|
|
@@ -803,37 +1009,376 @@ function _clampDim(v, min, max, fallback) {
|
|
|
803
1009
|
}
|
|
804
1010
|
|
|
805
1011
|
export function resizePty(cols, rows) {
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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 { }
|
|
810
1037
|
}
|
|
1038
|
+
s.ptyProcess = null;
|
|
1039
|
+
s.ptyKind = null;
|
|
1040
|
+
s.ptySkipPermissions = false;
|
|
811
1041
|
}
|
|
812
1042
|
|
|
813
1043
|
export function killPty() {
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
|
826
1141
|
}
|
|
827
|
-
|
|
828
|
-
ptyKind = null;
|
|
829
|
-
ptySkipPermissions = false;
|
|
1142
|
+
if (!key) key = firstMatch;
|
|
830
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;
|
|
831
1375
|
}
|
|
832
1376
|
|
|
833
1377
|
export function onPtyData(cb) {
|
|
834
1378
|
dataListeners.push(cb);
|
|
835
1379
|
return () => {
|
|
836
1380
|
dataListeners = dataListeners.filter(l => l !== cb);
|
|
1381
|
+
_maybeReap(); // a listener dropping off is a natural point to reclaim idle records
|
|
837
1382
|
};
|
|
838
1383
|
}
|
|
839
1384
|
|
|
@@ -841,38 +1386,130 @@ export function onPtyExit(cb) {
|
|
|
841
1386
|
exitListeners.push(cb);
|
|
842
1387
|
return () => {
|
|
843
1388
|
exitListeners = exitListeners.filter(l => l !== cb);
|
|
1389
|
+
_maybeReap();
|
|
844
1390
|
};
|
|
845
1391
|
}
|
|
846
1392
|
|
|
847
1393
|
export function getPtyPid() {
|
|
848
|
-
|
|
1394
|
+
const s = _active();
|
|
1395
|
+
return s && s.ptyProcess ? s.ptyProcess.pid : null;
|
|
849
1396
|
}
|
|
850
1397
|
|
|
851
1398
|
export function getPtyState() {
|
|
1399
|
+
const s = _active();
|
|
852
1400
|
return {
|
|
853
|
-
running: !!ptyProcess,
|
|
854
|
-
exitCode: lastExitCode,
|
|
1401
|
+
running: !!(s && s.ptyProcess),
|
|
1402
|
+
exitCode: s ? s.lastExitCode : null,
|
|
855
1403
|
};
|
|
856
1404
|
}
|
|
857
1405
|
|
|
858
1406
|
/** Kind of the active PTY: 'claude' | 'shell' | null. */
|
|
859
1407
|
export function getPtyKind() {
|
|
860
|
-
|
|
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;
|
|
861
1439
|
}
|
|
862
1440
|
|
|
863
1441
|
/** True iff the active Claude session was launched with --dangerously-skip-permissions. */
|
|
864
1442
|
export function getPtySkipPermissions() {
|
|
865
|
-
|
|
1443
|
+
const s = _active();
|
|
1444
|
+
return !!(s && s.ptyKind === 'claude' && s.ptySkipPermissions);
|
|
866
1445
|
}
|
|
867
1446
|
|
|
868
1447
|
export function getCurrentWorkspace() {
|
|
1448
|
+
const s = _active();
|
|
869
1449
|
return {
|
|
870
|
-
running: !!ptyProcess,
|
|
871
|
-
exitCode: lastExitCode,
|
|
872
|
-
cwd: currentWorkspacePath,
|
|
1450
|
+
running: !!(s && s.ptyProcess),
|
|
1451
|
+
exitCode: s ? s.lastExitCode : null,
|
|
1452
|
+
cwd: s ? s.currentWorkspacePath : null,
|
|
873
1453
|
};
|
|
874
1454
|
}
|
|
875
1455
|
|
|
876
1456
|
export function getOutputBuffer() {
|
|
877
|
-
|
|
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();
|
|
878
1515
|
}
|