cc-viewer 1.9.9 → 1.9.11

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 (140) hide show
  1. package/README.md +1 -1
  2. package/cli.js +4 -3
  3. package/dist/assets/App-D3swMyLc.js +2 -0
  4. package/dist/assets/{App-D06hBGII.css → App-DU5jmGrx.css} +1 -1
  5. package/dist/assets/{MdxEditorPanel-BJVD_bcU.js → MdxEditorPanel-DeyoQsxw.js} +1 -1
  6. package/dist/assets/Mobile-DvI_Kby2.js +1 -0
  7. package/dist/assets/ProxyStatsModal-CoI275by.js +1 -0
  8. package/dist/assets/{_baseUniq-CwQykUuc.js → _baseUniq-BvPjRimO.js} +1 -1
  9. package/dist/assets/{arc-EdUdBxtG.js → arc-BzZyqc6p.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-DtBIdElY.js → architectureDiagram-Q4EWVU46-B1oszHzA.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-guNZIKkZ.js → blockDiagram-DXYQGD6D-Ndp4o_QB.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-xV5vB-Z9.js → c4Diagram-AHTNJAMY-DlDE-ALt.js} +1 -1
  13. package/dist/assets/{channel-DYEktNap.js → channel-BhXx9y6q.js} +1 -1
  14. package/dist/assets/{chunk-4BX2VUAB-ZTkItpvj.js → chunk-4BX2VUAB-CM-2e6q_.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-dYIewyyR.js → chunk-4TB4RGXK-GWLkQKRO.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6-CSsPNgPX.js → chunk-55IACEB6-B6LOFqRZ.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-CnMiuqzQ.js → chunk-EDXVE4YY-Ct3e09Ku.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-CVpATPZ3.js → chunk-FMBD7UC4-DK-vXpZP.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-Dpq4s8dW.js → chunk-OYMX7WX6-CWxZc_DU.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-Bnojh6X6.js → chunk-QZHKN3VN-jp8ISHue.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-BYqK4Dsa.js → chunk-YZCP3GAM-DtYet6WV.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-C0dwAyuU.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-C0dwAyuU.js +1 -0
  24. package/dist/assets/clone-fiofFlrV.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-OJ7mmWyV.js → cose-bilkent-S5V4N54A-lNC-slVX.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-B1Kwd_Rk.js → dagre-KV5264BT-vm6U82qq.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-D8qrw8sp.js → diagram-5BDNPKRD-Dq3FXh-U.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-ip-N4ho8.js → diagram-G4DWMVQ6-BxF85wcF.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-2PZaskUF.js → diagram-MMDJMWI5-CFCTeMz0.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-CeAOQUZP.js → diagram-TYMM5635-Bui5Azdg.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-BCBaEYEb.js → erDiagram-SMLLAGMA-DrpUO1Kb.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-CsGyIqcX.js → flowDiagram-DWJPFMVM-Bb3amxiK.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-COsj3FZ-.js → ganttDiagram-T4ZO3ILL-BRwCFM3C.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DvQVQGJG.js → gitGraphDiagram-UUTBAWPF-DVXbBwhs.js} +1 -1
  35. package/dist/assets/{graph-BSB9fDSP.js → graph-Dh22wYWs.js} +1 -1
  36. package/dist/assets/index-BRW8dfBU.js +2 -0
  37. package/dist/assets/{index-BRw4Iz2O.js → index-BwZB7UwN.js} +1 -1
  38. package/dist/assets/{index-eM2ih5n7.js → index-CQ1Nswa3.js} +1 -1
  39. package/dist/assets/{index-BuVComcY.js → index-CfFFgxxf.js} +1 -1
  40. package/dist/assets/{index-DKufsAzx.js → index-Cfm4N4ao.js} +1 -1
  41. package/dist/assets/{index-CNNAYjkF.js → index-UlO7Q5dX.js} +1 -1
  42. package/dist/assets/{index-DXBfwPYD.js → index-e8wejtj_.js} +1 -1
  43. package/dist/assets/{index-Bo-12bug.js → index-xZXOwglE.js} +1 -1
  44. package/dist/assets/{infoDiagram-42DDH7IO-eNucS4iW.js → infoDiagram-42DDH7IO-cQPbF7Q6.js} +1 -1
  45. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js → ishikawaDiagram-UXIWVN3A-cp-ir90Y.js} +1 -1
  46. package/dist/assets/{journeyDiagram-VCZTEJTY-yJJzVEBb.js → journeyDiagram-VCZTEJTY-aoMcyNLC.js} +1 -1
  47. package/dist/assets/{jszip.min-ByI0ds5u.js → jszip.min-C7BBsGL5.js} +1 -1
  48. package/dist/assets/{kanban-definition-6JOO6SKY-2rfTi2zw.js → kanban-definition-6JOO6SKY-Cugr0o0f.js} +1 -1
  49. package/dist/assets/{layout-DTmHj0Wm.js → layout-CL0eiocE.js} +1 -1
  50. package/dist/assets/{linear-BOA3a9FE.js → linear-D6EJvwNs.js} +1 -1
  51. package/dist/assets/{mermaid.core-5H-NR79H.js → mermaid.core-CNYcCP19.js} +2 -2
  52. package/dist/assets/{min-CpjFTEec.js → min-dFBWKmZ_.js} +1 -1
  53. package/dist/assets/{mindmap-definition-QFDTVHPH-B8KCjFgB.js → mindmap-definition-QFDTVHPH-4fbr7E2n.js} +1 -1
  54. package/dist/assets/{pieDiagram-DEJITSTG-BARy96g5.js → pieDiagram-DEJITSTG-UHWzU_JW.js} +1 -1
  55. package/dist/assets/{quadrantDiagram-34T5L4WZ-DYoTapuI.js → quadrantDiagram-34T5L4WZ-e87bZkAb.js} +1 -1
  56. package/dist/assets/{requirementDiagram-MS252O5E-EeANLLK2.js → requirementDiagram-MS252O5E-l_97YNSi.js} +1 -1
  57. package/dist/assets/{sankeyDiagram-XADWPNL6-DmQ-YVPj.js → sankeyDiagram-XADWPNL6-D3JFnltO.js} +1 -1
  58. package/dist/assets/seqResourceLoaders-8kfGbCYo.js +2 -0
  59. package/dist/assets/seqResourceLoaders-C2YGihCH.css +41 -0
  60. package/dist/assets/{sequenceDiagram-FGHM5R23-SZtW0rYC.js → sequenceDiagram-FGHM5R23-5jDKcsRZ.js} +1 -1
  61. package/dist/assets/{stateDiagram-FHFEXIEX-CEygsIld.js → stateDiagram-FHFEXIEX-D5_gBKn8.js} +1 -1
  62. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js → stateDiagram-v2-QKLJ7IA2-BP5uQ5EH.js} +1 -1
  63. package/dist/assets/{timeline-definition-GMOUNBTQ-DhPBaQBt.js → timeline-definition-GMOUNBTQ-7IVQRwK1.js} +1 -1
  64. package/dist/assets/vendor-antd-CfuxfYfy.js +17 -0
  65. package/dist/assets/vendor-codemirror-C4Jn4TTc.js +1 -0
  66. package/dist/assets/{vendor-mdxeditor-Cz09a1_I.js → vendor-mdxeditor-BreKt5Hd.js} +2 -2
  67. package/dist/assets/{vendor-qrcode-qbbntD6-.js → vendor-qrcode-CutTqXgh.js} +1 -1
  68. package/dist/assets/{vendor-virtuoso-Sk8fQ4Hx.js → vendor-virtuoso-Cc9ovCDf.js} +1 -1
  69. package/dist/assets/{vennDiagram-DHZGUBPP-NniTHxDw.js → vennDiagram-DHZGUBPP-D6qzGquN.js} +1 -1
  70. package/dist/assets/{wardley-RL74JXVD-D9SNOrDw.js → wardley-RL74JXVD-DzAFqZmY.js} +1 -1
  71. package/dist/assets/{wardleyDiagram-NUSXRM2D-DvHXKNza.js → wardleyDiagram-NUSXRM2D-CImP9kv8.js} +1 -1
  72. package/dist/assets/{xychartDiagram-5P7HB3ND-CaiwPTQB.js → xychartDiagram-5P7HB3ND-Co45rRaM.js} +1 -1
  73. package/dist/index.html +4 -4
  74. package/package.json +1 -1
  75. package/plugins/README.md +9 -1
  76. package/server/interceptor.js +1 -1
  77. package/server/lib/builtin-model-prompts.js +1 -1
  78. package/server/lib/chat-queue.js +1 -1
  79. package/server/lib/credential-access.js +1 -1
  80. package/server/lib/credential-migrate.js +1 -1
  81. package/server/lib/error-report.js +26 -0
  82. package/server/lib/json-store.js +1 -1
  83. package/server/lib/launch-config.js +1 -1
  84. package/server/lib/log-watcher.js +1 -1
  85. package/server/lib/plugin-host.mjs +198 -0
  86. package/server/lib/plugin-loader.js +346 -90
  87. package/server/lib/plugin-manager.js +46 -2
  88. package/server/lib/plugin-manifest.js +329 -0
  89. package/server/lib/plugin-panels.js +183 -0
  90. package/server/lib/plugin-proxy.js +408 -0
  91. package/server/lib/plugin-runtime.js +775 -0
  92. package/server/lib/plugin-service-manager.js +463 -0
  93. package/server/lib/proxy/proxy-retry.js +1 -1
  94. package/server/lib/proxy/proxy-stats.js +1 -1
  95. package/server/lib/resume-watchdog.js +1 -1
  96. package/server/lib/same-origin.js +13 -3
  97. package/server/lib/sdk-manager.js +16 -9
  98. package/server/lib/sdk-session-index.js +1 -1
  99. package/server/lib/stats-worker.js +1 -1
  100. package/server/lib/system-prompt-files.js +1 -1
  101. package/server/lib/system-prompt-live.js +1 -1
  102. package/server/lib/system-prompt-snapshots.js +1 -1
  103. package/server/lib/v2/adapter.js +1 -1
  104. package/server/lib/v2/convert.js +1 -1
  105. package/server/lib/v2/jsonl-read.js +1 -1
  106. package/server/lib/v2/live-feed.js +1 -1
  107. package/server/lib/v2/meta-rows.js +1 -1
  108. package/server/lib/v2/resume-list.js +1 -1
  109. package/server/lib/v2/session-list.js +1 -1
  110. package/server/lib/v2/session-owner.js +1 -1
  111. package/server/lib/v2/v2-writer.js +1 -1
  112. package/server/lib/wire-compress.js +1 -1
  113. package/server/lib/ws-mux.js +1 -1
  114. package/server/proxy.js +1 -1
  115. package/server/pty-manager.js +1 -1
  116. package/server/routes/ask-perm.js +1 -1
  117. package/server/routes/events.js +1 -1
  118. package/server/routes/expert.js +1 -1
  119. package/server/routes/files-content.js +1 -1
  120. package/server/routes/files-fs.js +4 -2
  121. package/server/routes/misc.js +11 -0
  122. package/server/routes/plugin-services.js +104 -0
  123. package/server/routes/plugins.js +145 -17
  124. package/server/routes/preferences.js +1 -1
  125. package/server/routes/resume.js +1 -1
  126. package/server/routes/sdk-context.js +1 -1
  127. package/server/routes/sdk-mcp-status.js +1 -1
  128. package/server/routes/workspaces.js +1 -1
  129. package/server/server.js +328 -171
  130. package/dist/assets/App-CLteNB83.js +0 -2
  131. package/dist/assets/Mobile-CJCQf3MQ.js +0 -1
  132. package/dist/assets/ProxyStatsModal-B5ccoOPy.js +0 -1
  133. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +0 -1
  134. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +0 -1
  135. package/dist/assets/clone-CFj55h49.js +0 -1
  136. package/dist/assets/index-CqX5TkEL.js +0 -2
  137. package/dist/assets/seqResourceLoaders-7lsgbvGa.js +0 -2
  138. package/dist/assets/seqResourceLoaders-C0cU2e1o.css +0 -41
  139. package/dist/assets/vendor-antd-CS_S2mBN.js +0 -17
  140. package/dist/assets/vendor-codemirror-B5l-VbBO.js +0 -1
