cc-viewer 1.8.20 → 1.8.21

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.
Files changed (114) hide show
  1. package/cli.js +33 -4
  2. package/dist/assets/App-BmZyzd9Q.js +2 -0
  3. package/dist/assets/App-DOZI5HME.css +1 -0
  4. package/dist/assets/{MdxEditorPanel-Dsnv2wIw.js → MdxEditorPanel-BHvoYOF-.js} +1 -1
  5. package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
  6. package/dist/assets/Mobile-C10tOIQo.js +1 -0
  7. package/dist/assets/ProxyStatsModal-Do4qrHT7.js +1 -0
  8. package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
  9. package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
  13. package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
  14. package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
  24. package/dist/assets/clone-CFj55h49.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
  35. package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
  36. package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
  37. package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
  38. package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
  39. package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
  40. package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
  41. package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
  42. package/dist/assets/index-DyVKoGOX.js +2 -0
  43. package/dist/assets/{index-Nvk8r3tO.css → index-Dz36p_bA.css} +1 -1
  44. package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
  45. package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
  46. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
  47. package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
  48. package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
  49. package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
  50. package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
  51. package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
  52. package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
  53. package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
  54. package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
  55. package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
  56. package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
  57. package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
  58. package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
  59. package/dist/assets/seqResourceLoaders-B-wmkeam.js +2 -0
  60. package/dist/assets/seqResourceLoaders-BdWMUSrC.css +41 -0
  61. package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
  62. package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
  63. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
  64. package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
  65. package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
  66. package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
  67. package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
  68. package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
  69. package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
  70. package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
  71. package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
  72. package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
  73. package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
  74. package/dist/index.html +5 -5
  75. package/package.json +1 -1
  76. package/server/interceptor.js +119 -13
  77. package/server/lib/chat-queue.js +67 -4
  78. package/server/lib/same-origin.js +22 -0
  79. package/server/lib/v2/convert.js +1 -1
  80. package/server/lib/v2/layout.js +28 -3
  81. package/server/lib/v2/live-feed.js +41 -4
  82. package/server/lib/v2/resume-list.js +150 -0
  83. package/server/lib/v2/session-select.js +63 -14
  84. package/server/lib/v2/v2-writer.js +172 -34
  85. package/server/lib/v2/view-router.js +83 -0
  86. package/server/lib/view-root.js +209 -0
  87. package/server/pty-manager.js +790 -159
  88. package/server/routes/ask-perm.js +21 -1
  89. package/server/routes/events.js +123 -7
  90. package/server/routes/expert.js +46 -14
  91. package/server/routes/files-content.js +72 -7
  92. package/server/routes/files-fs.js +28 -3
  93. package/server/routes/git.js +31 -5
  94. package/server/routes/preferences.js +1 -16
  95. package/server/routes/project-meta.js +32 -3
  96. package/server/routes/proxy-stats.js +33 -5
  97. package/server/routes/resume.js +302 -0
  98. package/server/routes/search.js +23 -1
  99. package/server/routes/skills.js +18 -2
  100. package/server/routes/v2.js +6 -1
  101. package/server/routes/workspaces.js +10 -3
  102. package/server/server.js +191 -25
  103. package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
  104. package/server/workspace-registry.js +15 -1
  105. package/dist/assets/App-CesjoFSv.js +0 -2
  106. package/dist/assets/App-M61pqQEx.css +0 -1
  107. package/dist/assets/Mobile-bvGZ392d.js +0 -1
  108. package/dist/assets/ProxyStatsModal-HFk51cI8.js +0 -1
  109. package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
  110. package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
  111. package/dist/assets/clone-B0_ghF3F.js +0 -1
  112. package/dist/assets/index-DanpP6qP.js +0 -2
  113. package/dist/assets/seqResourceLoaders-BumAjQDW.css +0 -41
  114. package/dist/assets/seqResourceLoaders-ByHQMNIi.js +0 -2
