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.
Files changed (159) hide show
  1. package/README.md +3 -1
  2. package/cli.js +33 -4
  3. package/concepts/ar/UltraPlan.md +1 -1
  4. package/concepts/da/UltraPlan.md +1 -1
  5. package/concepts/de/UltraPlan.md +1 -1
  6. package/concepts/en/UltraPlan.md +1 -1
  7. package/concepts/es/UltraPlan.md +1 -1
  8. package/concepts/fr/UltraPlan.md +1 -1
  9. package/concepts/it/UltraPlan.md +1 -1
  10. package/concepts/ja/UltraPlan.md +1 -1
  11. package/concepts/ko/UltraPlan.md +1 -1
  12. package/concepts/no/UltraPlan.md +1 -1
  13. package/concepts/pl/UltraPlan.md +1 -1
  14. package/concepts/pt-BR/UltraPlan.md +1 -1
  15. package/concepts/ru/UltraPlan.md +1 -1
  16. package/concepts/th/UltraPlan.md +1 -1
  17. package/concepts/tr/UltraPlan.md +1 -1
  18. package/concepts/uk/UltraPlan.md +1 -1
  19. package/concepts/zh/UltraPlan.md +1 -1
  20. package/concepts/zh-TW/UltraPlan.md +1 -1
  21. package/dist/assets/App-DM-fbhNd.js +2 -0
  22. package/dist/assets/App-DOZI5HME.css +1 -0
  23. package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
  24. package/dist/assets/{MdxEditorPanel-3j8rS3IT.js → MdxEditorPanel-DjwOQDh0.js} +1 -1
  25. package/dist/assets/Mobile-BD-hefIH.js +1 -0
  26. package/dist/assets/ProxyStatsModal-pYa_0QVL.js +1 -0
  27. package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
  28. package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
  29. package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
  30. package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
  31. package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
  32. package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
  33. package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
  34. package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
  35. package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
  36. package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
  37. package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
  38. package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
  39. package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
  40. package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
  41. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
  42. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
  43. package/dist/assets/clone-CFj55h49.js +1 -0
  44. package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
  45. package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
  46. package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
  47. package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
  48. package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
  49. package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
  50. package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
  51. package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
  52. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
  53. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
  54. package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
  55. package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
  56. package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
  57. package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
  58. package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
  59. package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
  60. package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
  61. package/dist/assets/index-DkIhbLjr.js +2 -0
  62. package/dist/assets/{index-Nvk8r3tO.css → index-Dz36p_bA.css} +1 -1
  63. package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
  64. package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
  65. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
  66. package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
  67. package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
  68. package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
  69. package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
  70. package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
  71. package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
  72. package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
  73. package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
  74. package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
  75. package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
  76. package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
  77. package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
  78. package/dist/assets/seqResourceLoaders-BdWMUSrC.css +41 -0
  79. package/dist/assets/seqResourceLoaders-Dzy1J6G9.js +2 -0
  80. package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
  81. package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
  82. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
  83. package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
  84. package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
  85. package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
  86. package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
  87. package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
  88. package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
  89. package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
  90. package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
  91. package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
  92. package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
  93. package/dist/index.html +5 -5
  94. package/package.json +1 -1
  95. package/server/interceptor.js +192 -24
  96. package/server/lib/ask/ask-store.js +7 -3
  97. package/server/lib/async-file-lock.js +29 -5
  98. package/server/lib/auth.js +71 -54
  99. package/server/lib/chat-queue.js +67 -4
  100. package/server/lib/config-backup.js +8 -5
  101. package/server/lib/credential-access.js +233 -0
  102. package/server/lib/credential-migrate.js +147 -0
  103. package/server/lib/credential-store.js +120 -0
  104. package/server/lib/credential-vault.js +112 -0
  105. package/server/lib/file-access-policy.js +16 -0
  106. package/server/lib/git-diff.js +49 -20
  107. package/server/lib/im/im-config.js +81 -31
  108. package/server/lib/im-deny.js +22 -8
  109. package/server/lib/json-store.js +264 -0
  110. package/server/lib/log-watcher.js +9 -1
  111. package/server/lib/prefs-store.js +8 -33
  112. package/server/lib/same-origin.js +22 -0
  113. package/server/lib/session-pin-store.js +7 -14
  114. package/server/lib/v2/convert.js +1 -1
  115. package/server/lib/v2/layout.js +28 -3
  116. package/server/lib/v2/live-feed.js +41 -4
  117. package/server/lib/v2/resume-list.js +150 -0
  118. package/server/lib/v2/session-select.js +63 -14
  119. package/server/lib/v2/v2-writer.js +172 -34
  120. package/server/lib/v2/view-router.js +83 -0
  121. package/server/lib/view-root.js +209 -0
  122. package/server/pty-manager.js +800 -163
  123. package/server/routes/ask-perm.js +21 -1
  124. package/server/routes/auth.js +21 -1
  125. package/server/routes/events.js +123 -7
  126. package/server/routes/expert.js +46 -14
  127. package/server/routes/files-content.js +72 -7
  128. package/server/routes/files-fs.js +28 -3
  129. package/server/routes/git.js +31 -5
  130. package/server/routes/im.js +15 -2
  131. package/server/routes/preferences.js +59 -46
  132. package/server/routes/project-meta.js +32 -3
  133. package/server/routes/proxy-stats.js +33 -5
  134. package/server/routes/resume.js +302 -0
  135. package/server/routes/search.js +23 -1
  136. package/server/routes/skills.js +18 -2
  137. package/server/routes/v2.js +6 -1
  138. package/server/routes/workspaces.js +10 -3
  139. package/server/server.js +199 -25
  140. package/server/system-prompt-templates/presets/GLM-5.2.md +0 -1
  141. package/server/system-prompt-templates/presets/GLM-5.3.md +0 -1
  142. package/server/system-prompt-templates/presets/Qwen-3.md +0 -1
  143. package/server/system-prompt-templates/presets/deepseek-v4-flash.md +0 -1
  144. package/server/system-prompt-templates/presets/deepseek-v4-pro.md +0 -1
  145. package/server/system-prompt-templates/presets/kimi-k2.7-code.md +0 -1
  146. package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
  147. package/server/system-prompt-templates/systemPromptModel.md +0 -1
  148. package/server/workspace-registry.js +41 -35
  149. package/ultraAgents/code-expert.json +1 -1
  150. package/dist/assets/App-M61pqQEx.css +0 -1
  151. package/dist/assets/App-_HfxMJvv.js +0 -2
  152. package/dist/assets/Mobile-DaRLB7Gh.js +0 -1
  153. package/dist/assets/ProxyStatsModal-dlPIDlx-.js +0 -1
  154. package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
  155. package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
  156. package/dist/assets/clone-B0_ghF3F.js +0 -1
  157. package/dist/assets/index-CjbBUDGP.js +0 -2
  158. package/dist/assets/seqResourceLoaders-B_FSQh3U.css +0 -41
  159. package/dist/assets/seqResourceLoaders-hqLhsiGO.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,
