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
@@ -0,0 +1,264 @@
1
+ // Unified JSON store kernel — one locked + atomic read/write path per config file.
2
+ //
3
+ // cc-viewer historically copy-pasted the same "read → mutate → tmp→rename" block across
4
+ // prefs-store / auth / im-config / workspace-registry / ask-store / session-pin-store, with
5
+ // divergent lock and permission handling (and a few writers that were neither locked nor
6
+ // atomic — see lib/im/im-config.js). This module extracts that pattern ONCE so the lock and
7
+ // the 0600 policy live in a single place. It is the prerequisite for any consistent local
8
+ // state (and, later, for cloud sync): a file cannot be projected or compared unless every
9
+ // writer goes through the same locked, atomic path.
10
+ //
11
+ // Boundary: L1-lib. Imports only node builtins + the two L0-leaf primitives
12
+ // (file-api.renameSyncWithRetry, async-file-lock.withFileLockAsync) + @ccv/core/error-report.
13
+ // It deliberately does NOT import LOG_DIR — callers inject the file path so the kernel stays
14
+ // a pure, reusable primitive with no data-root coupling.
15
+ //
16
+ // Two lock flavors are provided because callers split into two camps:
17
+ // - mutateJson — async lock (withFileLockAsync), for async mutators.
18
+ // - mutateJsonSync — synchronous spin lock (openSync('wx')), for the many existing SYNC
19
+ // writers (auth.js, im-config.js) that cannot become async without
20
+ // cascading signature changes through routes and server startup.
21
+ // Both serialize same-process callers and mutex cross-process on the same `${file}.lock`.
22
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync, openSync, closeSync, statSync } from 'node:fs';
23
+ import { dirname } from 'node:path';
24
+ import { randomBytes } from 'node:crypto';
25
+ import { renameSyncWithRetry } from './file-api.js';
26
+ import { withFileLockAsync, hasLiveDiskHolder } from './async-file-lock.js';
27
+ import { reportSwallowed } from '@ccv/core/error-report';
28
+
29
+ /** Lock file lives next to the data file, derived from ITS name (not a fixed basename), so two
30
+ * different files in the same directory never share a lock by accident. */
31
+ export function lockPathFor(file) { return `${file}.lock`; }
32
+
33
+ /**
34
+ * Tolerant JSON read. Missing / corrupt / non-object (when fallback is a plain object) → fallback.
35
+ * Arrays are accepted when the fallback is an array; a scalar never satisfies an object fallback.
36
+ */
37
+ export function readJsonSafe(file, fallback = {}) {
38
+ try {
39
+ if (!existsSync(file)) return fallback;
40
+ const obj = JSON.parse(readFileSync(file, 'utf-8'));
41
+ if (obj === null || typeof obj !== 'object') return fallback;
42
+ // Shape guard: an array fallback accepts only an array; a plain-object fallback rejects one.
43
+ if (Array.isArray(fallback) !== Array.isArray(obj)) return fallback;
44
+ return obj;
45
+ } catch {
46
+ return fallback;
47
+ }
48
+ }
49
+
50
+ // Sentinel for "file exists but cannot be read/parsed". A MUTATE path can choose how to treat it:
51
+ // - DEFAULT (tolerant): collapse to `fallback` and write, so a corrupt preferences.json stays
52
+ // self-healing (the long-standing L66 behavior — a user re-saving recovers the file).
53
+ // - strictCorrupt:true: refuse to write, preserving the corrupt bytes — for files whose loss is
54
+ // unacceptable (e.g. profile.json, where a {} overwrite would drop every profile).
55
+ const CORRUPT = Symbol('json-store.corrupt');
56
+ function _readForMutation(file, fallback) {
57
+ if (!existsSync(file)) return fallback; // genuinely absent → seeding is fine
58
+ try {
59
+ const obj = JSON.parse(readFileSync(file, 'utf-8'));
60
+ if (obj === null || typeof obj !== 'object') return CORRUPT;
61
+ if (Array.isArray(fallback) !== Array.isArray(obj)) return CORRUPT;
62
+ return obj;
63
+ } catch {
64
+ return CORRUPT;
65
+ }
66
+ }
67
+
68
+ function _resolveForMutation(data, file, strictCorrupt) {
69
+ if (data !== CORRUPT) return data;
70
+ if (strictCorrupt) {
71
+ const err = new Error(`json-store: refusing to overwrite corrupt/unreadable file ${file}`);
72
+ err.code = 'JSON_STORE_CORRUPT';
73
+ reportSwallowed('json-store.corrupt-guard', err);
74
+ throw err;
75
+ }
76
+ return undefined; // tolerant: signal "use the fallback"
77
+ }
78
+
79
+ /**
80
+ * Atomic write (tmp + renameSyncWithRetry). `mode` defaults to 0600 (the file may carry secrets);
81
+ * pass `mode: false` to skip the permission bit entirely (non-secret stores under umask).
82
+ * Serialization: `pretty` (2-space, the prefs convention) or compact.
83
+ */
84
+ export function writeJsonAtomic(file, data, { mode = 0o600, pretty = true } = {}) {
85
+ mkdirSync(dirname(file), { recursive: true });
86
+ const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
87
+ try {
88
+ writeFileSync(tmp, pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data), mode ? { mode } : undefined);
89
+ renameSyncWithRetry(tmp, file);
90
+ if (mode) {
91
+ // writeFileSync's mode only applies on creation; re-assert on a pre-existing file.
92
+ try { chmodSync(file, mode); } catch { /* best-effort; non-POSIX or race */ }
93
+ }
94
+ } catch (err) {
95
+ try { unlinkSync(tmp); } catch {}
96
+ throw err;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Locked read-modify-write (async). Reads inside the lock, runs `mutator(data)` (mutate in
102
+ * place; may be async), atomically writes, returns the mutator's value when defined else data.
103
+ */
104
+ export async function mutateJson(file, mutator, { mode = 0o600, pretty = true, fallback = {}, ensureDir, strictCorrupt = false } = {}) {
105
+ return withFileLockAsync(lockPathFor(file), async () => {
106
+ const resolved = _resolveForMutation(_readForMutation(file, fallback), file, strictCorrupt);
107
+ const data = resolved === undefined ? fallback : resolved;
108
+ const result = await mutator(data);
109
+ writeJsonAtomic(file, data, { mode, pretty });
110
+ return result !== undefined ? result : data;
111
+ }, { ensureDir: ensureDir ?? dirname(file) });
112
+ }
113
+
114
+ /**
115
+ * Lock-only primitive: holds the file's `${file}.lock` around `fn()` WITHOUT doing any read
116
+ * or write itself. For stores whose on-disk shape differs from their in-memory shape (e.g.
117
+ * ask-store's {version, entries} wrap) and therefore need to run their own domain read/save
118
+ * inside the mutex rather than the kernel's read→mutate→write. Shares the same lock as
119
+ * mutateJson / mutateJsonSync for the file.
120
+ */
121
+ export async function withJsonLock(file, fn, { ensureDir } = {}) {
122
+ return withFileLockAsync(lockPathFor(file), fn, { ensureDir: ensureDir ?? dirname(file) });
123
+ }
124
+
125
+ // ─── Synchronous spin lock ───
126
+ // Mirrors async-file-lock's two-tier stale detection (dead PID or aged mtime) but with
127
+ // openSync('wx') + a blocking Atomics.wait so sync callers can hold the SAME `${file}.lock`.
128
+ // Same-process serialization is inherent (JS is single-threaded; a sync critical section has
129
+ // no interleaving). Cross-process mutual exclusion comes from the lock file.
130
+
131
+ function _isPidAlive(pid) {
132
+ if (!Number.isInteger(pid) || pid <= 0) return false;
133
+ try { process.kill(pid, 0); return true; } catch (err) { return err && err.code === 'EPERM'; }
134
+ }
135
+
136
+ function _readLockOwnerPid(path) {
137
+ try {
138
+ const raw = readFileSync(path, 'utf-8');
139
+ if (!raw) return null;
140
+ const obj = JSON.parse(raw);
141
+ if (obj && Number.isInteger(obj.pid)) return obj.pid;
142
+ } catch {}
143
+ return null;
144
+ }
145
+
146
+ function _isLockStale(path, mtimeFallbackMs) {
147
+ const pid = _readLockOwnerPid(path);
148
+ if (pid !== null) {
149
+ if (pid === process.pid) {
150
+ // Own pid is a crash leftover ONLY when this process does not currently hold the disk lock
151
+ // via the async flavor. An async holder (mutateJson / withJsonLock / mutatePrefs) is alive
152
+ // and mid-critical-section right now — it yields the JS thread at every await, so this sync
153
+ // caller can observe its lock; stealing it would let both writers commit and lose an update.
154
+ if (hasLiveDiskHolder(path)) return false;
155
+ return true;
156
+ }
157
+ return !_isPidAlive(pid);
158
+ }
159
+ try {
160
+ const stats = statSync(path);
161
+ return Date.now() - stats.mtimeMs > mtimeFallbackMs;
162
+ } catch {
163
+ return false; // conservative: cannot stat → assume held
164
+ }
165
+ }
166
+
167
+ function _blockingSleep(ms) {
168
+ try {
169
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
170
+ } catch { /* Atomics.wait unavailable on the main thread in some embedders — best-effort */ }
171
+ }
172
+
173
+ // Returns true when the lock was acquired, false on timeout (caller decides how to degrade).
174
+ // When the lock is held by THIS process's async flavor (mutateJson / withJsonLock / mutatePrefs),
175
+ // the sync caller must NOT spin-then-degrade: the async holder is alive and mid-critical-section,
176
+ // and a sync write issued from inside that critical section would alias the SAME in-memory object
177
+ // the async holder is about to write back — so the sync update is silently lost (the exact failure
178
+ // this kernel exists to prevent). That reentrancy is a programming error, not a race to ride out:
179
+ // throw so the caller fails loudly instead of corrupting state. Foreign-process contention still
180
+ // degrades per the caller's timeout policy.
181
+ function _acquireLockSync(lockPath, { deadline = 2000, retryMs = 25, staleThresholdMs = 5000 } = {}) {
182
+ if (hasLiveDiskHolder(lockPath)) {
183
+ const err = new Error(`mutateJsonSync re-entered a lock held by this process's async flavor: ${lockPath}`);
184
+ err.code = 'JSON_STORE_REENTRANT';
185
+ reportSwallowed('json-store.reentrant-sync', err);
186
+ throw err;
187
+ }
188
+ const deadlineAt = Date.now() + deadline;
189
+ while (true) {
190
+ let fd;
191
+ try {
192
+ fd = openSync(lockPath, 'wx');
193
+ try { writeFileSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() })); } finally { closeSync(fd); }
194
+ return true;
195
+ } catch (err) {
196
+ if (err?.code === 'EEXIST') {
197
+ if (Date.now() < deadlineAt) {
198
+ if (_isLockStale(lockPath, staleThresholdMs)) {
199
+ try { unlinkSync(lockPath); } catch {}
200
+ continue;
201
+ }
202
+ _blockingSleep(retryMs);
203
+ continue;
204
+ }
205
+ if (_isLockStale(lockPath, staleThresholdMs)) {
206
+ try { unlinkSync(lockPath); } catch {}
207
+ continue;
208
+ }
209
+ return false; // timeout — caller degrades instead of crashing
210
+ }
211
+ throw err;
212
+ }
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Synchronous locked read-modify-write, for the existing SYNC writers (auth.js, im-config.js).
218
+ * Holds the SAME `${file}.lock` as mutateJson so a sync writer and an async writer of the same
219
+ * file still mutex cross-process. `mutator` must be synchronous (no await).
220
+ *
221
+ * Timeout / reentrancy policy:
222
+ * - Foreign process holds the lock and it cannot be acquired in time: DEGRADE to an unlocked
223
+ * atomic write rather than throwing — a 2s freeze plus an uncaught throw out of a route
224
+ * callback would kill the whole server. The atomic write still can't tear the file; the
225
+ * residual lost-update window is the pre-kernel status quo and is reported so it's visible.
226
+ * Pass { strict: true } to throw instead.
227
+ * - THIS process's async flavor holds the lock (reentrant sync call from inside an async
228
+ * critical section): THROW (JSON_STORE_REENTRANT). That write would alias the async holder's
229
+ * in-memory object and be silently overwritten — a fail-closed throw is the only safe answer.
230
+ */
231
+ export function mutateJsonSync(file, mutator, { mode = 0o600, pretty = true, fallback = {}, ensureDir, deadline, strict = false, strictCorrupt = false } = {}) {
232
+ const dir = ensureDir ?? dirname(file);
233
+ try { mkdirSync(dir, { recursive: true }); } catch {}
234
+ const lockPath = lockPathFor(file);
235
+ const acquired = _acquireLockSync(lockPath, deadline ? { deadline } : {});
236
+ if (!acquired && strict) {
237
+ throw new Error(`Lock acquisition timeout: ${lockPath} (held by live process)`);
238
+ }
239
+ if (!acquired) {
240
+ reportSwallowed('json-store.lock-timeout-degraded', new Error(`mutateJsonSync degraded to unlocked write: ${file}`));
241
+ }
242
+ try {
243
+ const resolved = _resolveForMutation(_readForMutation(file, fallback), file, strictCorrupt);
244
+ const data = resolved === undefined ? fallback : resolved;
245
+ const result = mutator(data);
246
+ writeJsonAtomic(file, data, { mode, pretty });
247
+ return result !== undefined ? result : data;
248
+ } finally {
249
+ if (acquired) {
250
+ try { unlinkSync(lockPath); } catch (err) { reportSwallowed('json-store.lock-release', err); }
251
+ }
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Generic shallow merge of a patch onto `target` IN PLACE. Domain-specific merges (e.g.
257
+ * approvalModal reconciliation in prefs-store) stay in the per-store layer — the kernel only
258
+ * provides the plain-object assign so every store doesn't re-hand-roll it.
259
+ */
260
+ export function applyJsonPatch(target, patch) {
261
+ if (!patch || typeof patch !== 'object') return target;
262
+ Object.assign(target, patch);
263
+ return target;
264
+ }
@@ -5,6 +5,7 @@ import { reconstructEntries } from '@ccv/core/delta-reconstructor';
5
5
  import { enrichEntry } from './enrich-plan-input.js';
6
6
  import { enrichEntry as enrichWorkflowEntry } from './enrich-workflow.js';
7
7
  import { sseWrite } from './wire-compress.js';
8
+ import { reportSwallowed } from '@ccv/core/error-report';
8
9
 
9
10
  // 1.7.0: the v1 log-file tail (fs.watch + byte cursors + rotation follow)
10
11
  // retired with the v1 write path — the live channel is server/lib/v2/
@@ -66,8 +67,15 @@ function _safeSseWrite(clients, client, payload) {
66
67
  // streams. The drain listener below stays on the res: the encoder is
67
68
  // piped into it, so socket drain still propagates.
68
69
  ok = sseWrite(client, payload);
69
- } catch {
70
+ } catch (err) {
71
+ // A throwing write means this connection is unusable. Removing it from
72
+ // `clients` alone left the socket open and the 30s ping timer alive: the
73
+ // browser kept renewing its heartbeat watchdog on those pings, so it never
74
+ // reconnected and the chat panel stalled forever while the terminal WS
75
+ // stayed healthy. Report and CLOSE so the client's auto-reconnect fires.
76
+ reportSwallowed('sse.safe-write', err);
70
77
  _removeClient(clients, client);
78
+ try { client.end(); } catch {}
71
79
  return false;
72
80
  }
73
81
  if (!ok) {
@@ -7,11 +7,8 @@
7
7
  // callers serialize via withFileLockAsync's per-lockPath Promise chain; cross-process
8
8
  // callers mutex on the lock file. This prevents a concurrent writer from clobbering the
9
9
  // password-bearing file or losing a fork update.
10
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs';
11
10
  import { join, dirname } from 'node:path';
12
- import { randomBytes } from 'node:crypto';
13
- import { renameSyncWithRetry } from './file-api.js';
14
- import { withFileLockAsync } from './async-file-lock.js';
11
+ import { mutateJson, readJsonSafe, writeJsonAtomic } from './json-store.js';
15
12
  import { mergeApprovalModalPrefs } from '@ccv/core/approval-modal-prefs';
16
13
  import { reconcileVoicePackPrefs } from './voice-pack-manager.js';
17
14
  import { LOG_DIR } from '../../findcc.js';
@@ -22,46 +19,24 @@ import { LOG_DIR } from '../../findcc.js';
22
19
  // override on the helpers below exists only so the preferences route can forward its
23
20
  // deps.getPrefsFile() seam (used by branch tests) and stay symmetric with the GET read.
24
21
  export function getPrefsFile() { return join(LOG_DIR, 'preferences.json'); }
25
- // Lock lives next to the file so every writer of the SAME preferences.json shares one lock
26
- // (global POST, fork ops, …). In prod all paths are canonical ⇒ one lock ⇒ serialized.
27
- function getPrefsLock(file) { return join(dirname(file), 'preferences.lock'); }
28
22
 
29
23
  /** Read the raw on-disk prefs object (no stripping, no virtual defaults). {} on miss/corrupt. */
30
24
  export function readPrefsRaw(file = getPrefsFile()) {
31
- try {
32
- if (!existsSync(file)) return {};
33
- const obj = JSON.parse(readFileSync(file, 'utf-8'));
34
- return obj && typeof obj === 'object' ? obj : {};
35
- } catch { return {}; }
36
- }
37
-
38
- /** Atomic write (tmp + rename) with 0600 — the file may carry the base64 password. */
39
- function writePrefsAtomic(prefs, file) {
40
- mkdirSync(dirname(file), { recursive: true });
41
- const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
42
- try {
43
- writeFileSync(tmp, JSON.stringify(prefs, null, 2), { mode: 0o600 });
44
- renameSyncWithRetry(tmp, file);
45
- // writeFileSync's mode only applies on creation; re-assert 0600 on a pre-existing file.
46
- try { chmodSync(file, 0o600); } catch { /* best-effort; non-POSIX or race */ }
47
- } catch (err) {
48
- try { unlinkSync(tmp); } catch {}
49
- throw err;
50
- }
25
+ return readJsonSafe(file, {});
51
26
  }
52
27
 
53
28
  /**
54
29
  * Locked read-modify-write. Reads the raw prefs inside the lock, runs mutator(prefs)
55
30
  * (mutate in place; may be async), atomically writes, and returns the mutator's return
56
31
  * value when defined, else the mutated prefs object. `file` defaults to the canonical path.
32
+ *
33
+ * Delegates to the unified json-store kernel (mutateJson): one async file lock derived from
34
+ * the file name + atomic tmp→rename at 0600. The lock is shared with the SYNC writers of the
35
+ * same file (auth.js / im-config.js via mutateJsonSync), so all preferences.json writers now
36
+ * mutex on the same `preferences.json.lock`.
57
37
  */
58
38
  export async function mutatePrefs(mutator, file = getPrefsFile()) {
59
- return withFileLockAsync(getPrefsLock(file), async () => {
60
- const prefs = readPrefsRaw(file);
61
- const result = await mutator(prefs);
62
- writePrefsAtomic(prefs, file);
63
- return result !== undefined ? result : prefs;
64
- }, { ensureDir: dirname(file) });
39
+ return mutateJson(file, mutator, { mode: 0o600, fallback: {}, ensureDir: dirname(file) });
65
40
  }
66
41
 
67
42
  /**
@@ -0,0 +1,22 @@
1
+ // Same-origin guard for sensitive browser-facing endpoints.
2
+ //
3
+ // Loopback identifies the socket peer, not the browser page that initiated a
4
+ // request. Since the server intentionally has permissive CORS for legacy APIs,
5
+ // sensitive endpoints (executable selection, process kill, ...) need their own
6
+ // same-origin guard stacked on the admin check: a cross-site page riding the
7
+ // user's browser (token cookie / ?token= URL) must not reach them, while
8
+ // CLI/native clients (no Origin header) and same-origin pages pass through.
9
+ //
10
+ // Canonical implementation — every call site imports from here.
11
+ export function isSameOriginBrowserRequest(req, parsedUrl) {
12
+ const origin = req.headers?.origin;
13
+ if (!origin) return true; // CLI/native clients do not send Origin.
14
+ if (req.headers?.['sec-fetch-site'] === 'cross-site') return false;
15
+ try {
16
+ const originUrl = new URL(origin);
17
+ if (parsedUrl?.origin) return originUrl.origin.toLowerCase() === parsedUrl.origin.toLowerCase();
18
+ return originUrl.host.toLowerCase() === String(req.headers?.host || '').toLowerCase();
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
@@ -8,10 +8,9 @@
8
8
  // '' means no active project (workspace mode not yet launched) → read = null / write = no-op.
9
9
  // Writes are atomic (tmp + rename) so a torn read can't happen when several writers race
10
10
  // (mirrors server/lib/prefs-store.js).
11
- import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
11
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
- import { randomBytes } from 'node:crypto';
14
- import { renameSyncWithRetry } from './file-api.js';
13
+ import { writeJsonAtomic } from './json-store.js';
15
14
 
16
15
  /** Pin file path inside the project dir: `.session-pin.json`. */
17
16
  export function pinFilePath(logDir) {
@@ -32,8 +31,10 @@ export function readPin(logDir) {
32
31
 
33
32
  /**
34
33
  * Write (or clear) the pinned session id. No project (logDir = '') → no-op, returns false.
35
- * A null/empty pinnedSessionId deletes the file (back to "show latest"). Atomic tmp+rename.
36
- * Returns true on success, false on no-project / write failure (view state is best-effort).
34
+ * A null/empty pinnedSessionId deletes the file (back to "show latest"). Atomic via the
35
+ * json-store kernel (tmp + rename). Non-secret → mode:false (umask); the pin is a view
36
+ * preference, not a credential. Returns true on success, false on no-project / write
37
+ * failure (view state is best-effort).
37
38
  */
38
39
  export function writePin(logDir, pinnedSessionId) {
39
40
  if (!logDir) return false;
@@ -44,15 +45,7 @@ export function writePin(logDir, pinnedSessionId) {
44
45
  try { unlinkSync(file); } catch { /* already absent */ }
45
46
  return true;
46
47
  }
47
- mkdirSync(logDir, { recursive: true });
48
- const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
49
- try {
50
- writeFileSync(tmp, JSON.stringify({ pinnedSessionId: id }));
51
- renameSyncWithRetry(tmp, file);
52
- } catch (err) {
53
- try { unlinkSync(tmp); } catch {}
54
- throw err;
55
- }
48
+ writeJsonAtomic(file, { pinnedSessionId: id }, { mode: false, pretty: false });
56
49
  return true;
57
50
  } catch { return false; }
58
51
  }
@@ -53,7 +53,7 @@ const STOP_CHECK_EVERY = 50; // entries between shouldStop() polls
53
53
  const PROXY_SHARD_NAME = /^proxy_\d{4}-\d{2}-\d{2}\.jsonl$/;
54
54
 
55
55
  /** Enumerate a project's v1 log files, ascending by filename timestamp.
56
- * Excludes `_temp.jsonl` (resume scratch) and `proxy_*.jsonl` (retry stats). */
56
+ * Excludes `_temp.jsonl` and `proxy_*.jsonl` (retry stats). */
57
57
  export function listV1Files(projectDir) {
58
58
  if (!existsSync(projectDir)) return [];
59
59
  return readdirSync(projectDir)
@@ -6,7 +6,7 @@
6
6
  // and the swallowed ENOENT would silently drop v2 lines (plan risk F2).
7
7
 
8
8
  import { mkdirSync, writeFileSync, renameSync, existsSync, openSync, writeSync, fsyncSync, closeSync, readdirSync, statSync } from 'node:fs';
9
- import { join } from 'node:path';
9
+ import { join, resolve } from 'node:path';
10
10
 
11
11
  // The wire-format version this build writes AND the only one it reads.
12
12
  // Stamped into meta.json and the journal sentinel at session creation; the
@@ -28,6 +28,29 @@ export function sanitizePathComponent(name) {
28
28
  return cleaned;
29
29
  }
30
30
 
31
+ /**
32
+ * Normalize a session's owning cwd for cross-dir same-basename comparison
33
+ * (2026-10-07). Lexical ONLY — `resolve` collapses `.`/`..` and duplicate
34
+ * separators without touching the filesystem, then a trailing-separator strip
35
+ * evens `/a/x` vs `/a/x/`. (On POSIX `resolve` already drops trailing slashes,
36
+ * so the strip is redundant there; it still guards Windows `\` and the root
37
+ * case.) Deliberately NOT realpath: the write side stamps the raw spawn cwd and
38
+ * the read side reads the live PTY record's raw cwd, and both are the SAME raw
39
+ * string — realpath would introduce a symlink-mismatch (/tmp → /private/tmp)
40
+ * where the two raw sides already agree byte-for-byte. Applied identically on
41
+ * the write (meta.cwd stamp), read-candidate, and read-target sides so the
42
+ * comparison stays symmetric across a respawn. Absolute cwds pass through
43
+ * deterministically; a relative cwd would resolve against process.cwd() (never
44
+ * occurs for a spawn cwd, which is always absolute).
45
+ * @param {string} cwd
46
+ * @returns {string} '' for empty/non-string input.
47
+ */
48
+ export function normalizeSessionCwd(cwd) {
49
+ if (!cwd || typeof cwd !== 'string') return '';
50
+ const out = resolve(cwd);
51
+ return out.length > 1 ? out.replace(/[\\/]+$/, '') : out;
52
+ }
53
+
31
54
  /** Session creation time (ISO) → a 14-digit LOCAL-time stamp `yyyymmddhhmmss`
32
55
  * for the session dir-name prefix (task C). Deliberately NO internal separator
33
56
  * (unlike log-management's `compactLocalTs`, which emits `YYYYMMDD_HHMMSS`) so
@@ -122,8 +145,10 @@ export function writeFileAtomicSync(finalPath, data) {
122
145
  * targeting this session. Returns the paths object.
123
146
  *
124
147
  * meta: { wireFormat:2, sessionId, project, pid, startTs, userIdRaw,
125
- * userIdEncoding, leader?, im? } — spec §3. Existing meta is never
126
- * rewritten here (later additive updates go through updateMetaSync).
148
+ * userIdEncoding, leader?, im?, cwd? } — spec §3. Existing meta is never
149
+ * rewritten here (later additive updates go through updateMetaSync). `cwd` is
150
+ * the session's owning full working directory (same-name isolation, 2026-10-07);
151
+ * absent for legacy/external producers.
127
152
  */
128
153
  export function ensureSessionDirSync(logDir, project, sessionId, meta = {}, sessionsDirName = SESSIONS_DIR_NAME, dirName = sessionId) {
129
154
  // Path uses dirName (`<ts>_<uuid>` for live, task C); IDENTITY (meta.sessionId
@@ -183,6 +183,14 @@ export class V2LiveFeed {
183
183
  // row (v2_requests_delta). Explicit ctor param — this module has no access
184
184
  // to the server deps object.
185
185
  this._wireV3 = !!opts.wireV3;
186
+ // Per-project view layer (2026-10): this feed's project name + an optional
187
+ // client filter. The server runs ONE feed per project (bound + any viewed);
188
+ // each broadcast must reach only the clients whose viewed project matches
189
+ // THIS feed's project. `clientFilter(clients, project)` returns the subset;
190
+ // the default (no filter) is the legacy broadcast-to-all behavior, so the
191
+ // single-project case is byte-identical.
192
+ this._project = typeof opts.project === 'string' ? opts.project : '';
193
+ this._clientFilter = typeof opts.clientFilter === 'function' ? opts.clientFilter : null;
186
194
  this._sessions = new Map(); // dir → cursor bundle
187
195
  this._seenDirs = new Map(); // dir → last observed journal mtimeMs (attached or not)
188
196
  this._discardGated = new Set(); // dirs refused by the discard gate — first real attach must NOT suppress history
@@ -193,6 +201,29 @@ export class V2LiveFeed {
193
201
  this._active = false;
194
202
  }
195
203
 
204
+ /** Clients this feed's broadcasts should reach right now: the full list, or
205
+ * the per-project filtered subset when a filter is installed. */
206
+ _clientsFor() {
207
+ return this._clientFilter ? this._clientFilter(this._clients, this._project) : this._clients;
208
+ }
209
+
210
+ /**
211
+ * Instance-scoped variant (multi-instance, 2026-10-06): narrow the per-project clients to
212
+ * those viewing the SAME instance as this session. Two concurrent same-cwd instances share
213
+ * one project feed, so this keeps instance A's entries out of instance B's viewers.
214
+ * Backward compatible: a client with NO `_ccvViewInstance` stamp (the default / legacy /
215
+ * single-instance view) sees every session regardless of `sessionInstance`; a session with
216
+ * no stamped instance (legacy/external) is likewise visible to all.
217
+ */
218
+ _clientsForInstance(sessionInstance) {
219
+ const base = this._clientsFor();
220
+ if (!sessionInstance) return base; // session carries no instance → visible to all viewers
221
+ let anyInstanceViewer = false;
222
+ for (const c of base) { if (c && c._ccvViewInstance) { anyInstanceViewer = true; break; } }
223
+ if (!anyInstanceViewer) return base; // nobody is instance-scoped → keep legacy broadcast
224
+ return base.filter((c) => !c || !c._ccvViewInstance || c._ccvViewInstance === sessionInstance);
225
+ }
226
+
196
227
  /** Begin (or switch to) live-following a project's sessions/ root. */
197
228
  start(projectDir) {
198
229
  this.stop();
@@ -374,9 +405,14 @@ export class V2LiveFeed {
374
405
  // 4GB main-thread OOM). Seek their cursors to EOF instead: nothing is
375
406
  // read, cloned, or broadcast; the cold-load channel owns their history.
376
407
  let convertOrigin = false;
408
+ let sessionInstance = null;
377
409
  try {
378
410
  const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8'));
379
411
  convertOrigin = !!meta && meta.origin === 'convert';
412
+ // Multi-instance (2026-10-06): the owning PTY instanceKey stamped on meta.json (by the
413
+ // writer) lets this feed route the session's entries to exactly the clients viewing
414
+ // THAT instance of a same-cwd project. Absent for legacy/convert/external sessions.
415
+ sessionInstance = (meta && typeof meta.instance === 'string' && meta.instance) ? meta.instance : null;
380
416
  } catch (err) {
381
417
  // meta.json is written synchronously BEFORE the journal's first async
382
418
  // drain (ensureSessionDirSync), so journal-present + meta-ABSENT is
@@ -409,6 +445,7 @@ export class V2LiveFeed {
409
445
  dirty: false,
410
446
  suppress: !!suppressExisting,
411
447
  convertOrigin,
448
+ sessionInstance,
412
449
  // Journal req seqs with no done yet. The journal is only touched at
413
450
  // request start and completion, so a single >10min request would look
414
451
  // "idle" to the mtime-based eviction and get detached mid-flight — its
@@ -611,7 +648,7 @@ export class V2LiveFeed {
611
648
  try {
612
649
  processWatchedEntry(parsed, {
613
650
  reconstructor: cur.reconstructor,
614
- clients: this._clients,
651
+ clients: this._clientsForInstance(cur.sessionInstance),
615
652
  getClaudePid: this._getClaudePid,
616
653
  runParallelHook: this._runParallelHook,
617
654
  suppressEntryBroadcast: this._wireV3,
@@ -648,7 +685,7 @@ export class V2LiveFeed {
648
685
  ? `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"channel":${JSON.stringify(channel)},"line":${raw}}`
649
686
  : `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"line":${raw}}`;
650
687
  const eventName = kind === 'conv' ? 'v3_conv' : 'v3_resp';
651
- sendEventRawToClients(this._clients, eventName, payload);
688
+ sendEventRawToClients(this._clientsForInstance(cur.sessionInstance), eventName, payload);
652
689
  } catch (err) {
653
690
  reportSwallowed('v2-live.native-forward', err);
654
691
  }
@@ -721,12 +758,12 @@ export class V2LiveFeed {
721
758
  const corrected = withAgentNameSubType(classifyRequest(prev.entry, parsed), prev.entry);
722
759
  if (JSON.stringify(corrected) !== JSON.stringify(prev.row.typeTag)) {
723
760
  prev.row.typeTag = corrected;
724
- sendEventToClients(this._clients, 'v2_requests_delta', prev.row);
761
+ sendEventToClients(this._clientsForInstance(cur.sessionInstance), 'v2_requests_delta', prev.row);
725
762
  }
726
763
  } catch (err) { reportSwallowed('v2-live.row-classify', err); }
727
764
  }
728
765
  cur._v3Last = { row, entry: parsed };
729
- sendEventToClients(this._clients, 'v2_requests_delta', row);
766
+ sendEventToClients(this._clientsForInstance(cur.sessionInstance), 'v2_requests_delta', row);
730
767
  }
731
768
 
732
769
  // ── safety poll ────────────────────────────────────────────────────────────