@@ -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
- let ptyProcess = null;
23
- // Kind of the current PTY: 'claude' (real Claude Code session), 'shell' (fallback bare
24
- // shell auto-spawned on input), or null (none). The DingTalk bridge uses this to refuse
25
- // injecting into a bare shell — typing a prompt into a shell would execute it as a command.
26
- let ptyKind = null;
27
- // Whether the current Claude session was launched with --dangerously-skip-permissions
28
- // (cli.js canonicalizes --d/--ad before spawn). Surfaced for the bridge's RCE warning.
29
- let ptySkipPermissions = false;
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
- let lastExitCode = null;
33
- let outputBuffer = '';
34
- let currentWorkspacePath = null;
35
- let lastWorkspacePath = null; // kept after process exit, used for respawn shell
36
- let lastPtyCols = 120;
37
- let lastPtyRows = 30;
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
- function flushBatch(force = false) {
114
- batchScheduled = false;
115
- if (!batchBuffer) return;
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 snapshot
134
- // (server.js's data-resync reads getOutputBuffer), then broadcast live to the current
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
- // Wait for any in-flight spawn to finish before kill+spawn, avoiding a double-spawn /
247
- // cross-talk with spawnShell (self-serializing). while rather than if: with ≥3 concurrent
248
- // spawns, after A finishes B sets a new inflight=pB, and a single if's C would not re-check
249
- // pB before kill+spawn, letting implB/implC double-spawn — loop until there is genuinely
250
- // no inflight before proceeding.
251
- while (_spawnInflight) { try { await _spawnInflight; } catch { } }
252
- if (ptyProcess) {
253
- killPty();
254
- }
255
- const p = _spawnClaudeImpl(proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
256
- _spawnInflight = p;
257
- try { return await p; } finally { if (_spawnInflight === p) _spawnInflight = null; }
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
- async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = null, isNpmVersion = false, serverPort = null, serverProtocol = 'http', internalToken = null) {
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, try to find it automatically
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
- claudePath = resolveNativePath();
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
- ptyKind = 'claude';
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
- spawnClaude(proxyPort, cwd, retryArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
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
- spawnClaude(proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
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
- spawnClaude(proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
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
- currentWorkspacePath = null;
606
- for (const cb of exitListeners) {
607
- try { cb(exitCode); } catch { }
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
- if (ptyProcess) {
644
- ptyProcess.write(data);
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
- if (ptyProcess) return false; // a process is already running
726
- if (_spawnInflight) return _spawnInflight; // reuse the in-flight spawn to avoid double
727
- const p = _spawnShellImpl();
728
- _spawnInflight = p;
729
- try { return await p; } finally { if (_spawnInflight === p) _spawnInflight = null; }
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 cwd = lastWorkspacePath || process.cwd();
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
- ptyKind = 'shell';
769
- ptySkipPermissions = false;
770
-
771
- ptyProcess.onData((data) => {
772
- outputBuffer += data;
773
- if (outputBuffer.length > MAX_BUFFER) {
774
- const rawStart = outputBuffer.length - BUFFER_TRIM_TO;
775
- const safeStart = findSafeSliceStart(outputBuffer, rawStart);
776
- outputBuffer = outputBuffer.slice(safeStart);
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
- for (const cb of exitListeners) {
793
- try { cb(exitCode); } catch { }
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
- lastPtyCols = _clampDim(cols, PTY_COLS_MIN, PTY_COLS_MAX, lastPtyCols);
813
- lastPtyRows = _clampDim(rows, PTY_ROWS_MIN, PTY_ROWS_MAX, lastPtyRows);
814
- if (ptyProcess) {
815
- try { ptyProcess.resize(lastPtyCols, lastPtyRows); } catch { }
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
- if (ptyProcess) {
821
- flushBatch(true);
822
- batchBuffer = '';
823
- batchScheduled = false;
824
- // Windows: node-pty's ConPTY kill has a known synchronous-hang issue
825
- // (microsoft/node-pty#454); a hang would also take down the Ctrl+C exit-chain watchdog.
826
- // Instead use spawnSync taskkill /T /F to reap the whole process tree (ConPTY agent +
827
- // claude), bounded (timeout 2s) and providing "dead on return" semantics (which
828
- // spawnClaude's internal kill→respawn and workspaces stop→launch rely on). ptyProcess
829
- // .kill() is fully skipped on win32. Non-Windows behavior is unchanged.
830
- if (!killPtyTree(ptyProcess.pid)) {
831
- try { ptyProcess.kill(); } catch { }
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
- ptyProcess = null;
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
- return ptyProcess ? ptyProcess.pid : null;
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
- return ptyKind;
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
- return ptyKind === 'claude' && ptySkipPermissions;
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
- return outputBuffer;
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
  }