@@ -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) and the IM
357
- // credential store (preferences.json), but do not block all of ~/.claude — the
358
- // worker's working directory sits under ~/.claude/cc-viewer/IM_<id>/ and must stay
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
- 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';
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
- 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);
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
- spawnClaude(proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
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
- 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);
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
- currentWorkspacePath = null;
600
- for (const cb of exitListeners) {
601
- 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
+ }
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
- if (ptyProcess) {
638
- ptyProcess.write(data);
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
- if (ptyProcess) return false; // a process is already running
720
- if (_spawnInflight) return _spawnInflight; // reuse the in-flight spawn to avoid double
721
- const p = _spawnShellImpl();
722
- _spawnInflight = p;
723
- 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; }
724
919
  }
725
920
 
726
- async function _spawnShellImpl() {
727
- 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();
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
- ptyKind = 'shell';
763
- ptySkipPermissions = false;
764
-
765
- ptyProcess.onData((data) => {
766
- outputBuffer += data;
767
- if (outputBuffer.length > MAX_BUFFER) {
768
- const rawStart = outputBuffer.length - BUFFER_TRIM_TO;
769
- const safeStart = findSafeSliceStart(outputBuffer, rawStart);
770
- 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);
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
- for (const cb of exitListeners) {
787
- 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
+ }
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
- lastPtyCols = _clampDim(cols, PTY_COLS_MIN, PTY_COLS_MAX, lastPtyCols);
807
- lastPtyRows = _clampDim(rows, PTY_ROWS_MIN, PTY_ROWS_MAX, lastPtyRows);
808
- if (ptyProcess) {
809
- 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 { }
810
1037
  }
1038
+ s.ptyProcess = null;
1039
+ s.ptyKind = null;
1040
+ s.ptySkipPermissions = false;
811
1041
  }
812
1042
 
813
1043
  export function killPty() {
814
- if (ptyProcess) {
815
- flushBatch(true);
816
- batchBuffer = '';
817
- batchScheduled = false;
818
- // Windows: node-pty's ConPTY kill has a known synchronous-hang issue
819
- // (microsoft/node-pty#454); a hang would also take down the Ctrl+C exit-chain watchdog.
820
- // Instead use spawnSync taskkill /T /F to reap the whole process tree (ConPTY agent +
821
- // claude), bounded (timeout 2s) and providing "dead on return" semantics (which
822
- // spawnClaude's internal kill→respawn and workspaces stop→launch rely on). ptyProcess
823
- // .kill() is fully skipped on win32. Non-Windows behavior is unchanged.
824
- if (!killPtyTree(ptyProcess.pid)) {
825
- 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
826
1141
  }
827
- ptyProcess = null;
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
- return ptyProcess ? ptyProcess.pid : null;
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
- 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;
861
1439
  }
862
1440
 
863
1441
  /** True iff the active Claude session was launched with --dangerously-skip-permissions. */
864
1442
  export function getPtySkipPermissions() {
865
- return ptyKind === 'claude' && ptySkipPermissions;
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
- 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();
878
1515
  }