@@ -0,0 +1,775 @@
1
+ // Plugin runtime: lifecycle orchestration for directory (service/panel) plugins.
2
+ // Owns the mount table used by the proxy routes, per-plugin service status, and the
3
+ // activate/deactivate/onError lifecycle on top of plugin-loader's scan results.
4
+ //
5
+ // Single-flight model: reload()/unloadPlugin()/restartPlugin() all serialize through one
6
+ // queue. Every callback (spawn/activate/exit) checks the runtime generation and the
7
+ // stopping flag so a late event from a previous generation can never resurrect a plugin.
8
+ //
9
+ // Layering: L1-lib. Everything this module needs from server.js (port, tokens, httpServer,
10
+ // isStopping...) arrives via the ctx object passed to onServerStarted — never imported.
11
+ import { randomBytes } from 'node:crypto';
12
+ import { mkdirSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import {
15
+ loadPlugins,
16
+ getPluginEntries,
17
+ registerEventDispatcherPlugin,
18
+ } from './plugin-loader.js';
19
+ import {
20
+ spawnServiceProcess,
21
+ waitForServiceReady,
22
+ stopServiceProcess,
23
+ cleanupStaleServiceLocks,
24
+ createRestartBackoff,
25
+ } from './plugin-service-manager.js';
26
+ import { createProxyTracker, createPluginAgent, proxyHttpRequest, proxyUpgrade } from './plugin-proxy.js';
27
+ import { reportSwallowed } from './error-report.js';
28
+
29
+ const ACTIVATE_TIMEOUT_MS = 2000;
30
+ const DEACTIVATE_TIMEOUT_MS = 1500;
31
+ const START_GRACE_MS = 3000;
32
+ const BOOT_NONCE = randomBytes(8).toString('hex');
33
+
34
+ // Injectable service-process dependencies: the runtime's failure arms (blocked spawn,
35
+ // health timeout, crash/exit, stop during restart) are otherwise unreachable without
36
+ // spawning real children. Tests override these; production keeps the manager defaults.
37
+ const RUNTIME_DEP_DEFAULTS = Object.freeze({
38
+ spawnServiceProcess,
39
+ waitForServiceReady,
40
+ stopServiceProcess,
41
+ cleanupStaleServiceLocks,
42
+ createRestartBackoff,
43
+ });
44
+ let _runtimeDeps = { ...RUNTIME_DEP_DEFAULTS };
45
+
46
+ export function _setRuntimeDepsForTest(overrides = {}) {
47
+ _runtimeDeps = { ..._runtimeDeps, ...overrides };
48
+ }
49
+
50
+ let _started = false;
51
+ let _stopping = false;
52
+ let _stoppingPromise = null;
53
+ let _generation = 0;
54
+ let _ctx = null; // server-injected context (see onServerStarted)
55
+ let _queue = Promise.resolve(); // single-flight queue
56
+ const _plugins = new Map(); // name -> RuntimePlugin
57
+ const _tracker = createProxyTracker();
58
+
59
+ function enqueue(fn) {
60
+ const run = _queue.then(fn, fn);
61
+ _queue = run.catch((err) => reportSwallowed('plugin-runtime.queue', err));
62
+ return run;
63
+ }
64
+
65
+ function withTimeout(promise, ms, label) {
66
+ let timer;
67
+ return Promise.race([
68
+ promise.finally(() => clearTimeout(timer)),
69
+ new Promise((_, reject) => {
70
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
71
+ timer.unref?.();
72
+ }),
73
+ ]);
74
+ }
75
+
76
+ function emptyStatus() {
77
+ return {
78
+ state: 'pending', // pending|starting|ready|active|restarting|crashed|stopping|stopped|blocked|error
79
+ pid: null,
80
+ port: null,
81
+ restarts: 0,
82
+ lastError: null,
83
+ startedAt: null,
84
+ };
85
+ }
86
+
87
+ function getRuntimePlugin(name) {
88
+ return _plugins.get(name) || null;
89
+ }
90
+
91
+ /** Live service object updated in place (the activate ctx holds this reference across restarts) */
92
+ class ServiceHandle {
93
+ constructor(rt) {
94
+ this._rt = rt;
95
+ this.prefix = rt.entry?.service?.prefix ?? null;
96
+ }
97
+ get port() { return this._rt.status.port; }
98
+ get pid() { return this._rt.status.pid; }
99
+ get baseUrl() { return this._rt.status.port ? `http://127.0.0.1:${this._rt.status.port}` : null; }
100
+ get secret() { return this._rt.child?.secret ?? null; }
101
+ status() { return { ...this._rt.status }; }
102
+ restart() { return restartPlugin(this._rt.name); }
103
+ }
104
+
105
+ function makeLogger(name) {
106
+ const tag = `[plugin:${name}]`;
107
+ return {
108
+ debug: (...a) => { if (process.env.CCV_DEBUG_PLUGINS === '1') console.error(tag, ...a); },
109
+ info: (...a) => console.log(tag, ...a),
110
+ warn: (...a) => console.warn(tag, ...a),
111
+ error: (...a) => console.error(tag, ...a),
112
+ };
113
+ }
114
+
115
+ function callLifecycle(rt, hook, args, timeoutMs, phase) {
116
+ const fn = rt.entry?.plugin?.[hook];
117
+ if (typeof fn !== 'function') return Promise.resolve();
118
+ return withTimeout(Promise.resolve().then(() => fn(...args)), timeoutMs, `${hook} of ${rt.name}`)
119
+ .catch((err) => {
120
+ notifyError(rt, err, { phase });
121
+ });
122
+ }
123
+
124
+ function notifyError(rt, err, info) {
125
+ rt.status.lastError = `${info.phase}: ${err && err.message || err}`;
126
+ const fn = rt.entry?.plugin?.onError;
127
+ if (typeof fn === 'function') {
128
+ try {
129
+ Promise.resolve(fn(err, info)).catch((e) => reportSwallowed('plugin-runtime.onerror', e));
130
+ } catch (e) {
131
+ reportSwallowed('plugin-runtime.onerror', e);
132
+ }
133
+ } else {
134
+ reportSwallowed('plugin-runtime.error', err, { plugin: rt.name, phase: info.phase });
135
+ }
136
+ }
137
+
138
+ function currentMountTarget(rt) {
139
+ if (!rt.child || !rt.status.port) return null;
140
+ // basePath always has a trailing slash (normalizeBasePath) - strip it before joining to avoid '//plugins/x'
141
+ const basePath = (_ctx?.basePath || '').replace(/\/$/, '');
142
+ const externalPrefix = `${basePath}${rt.entry.service.prefix}`;
143
+ return {
144
+ name: rt.name,
145
+ port: rt.status.port,
146
+ secret: rt.child.secret,
147
+ externalPrefix,
148
+ cookiePath: `${externalPrefix}/`,
149
+ protocol: _ctx?.protocol || 'http',
150
+ agent: rt.agent,
151
+ };
152
+ }
153
+
154
+ // ---- mount table ----
155
+
156
+ function findMountByPath(pathname) {
157
+ // pathname already has basePath stripped (handled at the top of handleRequest)
158
+ if (!pathname.startsWith('/plugins/')) return null;
159
+ const rest = pathname.slice('/plugins/'.length);
160
+ const slash = rest.indexOf('/');
161
+ const name = slash === -1 ? rest : rest.slice(0, slash);
162
+ const rt = _plugins.get(name);
163
+ if (!rt || !rt.entry?.service || !rt.entry.enabled) return null;
164
+ const prefix = rt.entry.service.prefix; // '/plugins/<name>' (validated by the manifest)
165
+ if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) return null;
166
+ return rt;
167
+ }
168
+
169
+ export function hasAnyMountPrefix(pathname) {
170
+ return findMountByPath(pathname) !== null;
171
+ }
172
+
173
+ // ---- HTTP proxy entry (called from routes/plugin-services.js) ----
174
+
175
+ export function tryProxyHttp(req, res, parsedUrl) {
176
+ const pathname = parsedUrl.pathname;
177
+ const rt = findMountByPath(pathname);
178
+ if (!rt) return false;
179
+ const prefix = rt.entry.service.prefix;
180
+ if (pathname === prefix) {
181
+ // 308 to the trailing-slash form (Location carries basePath + query, no-store)
182
+ const location = `${(_ctx?.basePath || '').replace(/\/$/, '')}${prefix}/${parsedUrl.search || ''}`;
183
+ res.writeHead(308, { Location: location, 'Cache-Control': 'no-store' });
184
+ res.end();
185
+ return true;
186
+ }
187
+ if (rt.status.state === 'stopping' || rt.status.state === 'stopped') {
188
+ res.writeHead(503, { 'Content-Type': 'application/json' });
189
+ res.end(JSON.stringify({ error: `plugin "${rt.name}" is stopping` }));
190
+ return true;
191
+ }
192
+ const target = currentMountTarget(rt);
193
+ if (!target || rt.status.state !== 'ready') {
194
+ res.writeHead(503, { 'Content-Type': 'application/json' });
195
+ res.end(JSON.stringify({ error: `plugin "${rt.name}" is not ready`, state: rt.status.state, lastError: rt.status.lastError }));
196
+ return true;
197
+ }
198
+ proxyHttpRequest(req, res, {
199
+ target,
200
+ match: { forwardPath: pathname.slice(prefix.length) || '/', search: parsedUrl.search || '' },
201
+ tracker: _tracker,
202
+ });
203
+ return true;
204
+ }
205
+
206
+ export function tryProxyUpgrade(req, socket, head, parsedUrl) {
207
+ const pathname = parsedUrl ? parsedUrl.pathname : new URL(req.url, 'http://localhost').pathname;
208
+ const rt = findMountByPath(pathname);
209
+ if (!rt) return false;
210
+ // Attach client socket error/close synchronously before entering the async section
211
+ socket.on('error', (err) => reportSwallowed('plugin-runtime.ws-client', err));
212
+ const prefix = rt.entry.service.prefix;
213
+ const target = currentMountTarget(rt);
214
+ if (!target || rt.status.state !== 'ready') {
215
+ try {
216
+ socket.write(`HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n${JSON.stringify({ error: `plugin "${rt.name}" is not ready` })}`);
217
+ } catch { /* noop */ }
218
+ socket.destroy();
219
+ return true;
220
+ }
221
+ proxyUpgrade(req, socket, head, {
222
+ target,
223
+ match: { forwardPath: pathname.slice(prefix.length) || '/', search: parsedUrl ? (parsedUrl.search || '') : '' },
224
+ tracker: _tracker,
225
+ });
226
+ return true;
227
+ }
228
+
229
+ // Stop a child we spawned. stopServiceProcess only signals a PID when the lock proves
230
+ // ownership (PID-reuse safety); when it had to skip the signal we still hold the
231
+ // ChildProcess handle, whose identity cannot be a reused PID, so kill it directly to
232
+ // avoid leaking a detached orphan.
233
+ async function stopOwnedService(rt, spawned, timeoutMs = 1500) {
234
+ const result = await _runtimeDeps.stopServiceProcess(rt.name, {
235
+ lockPath: spawned.lockPath,
236
+ configPath: spawned.configPath,
237
+ pid: spawned.pid,
238
+ nonce: spawned.nonce,
239
+ timeoutMs,
240
+ });
241
+ if (result?.skippedSignal) {
242
+ const child = spawned.child;
243
+ if (child && typeof child.kill === 'function' && child.exitCode == null) {
244
+ try { child.kill('SIGKILL'); } catch (err) { reportSwallowed('plugin-runtime.orphan-kill', err); }
245
+ }
246
+ }
247
+ return result;
248
+ }
249
+
250
+ // ---- Lifecycle: start/stop of one service plugin ----
251
+ // Epoch scheme (prevents double children on restart/exit races): every start/stop/restart
252
+ // bumps rt.epoch; callbacks capture the epoch and spawned handle and are dropped on mismatch.
253
+
254
+ async function startServicePlugin(rt, generation) {
255
+ // Refuse once shutdown has begun: onServerStopping sweeps `_plugins` in order, so a
256
+ // spawn accepted after that sweep would never be visited and its child would leak.
257
+ if (_stopping || !_started || rt.stopping || generation !== _generation) return false;
258
+ const epoch = ++rt.epoch;
259
+ const desc = {
260
+ name: rt.name,
261
+ version: rt.entry.manifest?.version,
262
+ entryPath: rt.entry.service.entryPath,
263
+ pluginDir: rt.entry.pluginDir,
264
+ prefix: rt.entry.service.prefix,
265
+ env: rt.entry.service.env,
266
+ };
267
+ rt.status.state = rt.status.restarts > 0 ? 'restarting' : 'starting';
268
+ rt.status.lastError = null;
269
+
270
+ const spawned = _runtimeDeps.spawnServiceProcess(desc, {
271
+ logDir: _ctx.logDir,
272
+ parentPort: _ctx.port,
273
+ parentBasePath: _ctx.basePath,
274
+ generation,
275
+ serverPid: process.pid,
276
+ bootNonce: BOOT_NONCE,
277
+ });
278
+ if (spawned.blockedByTestGuard) {
279
+ rt.status.state = 'blocked';
280
+ rt.status.lastError = 'blocked by test guard';
281
+ return false;
282
+ }
283
+ rt.child = spawned;
284
+ rt.backoff.markStarted();
285
+
286
+ // The spawn may straddle a stop/reload: never leave a freshly spawned child behind.
287
+ if (_stopping || !_started || rt.stopping || generation !== _generation || rt.epoch !== epoch) {
288
+ await stopOwnedService(rt, spawned);
289
+ if (rt.child === spawned) rt.child = null;
290
+ return false;
291
+ }
292
+
293
+ let childDead = false;
294
+ const child = spawned.child;
295
+ if (child && typeof child.on === 'function') {
296
+ child.on('error', (err) => {
297
+ if (rt.epoch !== epoch || rt.child !== spawned) return; // stale event
298
+ notifyError(rt, err, { phase: 'spawn', pid: spawned.pid });
299
+ });
300
+ child.on('exit', (code, signal) => {
301
+ childDead = true;
302
+ // Late exits (after stop/restart) must not trigger backoff restarts - prevents double children
303
+ if (rt.epoch !== epoch || rt.child !== spawned) return;
304
+ onServiceExit(rt, generation, code, signal).catch((err) => reportSwallowed('plugin-runtime.exit', err));
305
+ });
306
+ }
307
+
308
+ const ready = await _runtimeDeps.waitForServiceReady(desc, {
309
+ lockPath: spawned.lockPath,
310
+ secret: spawned.secret,
311
+ nonce: spawned.nonce,
312
+ expectedPid: spawned.pid,
313
+ isChildDead: () => childDead,
314
+ timeoutMs: 10000,
315
+ });
316
+ if (generation !== _generation || _stopping || !_started || rt.stopping || rt.epoch !== epoch) {
317
+ // Stopped/reloaded while the health probe was in flight — kill our own child here;
318
+ // the shutdown sweep may already have moved past this runtime entry.
319
+ await stopOwnedService(rt, spawned);
320
+ if (rt.child === spawned) rt.child = null;
321
+ return false;
322
+ }
323
+ if (!ready.ok) {
324
+ notifyError(rt, new Error(ready.error), { phase: 'health', pid: spawned.pid });
325
+ await stopOwnedService(rt, spawned);
326
+ // Health-failure path: stopServiceProcess removed this generation's config (the
327
+ // only place the secret lives) and lock; reset the child handle.
328
+ if (rt.epoch === epoch && rt.child === spawned) rt.child = null;
329
+ rt.status.state = 'error';
330
+ return false;
331
+ }
332
+
333
+ rt.status.state = 'ready';
334
+ rt.status.port = ready.port;
335
+ rt.status.pid = ready.pid;
336
+ rt.status.startedAt = new Date().toISOString();
337
+ rt.agent = rt.agent || createPluginAgent();
338
+ return true;
339
+ }
340
+
341
+ async function onServiceExit(rt, generation, code, signal) {
342
+ if (generation !== _generation || _stopping || rt.stopping) return;
343
+ // Capture this generation's identifiers ONCE. Every await below can overlap a
344
+ // concurrent restart/reload (stopServicePlugin bumps rt.epoch and rt.child may be a
345
+ // brand-new child by the time we resume); re-reading after an await would let this
346
+ // cleanup kill the replacement child or deactivate it a second time.
347
+ const exitEpoch = rt.epoch;
348
+ const crashedChild = rt.child;
349
+ rt.status.port = null;
350
+ rt.status.pid = null;
351
+ rt.status.state = 'restarting';
352
+ if (rt.agent) { try { rt.agent.destroy(); } catch { /* noop */ } rt.agent = null; }
353
+ notifyError(rt, new Error(`service exited (code=${code} signal=${signal})`), { phase: 'service-exit', exitCode: code, signal });
354
+
355
+ // Tear down the crashed generation before scheduling a replacement:
356
+ // - aborting the controller unsubscribes events.on so the reboot cannot double-deliver;
357
+ // - deactivate balances the previous activate (subscriptions/resources rebuilt later);
358
+ // - stopServiceProcess removes the stale lock and this generation's config, the only
359
+ // place the plugin secret lives (a child that died does not clean up after itself).
360
+ rt.controller?.abort();
361
+ rt.controller = null;
362
+ const wasActivated = rt.activated;
363
+ if (wasActivated) {
364
+ // Close the double-deactivate window before awaiting: a concurrent restart must see
365
+ // this generation as already deactivated.
366
+ rt.activated = false;
367
+ await callLifecycle(rt, 'deactivate', ['service-exit'], DEACTIVATE_TIMEOUT_MS, 'deactivate');
368
+ // A restart/reload took over during deactivate and owns the runtime entry now.
369
+ if (rt.epoch !== exitEpoch) return;
370
+ }
371
+ if (crashedChild && rt.child === crashedChild) {
372
+ // The child is already dead, so the PID-identity guard in stopServiceProcess keeps
373
+ // this to file cleanup — it never signals a reused PID.
374
+ await stopOwnedService(rt, crashedChild);
375
+ if (rt.child === crashedChild) rt.child = null;
376
+ }
377
+
378
+ // A concurrent stop/reload may have taken over while cleanup awaited.
379
+ if (generation !== _generation || _stopping || rt.stopping || rt.epoch !== exitEpoch) return;
380
+ const { allowed, delayMs } = rt.backoff.next();
381
+ if (!allowed) {
382
+ rt.status.state = 'crashed';
383
+ return;
384
+ }
385
+ rt.restartTimer = setTimeout(() => {
386
+ rt.restartTimer = null;
387
+ if (generation !== _generation || _stopping || !_started || rt.stopping || rt.epoch !== exitEpoch) return;
388
+ enqueue(async () => {
389
+ if (generation !== _generation || _stopping || !_started || rt.stopping || rt.epoch !== exitEpoch) return;
390
+ rt.status.restarts += 1;
391
+ const ok = await startServicePlugin(rt, generation);
392
+ // A successfully restarted service must re-run activate: the crash path called
393
+ // deactivate, so the subscriptions/state torn down there have to be rebuilt.
394
+ if (ok && wasActivated && typeof rt.entry?.plugin?.activate === 'function') {
395
+ rt.activated = true;
396
+ await callLifecycle(rt, 'activate', [buildPluginContext(rt)], ACTIVATE_TIMEOUT_MS, 'activate');
397
+ }
398
+ });
399
+ }, delayMs);
400
+ rt.restartTimer.unref?.();
401
+ }
402
+
403
+ async function stopServicePlugin(rt, reason) {
404
+ rt.stopping = true;
405
+ rt.status.state = 'stopping';
406
+ rt.epoch += 1; // invalidate every late callback from this generation (restart/exit race guard)
407
+ if (rt.restartTimer) { clearTimeout(rt.restartTimer); rt.restartTimer = null; }
408
+ // Abort the signal first so event subscriptions unsubscribe (no events during deactivate)
409
+ rt.controller?.abort();
410
+
411
+ // P0-5 ordering: send SIGTERM synchronously, then run deactivate in parallel
412
+ const child = rt.child;
413
+ const sigtermPromise = (async () => {
414
+ if (!child) return;
415
+ await stopOwnedService(rt, child);
416
+ })();
417
+
418
+ const deactivatePromise = callLifecycle(rt, 'deactivate', [reason], DEACTIVATE_TIMEOUT_MS, 'deactivate');
419
+ _tracker.destroyAll(rt.name);
420
+ if (rt.agent) { try { rt.agent.destroy(); } catch { /* noop */ } rt.agent = null; }
421
+
422
+ await Promise.allSettled([sigtermPromise, deactivatePromise]);
423
+ rt.child = null;
424
+ rt.status.port = null;
425
+ rt.status.pid = null;
426
+ rt.status.state = 'stopped';
427
+ rt.controller = null;
428
+ rt.activated = false;
429
+ }
430
+
431
+ // ---- Public runtime API ----
432
+
433
+ export async function onServerStarted(ctx) {
434
+ _ctx = {
435
+ logDir: ctx.logDir,
436
+ port: ctx.port,
437
+ host: ctx.host,
438
+ basePath: ctx.basePath || '',
439
+ protocol: ctx.protocol || 'http',
440
+ token: ctx.token,
441
+ internalToken: ctx.internalToken,
442
+ httpServer: ctx.httpServer,
443
+ pty: ctx.pty ?? null,
444
+ interactions: ctx.interactions ?? null,
445
+ isStopping: ctx.isStopping || (() => false),
446
+ };
447
+ _stopping = false;
448
+ _started = true;
449
+ _generation += 1;
450
+ const generation = _generation;
451
+ ensureEventDispatcherRegistered();
452
+
453
+ await _runtimeDeps.cleanupStaleServiceLocks(ctx.logDir, { bootNonce: BOOT_NONCE });
454
+
455
+ // Reload while the main process is already running: entries come from the last loadPlugins
456
+ await activateAll(generation, { graceMs: START_GRACE_MS });
457
+ }
458
+
459
+ function buildPluginContext(rt) {
460
+ const manifest = rt.entry.manifest;
461
+ const serviceHandle = rt.entry.service ? new ServiceHandle(rt) : null;
462
+ const controller = new AbortController();
463
+ const signal = controller.signal;
464
+ rt.controller = controller;
465
+ const config = { ...(manifest?.configDefaults || {}) };
466
+ const dataDir = rt.dataDir;
467
+ return {
468
+ name: rt.name,
469
+ version: manifest?.version ?? null,
470
+ pluginDir: rt.entry.pluginDir,
471
+ dataDir,
472
+ basePath: _ctx.basePath,
473
+ server: {
474
+ port: _ctx.port,
475
+ host: _ctx.host,
476
+ protocol: _ctx.protocol,
477
+ url: `${_ctx.protocol}://127.0.0.1:${_ctx.port}${_ctx.basePath}`,
478
+ ip: null,
479
+ token: _ctx.token,
480
+ internalToken: _ctx.internalToken,
481
+ httpServer: _ctx.httpServer,
482
+ // Read through getters: server.js fills pty/interactions via _enrichContext after activate
483
+ get pty() { return _ctx.pty; },
484
+ get interactions() { return _ctx.interactions; },
485
+ },
486
+ service: serviceHandle,
487
+ panel: rt.entry.panel ? { url: `/plugin-panels/${rt.name}/`, title: rt.entry.panel.title || rt.name } : null,
488
+ config,
489
+ logger: makeLogger(rt.name),
490
+ events: {
491
+ on(event, cb) {
492
+ // Subscriptions go into the runtime dispatcher table (injected into the loader pipeline via
493
+ // getEventDispatcherPlugin); abort on deactivate/unload unsubscribes. No second event bus.
494
+ const hookName = event === 'new_entry' ? 'onNewEntry' : event === 'stream_chunk' ? 'onStreamChunk' : null;
495
+ if (!hookName || typeof cb !== 'function') return () => {};
496
+ const wrapper = (payload) => { if (!signal.aborted) cb(payload); };
497
+ _eventSubs[hookName].add(wrapper);
498
+ const unsubscribe = () => { _eventSubs[hookName].delete(wrapper); };
499
+ if (signal.aborted) unsubscribe();
500
+ else signal.addEventListener('abort', unsubscribe, { once: true });
501
+ return unsubscribe;
502
+ },
503
+ },
504
+ signal,
505
+ isStopping: () => _stopping || rt.stopping || _ctx.isStopping(),
506
+ };
507
+ }
508
+
509
+ // Events dispatcher: the subscription table lives in the runtime; getEventDispatcherPlugin()
510
+ // returns the plugin object that bridges into the loader hook pipeline (no second event bus).
511
+ const _eventDispatcher = {
512
+ name: 'ccv-plugin-events',
513
+ hooks: {
514
+ onNewEntry(payload) { dispatchEvent('onNewEntry', payload); },
515
+ onStreamChunk(payload) { dispatchEvent('onStreamChunk', payload); },
516
+ },
517
+ };
518
+ const _eventSubs = { onNewEntry: new Set(), onStreamChunk: new Set() };
519
+ function dispatchEvent(hookName, payload) {
520
+ for (const fn of _eventSubs[hookName]) {
521
+ try { fn(payload); } catch (err) { reportSwallowed('plugin-runtime.event', err); }
522
+ }
523
+ }
524
+ let _dispatcherRegistered = false;
525
+ export function getEventDispatcherPlugin() {
526
+ _dispatcherRegistered = true;
527
+ return _eventDispatcher;
528
+ }
529
+ export function isEventDispatcherRegistered() {
530
+ return _dispatcherRegistered;
531
+ }
532
+
533
+ // Register the dispatcher into the loader hook pipeline (_plugins is rebuilt by every
534
+ // loadPlugins; the loader re-attaches it at the end of each scan). Call once on server start.
535
+ function ensureEventDispatcherRegistered() {
536
+ registerEventDispatcherPlugin(_eventDispatcher);
537
+ _dispatcherRegistered = true;
538
+ }
539
+
540
+ async function activateAll(generation, { graceMs = 0 } = {}) {
541
+ const entries = getPluginEntries();
542
+ const tasks = [];
543
+ for (const entry of entries) {
544
+ if (!entry.enabled || entry.loadError) continue;
545
+ if (entry.shape !== 'dir') {
546
+ // Single-file hook plugins may also implement activate/deactivate/onError
547
+ if (entry.plugin && (typeof entry.plugin.activate === 'function')) {
548
+ const rt = ensureRuntimePlugin(entry);
549
+ tasks.push(activatePlugin(rt, generation));
550
+ }
551
+ continue;
552
+ }
553
+ // Only create a runtime for directory plugins with a service or a lifecycle (panel-only/
554
+ // hook-only plugins must not carry a forever-pending runtime status)
555
+ const hasLifecycle = entry.plugin && (
556
+ typeof entry.plugin.activate === 'function' ||
557
+ typeof entry.plugin.deactivate === 'function' ||
558
+ typeof entry.plugin.onError === 'function'
559
+ );
560
+ if (!entry.service && !hasLifecycle) continue;
561
+ const rt = ensureRuntimePlugin(entry);
562
+ tasks.push(activatePlugin(rt, generation));
563
+ }
564
+ const all = Promise.allSettled(tasks);
565
+ if (graceMs > 0) {
566
+ // Start grace period: after the timeout keep going in the background without blocking the main service (unref timer)
567
+ let timer;
568
+ await Promise.race([all, new Promise(r => { timer = setTimeout(r, graceMs); timer.unref?.(); })]);
569
+ clearTimeout(timer);
570
+ } else {
571
+ await all;
572
+ }
573
+ }
574
+
575
+ function ensureRuntimePlugin(entry) {
576
+ let rt = _plugins.get(entry.name);
577
+ if (!rt) {
578
+ rt = {
579
+ name: entry.name,
580
+ entry,
581
+ status: emptyStatus(),
582
+ child: null,
583
+ agent: null,
584
+ backoff: _runtimeDeps.createRestartBackoff(),
585
+ stopping: false,
586
+ activated: false,
587
+ restartTimer: null,
588
+ controller: null,
589
+ dataDir: null,
590
+ epoch: 0, // restart/exit race guard: start/stop bump it; callbacks capture and compare
591
+ };
592
+ _plugins.set(entry.name, rt);
593
+ }
594
+ rt.entry = entry;
595
+ rt.stopping = false;
596
+ return rt;
597
+ }
598
+
599
+ async function activatePlugin(rt, generation) {
600
+ if (generation !== _generation || _stopping) return;
601
+ try {
602
+ // dataDir is created for every directory plugin (panel/hook-only plugins need persistent data too)
603
+ if (rt.entry.shape === 'dir') {
604
+ rt.dataDir = join(_ctx.logDir, 'plugin-data', rt.name);
605
+ mkdirSync(rt.dataDir, { recursive: true, mode: 0o700 });
606
+ }
607
+ // Service plugin: spawn the child first, then activate
608
+ if (rt.entry.service) {
609
+ if (process.env.CCV_IM_PLATFORM) {
610
+ rt.status.state = 'blocked';
611
+ rt.status.lastError = 'skipped in IM worker process';
612
+ return;
613
+ }
614
+ const ok = await startServicePlugin(rt, generation);
615
+ if (!ok) return;
616
+ }
617
+ if (rt.entry.plugin && typeof rt.entry.plugin.activate === 'function') {
618
+ const ctx = buildPluginContext(rt);
619
+ rt.activated = true;
620
+ await callLifecycle(rt, 'activate', [ctx], ACTIVATE_TIMEOUT_MS, 'activate');
621
+ }
622
+ // Lifecycle-only directory plugins have no service and no panel state to report;
623
+ // mark them active instead of leaving a misleading forever-'pending' status.
624
+ if (!rt.entry.service) rt.status.state = 'active';
625
+ } catch (err) {
626
+ notifyError(rt, err, { phase: 'activate' });
627
+ }
628
+ }
629
+
630
+ export async function onServerStopping() {
631
+ if (_stoppingPromise) return _stoppingPromise;
632
+ _stopping = true;
633
+ _stoppingPromise = (async () => {
634
+ const generation = _generation;
635
+ const tasks = [];
636
+ for (const rt of _plugins.values()) {
637
+ if (rt.activated || rt.child) {
638
+ tasks.push(stopServicePlugin(rt, 'serverStopping'));
639
+ }
640
+ }
641
+ await Promise.allSettled(tasks);
642
+ _plugins.clear();
643
+ _started = false;
644
+ _generation = generation + 1;
645
+ })();
646
+ try {
647
+ await _stoppingPromise;
648
+ } finally {
649
+ _stoppingPromise = null;
650
+ }
651
+ }
652
+
653
+ /**
654
+ * reload (the single-flight entry): deactivate everything -> stop children -> loadPlugins ->
655
+ * spawn + activate. Degrades to a plain loadPlugins when the server is not started.
656
+ * opts.reason: 'reload' (default) | 'disable' - disable is a semantic label only (disable
657
+ * goes through preferences + reload; deactivate's reason should reflect user intent).
658
+ */
659
+ export function reloadRuntime(opts = {}) {
660
+ return enqueue(async () => {
661
+ // Write operations must not enqueue during the shutdown teardown window.
662
+ if (_stopping) return { ok: false, error: 'server is stopping', generation: _generation, started: _started, plugins: getRuntimePluginsInfo() };
663
+ const reason = opts.reason === 'disable' ? 'disable' : 'reload';
664
+ if (!_started) {
665
+ const r = await loadPlugins(opts);
666
+ return { ok: true, generation: r.generation, started: false, plugins: getRuntimePluginsInfo() };
667
+ }
668
+ const generation = _generation + 1;
669
+ // 1) Stop the previous generation
670
+ const stopTasks = [];
671
+ for (const rt of _plugins.values()) {
672
+ if (rt.activated || rt.child) {
673
+ stopTasks.push(stopServicePlugin(rt, reason));
674
+ }
675
+ }
676
+ await Promise.allSettled(stopTasks);
677
+ _plugins.clear();
678
+ _generation = generation;
679
+ // 2) Re-scan/import (fingerprint cache-bust)
680
+ await loadPlugins(opts);
681
+ // 3) Activate the new generation
682
+ await activateAll(generation);
683
+ return { ok: true, generation: _generation, started: true, plugins: getRuntimePluginsInfo() };
684
+ });
685
+ }
686
+
687
+ export function unloadPlugin(name, reason = 'delete') {
688
+ return enqueue(async () => {
689
+ if (_stopping) return { ok: false, error: 'server is stopping' };
690
+ const rt = _plugins.get(name);
691
+ if (!rt) return { ok: true, unloaded: false };
692
+ await stopServicePlugin(rt, reason);
693
+ _plugins.delete(name);
694
+ return { ok: true, unloaded: true };
695
+ });
696
+ }
697
+
698
+ export function restartPlugin(name) {
699
+ return enqueue(async () => {
700
+ // A restart accepted inside the stop window would spawn a child after the shutdown
701
+ // sweep has already passed this runtime entry (orphan process + permanent lock).
702
+ if (_stopping || !_started) return { ok: false, error: _stopping ? 'server is stopping' : 'plugin runtime is not started' };
703
+ const rt = _plugins.get(name);
704
+ if (!rt || !rt.entry?.service) return { ok: false, error: 'not a service plugin' };
705
+ await stopServicePlugin(rt, 'restart');
706
+ rt.stopping = false;
707
+ rt.backoff.reset();
708
+ const ok = await startServicePlugin(rt, _generation);
709
+ if (!ok) return { ok: false, status: { ...rt.status } };
710
+ // Re-run activate after restart: event subscriptions/signal/resources torn down in deactivate
711
+ // must be rebuilt (otherwise restart only reboots the child and leaves a half-dead plugin context).
712
+ if (rt.entry.plugin && typeof rt.entry.plugin.activate === 'function') {
713
+ rt.activated = true;
714
+ await callLifecycle(rt, 'activate', [buildPluginContext(rt)], ACTIVATE_TIMEOUT_MS, 'activate');
715
+ }
716
+ return { ok: true, status: { ...rt.status } };
717
+ });
718
+ }
719
+
720
+ // ---- Status queries (read by routes) ----
721
+
722
+ export function getRuntimePluginsInfo() {
723
+ const out = [];
724
+ for (const rt of _plugins.values()) {
725
+ out.push({
726
+ name: rt.name,
727
+ status: { ...rt.status },
728
+ hasService: !!rt.entry?.service,
729
+ hasPanel: !!rt.entry?.panel,
730
+ });
731
+ }
732
+ return out;
733
+ }
734
+
735
+ export function getRuntimePluginStatus(name) {
736
+ const rt = getRuntimePlugin(name);
737
+ return rt ? { ...rt.status } : null;
738
+ }
739
+
740
+ export function isRuntimeStarted() {
741
+ return _started;
742
+ }
743
+
744
+ export function isRuntimeStopping() {
745
+ return _stopping;
746
+ }
747
+
748
+ export function getRuntimeContext() {
749
+ return _ctx;
750
+ }
751
+
752
+ /**
753
+ * Called by server.js after ptyApi/interactions are built: fills in ctx references (fields
754
+ * are by-reference; they are null when buildPluginContext runs before enrichment).
755
+ */
756
+ export function _enrichContext({ pty, interactions }) {
757
+ if (!_ctx) return;
758
+ if (pty !== undefined) _ctx.pty = pty;
759
+ if (interactions !== undefined) _ctx.interactions = interactions;
760
+ }
761
+
762
+ // Test-only: reset all module state
763
+ export function _resetRuntimeForTest() {
764
+ _plugins.clear();
765
+ _tracker.destroyAll('*');
766
+ _started = false;
767
+ _stopping = false;
768
+ _stoppingPromise = null;
769
+ _generation = 0;
770
+ _ctx = null;
771
+ _queue = Promise.resolve();
772
+ _runtimeDeps = { ...RUNTIME_DEP_DEFAULTS };
773
+ for (const subs of Object.values(_eventSubs)) subs.clear();
774
+ _dispatcherRegistered = false;
775
+ }