benchgo-plugin 0.0.0-stage → 0.0.3

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 (180) hide show
  1. package/DEEPSEEK-LICENSE +21 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +17 -0
  4. package/PASEO-LICENSE +211 -0
  5. package/README.md +256 -2
  6. package/TOOL-CONFIG.md +33 -0
  7. package/VERIFICATION.md +297 -0
  8. package/assets/fonts/README.md +13 -0
  9. package/assets/fonts/manifest.json +437 -0
  10. package/assets/fonts/web/fangyuan-latin-238bc49130b1.woff2 +0 -0
  11. package/assets/fonts/web/fangyuan-u30-0fe8d99641c3.woff2 +0 -0
  12. package/assets/fonts/web/fangyuan-u32-202a573131a1.woff2 +0 -0
  13. package/assets/fonts/web/fangyuan-u4e-cc2f8b20be49.woff2 +0 -0
  14. package/assets/fonts/web/fangyuan-u4f-9189b5f2d321.woff2 +0 -0
  15. package/assets/fonts/web/fangyuan-u50-e2560e003872.woff2 +0 -0
  16. package/assets/fonts/web/fangyuan-u51-a9c5e7a8c7c4.woff2 +0 -0
  17. package/assets/fonts/web/fangyuan-u52-0cfd00b473c8.woff2 +0 -0
  18. package/assets/fonts/web/fangyuan-u53-18122a07f60a.woff2 +0 -0
  19. package/assets/fonts/web/fangyuan-u54-8ca41aa9cff2.woff2 +0 -0
  20. package/assets/fonts/web/fangyuan-u55-7bd55e14647e.woff2 +0 -0
  21. package/assets/fonts/web/fangyuan-u56-26f093e8a810.woff2 +0 -0
  22. package/assets/fonts/web/fangyuan-u57-36bde83a8378.woff2 +0 -0
  23. package/assets/fonts/web/fangyuan-u58-d1df14217098.woff2 +0 -0
  24. package/assets/fonts/web/fangyuan-u59-4e41ab22c51a.woff2 +0 -0
  25. package/assets/fonts/web/fangyuan-u5a-746a367174e6.woff2 +0 -0
  26. package/assets/fonts/web/fangyuan-u5b-3d1dd404d70c.woff2 +0 -0
  27. package/assets/fonts/web/fangyuan-u5c-fe0a273f69e7.woff2 +0 -0
  28. package/assets/fonts/web/fangyuan-u5d-af1f0a1c8d2d.woff2 +0 -0
  29. package/assets/fonts/web/fangyuan-u5e-598c65420e17.woff2 +0 -0
  30. package/assets/fonts/web/fangyuan-u5f-a92eaaa5265a.woff2 +0 -0
  31. package/assets/fonts/web/fangyuan-u60-48ee3d26462d.woff2 +0 -0
  32. package/assets/fonts/web/fangyuan-u61-7a57f35bc077.woff2 +0 -0
  33. package/assets/fonts/web/fangyuan-u62-d2d0b7b36631.woff2 +0 -0
  34. package/assets/fonts/web/fangyuan-u63-cade7c6635e6.woff2 +0 -0
  35. package/assets/fonts/web/fangyuan-u64-ed34e40d7f65.woff2 +0 -0
  36. package/assets/fonts/web/fangyuan-u65-e13e83e69c38.woff2 +0 -0
  37. package/assets/fonts/web/fangyuan-u66-1dbcfa5da147.woff2 +0 -0
  38. package/assets/fonts/web/fangyuan-u67-6b0a26b82da6.woff2 +0 -0
  39. package/assets/fonts/web/fangyuan-u68-1b8217cfceed.woff2 +0 -0
  40. package/assets/fonts/web/fangyuan-u69-673e63627790.woff2 +0 -0
  41. package/assets/fonts/web/fangyuan-u6a-3c94008a4640.woff2 +0 -0
  42. package/assets/fonts/web/fangyuan-u6b-44572c410fa0.woff2 +0 -0
  43. package/assets/fonts/web/fangyuan-u6c-1b323b366e88.woff2 +0 -0
  44. package/assets/fonts/web/fangyuan-u6d-cbc376d6fe3f.woff2 +0 -0
  45. package/assets/fonts/web/fangyuan-u6e-7f7e211e2fee.woff2 +0 -0
  46. package/assets/fonts/web/fangyuan-u6f-ff280ce052fa.woff2 +0 -0
  47. package/assets/fonts/web/fangyuan-u70-cb727eaf73b6.woff2 +0 -0
  48. package/assets/fonts/web/fangyuan-u71-0bb1747439fe.woff2 +0 -0
  49. package/assets/fonts/web/fangyuan-u72-cccd39590d69.woff2 +0 -0
  50. package/assets/fonts/web/fangyuan-u73-e67705133584.woff2 +0 -0
  51. package/assets/fonts/web/fangyuan-u74-03a4e7ac9df5.woff2 +0 -0
  52. package/assets/fonts/web/fangyuan-u75-a2d30b327e65.woff2 +0 -0
  53. package/assets/fonts/web/fangyuan-u76-c08fe029c988.woff2 +0 -0
  54. package/assets/fonts/web/fangyuan-u77-76f03be1f4cb.woff2 +0 -0
  55. package/assets/fonts/web/fangyuan-u78-5b721deaf582.woff2 +0 -0
  56. package/assets/fonts/web/fangyuan-u79-db7713c2eda4.woff2 +0 -0
  57. package/assets/fonts/web/fangyuan-u7a-3e40d3726a09.woff2 +0 -0
  58. package/assets/fonts/web/fangyuan-u7b-a8204d1c9dba.woff2 +0 -0
  59. package/assets/fonts/web/fangyuan-u7c-89bcdae046de.woff2 +0 -0
  60. package/assets/fonts/web/fangyuan-u7d-bcc45fa6351a.woff2 +0 -0
  61. package/assets/fonts/web/fangyuan-u7e-49fab96d9269.woff2 +0 -0
  62. package/assets/fonts/web/fangyuan-u7f-dc110e1ecbf3.woff2 +0 -0
  63. package/assets/fonts/web/fangyuan-u80-c5ce9be0335e.woff2 +0 -0
  64. package/assets/fonts/web/fangyuan-u81-b026091b3779.woff2 +0 -0
  65. package/assets/fonts/web/fangyuan-u82-a7c6960e875d.woff2 +0 -0
  66. package/assets/fonts/web/fangyuan-u83-4b7d1c2c50d4.woff2 +0 -0
  67. package/assets/fonts/web/fangyuan-u84-4119128100c8.woff2 +0 -0
  68. package/assets/fonts/web/fangyuan-u85-ca43dd4c5411.woff2 +0 -0
  69. package/assets/fonts/web/fangyuan-u86-54fc0e3158fb.woff2 +0 -0
  70. package/assets/fonts/web/fangyuan-u87-b64f5eb55720.woff2 +0 -0
  71. package/assets/fonts/web/fangyuan-u88-00feb1251f08.woff2 +0 -0
  72. package/assets/fonts/web/fangyuan-u89-db419435b316.woff2 +0 -0
  73. package/assets/fonts/web/fangyuan-u8a-06c11d45fb87.woff2 +0 -0
  74. package/assets/fonts/web/fangyuan-u8b-5c2f6fda436a.woff2 +0 -0
  75. package/assets/fonts/web/fangyuan-u8c-2b46d2d1f4cc.woff2 +0 -0
  76. package/assets/fonts/web/fangyuan-u8d-1a67d064000d.woff2 +0 -0
  77. package/assets/fonts/web/fangyuan-u8e-eb87baf0091c.woff2 +0 -0
  78. package/assets/fonts/web/fangyuan-u8f-96c1f0632da6.woff2 +0 -0
  79. package/assets/fonts/web/fangyuan-u90-86752dba8cbe.woff2 +0 -0
  80. package/assets/fonts/web/fangyuan-u91-0573b8548552.woff2 +0 -0
  81. package/assets/fonts/web/fangyuan-u92-f85ecf735974.woff2 +0 -0
  82. package/assets/fonts/web/fangyuan-u93-e7e0451735db.woff2 +0 -0
  83. package/assets/fonts/web/fangyuan-u94-f3957ff98bcd.woff2 +0 -0
  84. package/assets/fonts/web/fangyuan-u95-b31129d29013.woff2 +0 -0
  85. package/assets/fonts/web/fangyuan-u96-ca7f779c2c13.woff2 +0 -0
  86. package/assets/fonts/web/fangyuan-u97-100a25198b2c.woff2 +0 -0
  87. package/assets/fonts/web/fangyuan-u98-59e4b784a94f.woff2 +0 -0
  88. package/assets/fonts/web/fangyuan-u99-d35af6e30868.woff2 +0 -0
  89. package/assets/fonts/web/fangyuan-u9a-1eb6f7cdfcfe.woff2 +0 -0
  90. package/assets/fonts/web/fangyuan-u9b-8f428d81dcbb.woff2 +0 -0
  91. package/assets/fonts/web/fangyuan-u9c-e10f7f8f0274.woff2 +0 -0
  92. package/assets/fonts/web/fangyuan-u9e-2be65003b759.woff2 +0 -0
  93. package/assets/fonts/web/fangyuan-u9f-02c3d5224fdb.woff2 +0 -0
  94. package/assets/fonts/web/fangyuan-ufb-3eaee54bc476.woff2 +0 -0
  95. package/assets/fonts/web/fangyuan-ufe-f64da8a3acce.woff2 +0 -0
  96. package/assets/fonts/web/fangyuan-uff-0a6dcfb689fd.woff2 +0 -0
  97. package/client/assets/iconsax/LICENSE +16 -0
  98. package/client/assets/iconsax/SOURCE.md +14 -0
  99. package/config/routergo-deepseek.example.yml +25 -0
  100. package/config.example.json +24 -0
  101. package/cordis.patch.yml +5 -0
  102. package/lib/LINK-THIRD-PARTY-LICENSES.txt +1197 -0
  103. package/lib/client.js +3577 -0
  104. package/lib/link-web/app.css +92 -0
  105. package/lib/link-web/app.js +599 -0
  106. package/lib/link-web/index.html +1 -0
  107. package/lib/link-web/workbench.js +118 -0
  108. package/lib/link.mjs +244 -0
  109. package/lib/relay/ip-certificate.mjs +559 -0
  110. package/lib/relay/relay.example.json +8 -0
  111. package/lib/relay/run-relay-server.cjs +4100 -0
  112. package/lib/server/bootstrap.mjs +147 -0
  113. package/lib/server/bootstrap.sh +39 -0
  114. package/lib/server/manifest.json +8 -0
  115. package/lib/server/server-seed.tar.gz +0 -0
  116. package/locale/en.json +6 -0
  117. package/locale/zh-CN.json +6 -0
  118. package/openapi.json +629 -0
  119. package/package.json +104 -4
  120. package/shared/plugin-install.mjs +198 -0
  121. package/shared/tool-quick-settings.mjs +70 -0
  122. package/shared/update-feed.mjs +191 -0
  123. package/shared/update-public-key.pem +3 -0
  124. package/src/cli.mjs +102 -0
  125. package/src/commands.mjs +17 -0
  126. package/src/delegation.mjs +47 -0
  127. package/src/fonts.mjs +17 -0
  128. package/src/harness-adapter.mjs +228 -0
  129. package/src/harness-commands.mjs +39 -0
  130. package/src/harness-selection.mjs +5 -0
  131. package/src/harness-usage.mjs +31 -0
  132. package/src/isolated-launch.mjs +22 -0
  133. package/src/line-process.mjs +114 -0
  134. package/src/link-plugin.mjs +21 -0
  135. package/src/mcp.mjs +21 -0
  136. package/src/model-providers.mjs +125 -0
  137. package/src/models.mjs +37 -0
  138. package/src/native-auth.mjs +32 -0
  139. package/src/picker-catalog.mjs +82 -0
  140. package/src/plugin.mjs +138 -0
  141. package/src/projects.mjs +96 -0
  142. package/src/provider-access.mjs +11 -0
  143. package/src/providers/acp.mjs +138 -0
  144. package/src/providers/claude.mjs +115 -0
  145. package/src/providers/codex.mjs +134 -0
  146. package/src/providers/opencode.mjs +138 -0
  147. package/src/providers/pi-permissions.ts +24 -0
  148. package/src/providers/pi.mjs +97 -0
  149. package/src/questions.mjs +34 -0
  150. package/src/recent-workspace.mjs +36 -0
  151. package/src/runtime.mjs +421 -0
  152. package/src/session-routes.mjs +89 -0
  153. package/src/store.mjs +98 -0
  154. package/src/tool-catalog.mjs +22 -0
  155. package/src/tool-config.mjs +94 -0
  156. package/src/tool-install.mjs +31 -0
  157. package/src/tool-profiles.mjs +114 -0
  158. package/src/tooling.mjs +90 -0
  159. package/src/updates.mjs +156 -0
  160. package/src/usage-import-parsers.mjs +37 -0
  161. package/src/usage-import.mjs +114 -0
  162. package/src/usage-timeline.mjs +36 -0
  163. package/src/usage.mjs +142 -0
  164. package/src/web.mjs +274 -0
  165. package/voice/README.md +49 -0
  166. package/voice/STREAMING.md +141 -0
  167. package/voice/VERIFICATION.md +36 -0
  168. package/voice/client/audio-worklet.js +35 -0
  169. package/voice/locale/en.json +1 -0
  170. package/voice/locale/zh-CN.json +1 -0
  171. package/voice/src/asr.mjs +44 -0
  172. package/voice/src/calls.mjs +37 -0
  173. package/voice/src/config.mjs +139 -0
  174. package/voice/src/media.mjs +70 -0
  175. package/voice/src/plugin.mjs +10 -0
  176. package/voice/src/realtime-protocols.mjs +30 -0
  177. package/voice/src/realtime-socket.mjs +86 -0
  178. package/voice/src/realtime.mjs +130 -0
  179. package/voice/src/tts.mjs +16 -0
  180. package/voice/src/web.mjs +171 -0
@@ -0,0 +1,97 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { access } from 'node:fs/promises';
3
+ import { LineProcess } from '../line-process.mjs';
4
+ import { commandCatalog, compactCommand } from '../commands.mjs';
5
+
6
+ export class PiProvider {
7
+ static capabilities = { stream: true, resume: true, steer: true, approvals: true, discover: false };
8
+ constructor(config = {}) { this.config = config; }
9
+
10
+ async connect(session, host) {
11
+ if (session.nativeId) {
12
+ try { await access(session.nativeId); }
13
+ catch { throw new Error('Pi native session file is missing; refusing to create a replacement session'); }
14
+ }
15
+ // Do not silently remove proxy settings: Pi uses undici and needs an HTTP
16
+ // proxy. A user can supply config.env with a compatible proxy explicitly.
17
+ const args = [...(this.config.commandArgs ?? []), '--mode', 'rpc', '--extension', fileURLToPath(new URL('./pi-permissions.ts', import.meta.url))];
18
+ if (session.nativeId) args.push('--session', session.nativeId);
19
+ if (session.model) args.push('--model', session.model);
20
+ const wire = new LineProcess(this.config.command ?? 'pi', args, { cwd: session.cwd, env: { ...this.config.env, ...(host.delegation ? { COMMANDER_DELEGATE_URL: host.delegation.url, COMMANDER_DELEGATE_TOKEN: host.delegation.token } : {}) }, dialect: 'pi' });
21
+ let promptGeneration = 0;
22
+ let agentStarted = false;
23
+ const driver = {
24
+ nativeId: session.nativeId,
25
+ async setModel(model) {
26
+ if (typeof model !== 'string' || !model.includes('/')) throw new Error('Pi model must include provider/model');
27
+ const [provider, ...parts] = model.split('/');
28
+ if (!provider || !parts.length) throw new Error('Pi model must include provider/model');
29
+ await wire.request('set_model', { provider, modelId: parts.join('/') });
30
+ session.model = model;
31
+ },
32
+ async submit(text) {
33
+ const generation = ++promptGeneration;
34
+ agentStarted = false;
35
+ const ack = await wire.request('prompt', { message: text });
36
+ if (ack?.agentInvoked === false) {
37
+ await new Promise(resolve => setImmediate(resolve));
38
+ if (generation === promptGeneration && !agentStarted) host.event('turn.completed', { status: 'completed', agentInvoked: false });
39
+ } else if (ack?.agentInvoked === undefined && /^\s*\//.test(text)) {
40
+ // Older RPC releases do not acknowledge whether an extension invoked
41
+ // the model. Like Paseo, check state after queued events have arrived.
42
+ await new Promise(resolve => setImmediate(resolve));
43
+ const state = await wire.request('get_state');
44
+ if (generation === promptGeneration && !agentStarted && state.isStreaming === false) host.event('turn.completed', { status: 'completed', agentInvoked: false });
45
+ }
46
+ },
47
+ async commands() { const result = await wire.request('get_commands'); return commandCatalog(result?.commands, [compactCommand]); },
48
+ async command({ name, arguments: args }) {
49
+ if (name === 'compact') {
50
+ await wire.request('compact', args ? { customInstructions: args } : {}, 30 * 60_000);
51
+ host.event('turn.completed', { status: 'completed' });
52
+ } else await driver.submit(`/${name}${args ? ` ${args}` : ''}`);
53
+ },
54
+ async steer(text) { await wire.request('steer', { message: text }); },
55
+ async interrupt() { await wire.request('abort'); },
56
+ async history() { return wire.request('get_messages'); },
57
+ close: () => wire.close(),
58
+ };
59
+ wire.on('failure', error => host.failure(error));
60
+ wire.on('notification', message => {
61
+ void (async () => {
62
+ switch (message.type) {
63
+ case 'agent_start': agentStarted = true; host.event('turn.started', {}); break;
64
+ case 'agent_end':
65
+ // A new Pi session file may only become durable after the first turn.
66
+ { const state = await wire.request('get_state');
67
+ if (state.sessionFile) { driver.nativeId = state.sessionFile; host.nativeId(state.sessionFile); }
68
+ host.event('turn.completed', { status: message.messages?.some(m => m.stopReason === 'error') ? 'failed' : 'completed' }); }
69
+ break;
70
+ case 'message_update':
71
+ if (message.assistantMessageEvent?.type === 'text_delta') host.event('text.delta', { text: message.assistantMessageEvent.delta });
72
+ break;
73
+ case 'message_end':
74
+ if (message.message?.role === 'assistant' && message.message.usage) host.event('usage', { id: message.message.timestamp ?? message.message.id ?? host.turnId, model: message.message.model, usage: message.message.usage });
75
+ break;
76
+ case 'command_output':
77
+ if (typeof message.text === 'string' && message.text) host.event('text.delta', { text: message.text });
78
+ break;
79
+ case 'tool_execution_start': case 'tool_execution_update': case 'tool_execution_end': host.event('item', message); break;
80
+ case 'extension_ui_request': {
81
+ if (['confirm', 'select', 'input', 'editor'].includes(message.method)) {
82
+ const answer = await host.approval({ kind: message.method === 'confirm' ? 'permission' : 'question', method: message.method, nativeRequestId: message.id, details: message });
83
+ wire.send({ type: 'extension_ui_response', id: message.id, ...(message.method === 'confirm' ? { confirmed: answer.allow === true } : { value: answer.value, cancelled: answer.value === undefined }) });
84
+ } else host.event('ui.notice', message);
85
+ break;
86
+ }
87
+ }
88
+ })().catch(error => host.failure(error));
89
+ });
90
+ try {
91
+ const state = await wire.request('get_state');
92
+ if (session.nativeId && state.sessionFile !== session.nativeId) throw new Error('Pi resumed a different session file');
93
+ driver.nativeId = state.sessionFile;
94
+ return driver;
95
+ } catch (error) { await wire.close(); throw error; }
96
+ }
97
+ }
@@ -0,0 +1,34 @@
1
+ // Translate native question contracts through Harness's human input seam.
2
+ // No model-generated answers or permission decisions enter this conversion.
3
+ export async function askNativeQuestion(provider, request, ask) {
4
+ const details = request.details;
5
+ if (provider === 'pi') {
6
+ const result = await ask({ questions: [{
7
+ id: 'value', question: details.title ?? 'Pi question',
8
+ ...(details.message ? { detail: details.message } : {}),
9
+ ...(details.options ? { options: details.options.map(label => ({ label })) } : {}),
10
+ }] });
11
+ const answer = result.answers.find(a => a.id === 'value');
12
+ const value = answer?.custom ?? answer?.selected?.[0];
13
+ return { allow: false, ...(value === undefined ? {} : { value }) };
14
+ }
15
+ const native = details.questions;
16
+ if (!Array.isArray(native) || !native.length) throw new Error('Native question list is empty or unsupported');
17
+ const questions = native.map((q, index) => ({
18
+ id: provider === 'codex' ? q.id : String(index),
19
+ question: q.question,
20
+ ...(q.header ? { header: q.header } : {}),
21
+ ...(q.options?.length ? { options: q.options.map(option => ({ label: option.label, ...(option.description ? { description: option.description } : {}) })) } : {}),
22
+ multiSelect: q.multiSelect === true || q.multiple === true,
23
+ }));
24
+ const result = await ask({ questions });
25
+ const answers = questions.map(q => {
26
+ const response = result.answers.find(a => a.id === q.id);
27
+ if (!response) throw new Error('Human did not answer every native question');
28
+ return [...response.selected, ...(response.custom !== undefined ? [response.custom] : [])];
29
+ });
30
+ if (provider === 'codex') return { allow: false, answers: Object.fromEntries(questions.map((q, index) => [q.id, { answers: answers[index] }])) };
31
+ if (provider === 'opencode') return { allow: false, answers };
32
+ if (provider === 'claude') return { allow: true, answers: Object.fromEntries(native.map((q, index) => [q.question, answers[index].join(', ')])) };
33
+ throw new Error(`Unsupported native question provider: ${provider}`);
34
+ }
@@ -0,0 +1,36 @@
1
+ import { mkdir, readFile, writeFile, rename, rm } from 'node:fs/promises';
2
+ import { isAbsolute, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { profileDirectory } from './projects.mjs';
5
+
6
+ /** Commander owns recovery identity; the official first-use marker is never rewritten. */
7
+ export function recentWorkspaceActions(ctx, directory) {
8
+ const file = join(directory, 'recent-workspace.json');
9
+ let queue = Promise.resolve();
10
+ return {
11
+ ensure({ workspaceId } = {}) {
12
+ const task = queue.then(async () => {
13
+ let saved;
14
+ try {
15
+ saved = JSON.parse(await readFile(file, 'utf8'));
16
+ if (typeof saved.id !== 'string' || typeof saved.path !== 'string' || !isAbsolute(saved.path)) throw new Error('Invalid recent workspace record');
17
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
18
+ // A valid official default takes priority; otherwise recover our durable identity.
19
+ const existing = (typeof workspaceId === 'string' && ctx.workspaceRegistry.get(workspaceId))
20
+ || (saved && ctx.workspaceRegistry.get(saved.id));
21
+ const path = existing?.path ?? saved?.path ?? join(profileDirectory(ctx), 'workspaces', 'default-workspace');
22
+ await mkdir(path, { recursive: true });
23
+ const workspace = await ctx.workspaceRegistry.create(path);
24
+ const next = { id: workspace.id, path: workspace.path };
25
+ if (saved?.id !== next.id || saved?.path !== next.path) {
26
+ const temp = `${file}.${randomUUID()}.tmp`;
27
+ try { await writeFile(temp, JSON.stringify(next), { mode: 0o600 }); await rename(temp, file); }
28
+ finally { await rm(temp, { force: true }); }
29
+ }
30
+ return { workspaceId: workspace.id, path: workspace.path, title: workspace.title };
31
+ });
32
+ queue = task.catch(() => {});
33
+ return task;
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,421 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import { Store, workingDirectory } from './store.mjs';
8
+ import { CodexProvider } from './providers/codex.mjs';
9
+ import { ClaudeProvider } from './providers/claude.mjs';
10
+ import { PiProvider } from './providers/pi.mjs';
11
+ import { OpenCodeProvider } from './providers/opencode.mjs';
12
+ import { normalizeCommand } from './commands.mjs';
13
+ import { acpProviderClasses, ACP_TOOL_DEFAULTS } from './providers/acp.mjs';
14
+
15
+ const copy = value => JSON.parse(JSON.stringify(value));
16
+ const terminal = new Set(['idle', 'failed', 'disconnected', 'closed']);
17
+ export const providerClasses = { codex: CodexProvider, claude: ClaudeProvider, pi: PiProvider, opencode: OpenCodeProvider, ...acpProviderClasses };
18
+
19
+ export class Commander extends EventEmitter {
20
+ constructor({ dataDir = join(homedir(), '.dowork', 'commander'), providers = {}, approvalTimeout = 120_000, eventLimit = 2000, routeService } = {}) {
21
+ super();
22
+ this.store = new Store(dataDir);
23
+ this.routeService = routeService;
24
+ this.sessions = new Map(); this.drivers = new Map(); this.pending = new Map(); this.connecting = new Map();
25
+ this.providers = new Map(Object.entries(providerClasses).map(([name, Type]) => [name, new Type(providers[name])]));
26
+ this.approvalTimeout = approvalTimeout; this.eventLimit = eventLimit;
27
+ this.fault = undefined; this.closing = false;
28
+ this.generations = new Map();
29
+ this.submissions = new Map(); this.activeSubmissions = new Set();
30
+ this.removals = new Map();
31
+ this.archiving = new Set();
32
+ }
33
+
34
+ async open() {
35
+ const data = await this.store.open();
36
+ try {
37
+ if (!data || data.version !== 1 || !Array.isArray(data.sessions)) throw new Error('Unsupported BenchGo state format');
38
+ for (const record of data.sessions) {
39
+ if (['running', 'waiting', 'connecting'].includes(record.status)) {
40
+ for (const entry of Object.values(record.receipts)) {
41
+ if (entry.receipt.turnId === record.turnId) entry.receipt.status = 'uncertain';
42
+ }
43
+ }
44
+ record.status = record.archived ? 'closed' : 'disconnected';
45
+ if (!record.route) record.routeMode = 'native-unbound';
46
+ record.pendingApprovals = [];
47
+ this.sessions.set(record.id, record);
48
+ }
49
+ return this;
50
+ } catch (error) {
51
+ this.sessions.clear();
52
+ await this.store.close();
53
+ throw error;
54
+ }
55
+ }
56
+
57
+ healthy() {
58
+ if (this.closing) throw new Error('BenchGo is shutting down');
59
+ if (this.fault) throw new Error(`BenchGo persistence failed: ${this.fault.message}`);
60
+ }
61
+ get(id) { const session = this.sessions.get(id); if (!session) throw new Error(`Unknown BenchGo session: ${id}`); return session; }
62
+ list() { return [...this.sessions.values()].map(s => this.snapshot(s.id)); }
63
+ snapshot(id) { const { events, receipts, ...value } = this.get(id); return copy(value); }
64
+ catalog() { return [...this.providers].map(([name, provider]) => ({ name, capabilities: provider.constructor.capabilities })); }
65
+ async providerConfig(name, session) {
66
+ const base = this.providers.get(name)?.config ?? {};
67
+ const installed = this.toolInstaller ? await this.toolInstaller.resolveLaunch(name) : null;
68
+ // Reconnecting never reads mutable tool defaults or global applied profiles.
69
+ const route = session?.route;
70
+ if (route && !this.routeService) throw new Error('会话绑定服务不可用,拒绝回退到本机配置。');
71
+ const launch = route ? await this.routeService.launch({ tool: name, route, sessionId: session.id }) : {};
72
+ const profile = launch.managedProfile;
73
+ return { ...base, ...(installed && !base.command ? { command: name === 'claude' && installed.args.length ? installed.args[0] : installed.command, commandArgs: name === 'claude' ? [] : installed.args } : {}),
74
+ ...(ACP_TOOL_DEFAULTS[name] ? { args: base.args ?? installed?.args ?? base.commandArgs ?? [] } : {}),
75
+ ...launch, commandArgs: [...(base.commandArgs ?? (name === 'claude' ? [] : installed?.args) ?? []), ...(launch.commandArgs ?? [])],
76
+ env: { ...(!base.command ? installed?.env : {}), ...base.env, ...launch.env }, ...(profile?.configured ? { managedProfile: profile } : {}) };
77
+ }
78
+ async doctor() {
79
+ const run = promisify(execFile);
80
+ return Promise.all([...this.providers].map(async ([name, provider]) => {
81
+ const config = await this.providerConfig(name);
82
+ const command = config.command ?? ACP_TOOL_DEFAULTS[name]?.command ?? (name === 'claude' ? 'claude' : name);
83
+ try {
84
+ const { stdout } = await run(command, [...(config.commandArgs ?? []), '--version'], { env: { ...process.env, ...config.env }, timeout: 8000, maxBuffer: 32_768, windowsHide: true });
85
+ return { name, available: true, version: stdout.trim().slice(0, 200), login: 'not-checked' };
86
+ } catch (error) { return { name, available: false, error: error.code === 'ENOENT' ? 'not-installed' : 'version-check-failed', login: 'not-checked' }; }
87
+ }));
88
+ }
89
+ persist() {
90
+ clearTimeout(this.flushTimer); this.flushTimer = undefined;
91
+ return this.store.save([...this.sessions.values()]);
92
+ }
93
+
94
+ event(id, type, data = {}) {
95
+ const session = this.get(id);
96
+ if (type === 'turn.started' && session.status !== 'closed') session.status = session.pendingApprovals.length ? 'waiting' : 'running';
97
+ if (type === 'turn.completed') {
98
+ session.status = data.status === 'failed' ? 'failed' : 'idle';
99
+ this.denyPending(id);
100
+ }
101
+ const event = copy({ seq: ++session.cursor, time: new Date().toISOString(), sessionId: id, turnId: session.turnId ?? null, type, data });
102
+ session.events.push(event);
103
+ if (session.events.length > this.eventLimit) session.events.splice(0, session.events.length - this.eventLimit);
104
+ session.updatedAt = event.time;
105
+ // Save snapshots in order. A failed write prevents subsequent mutations;
106
+ // it is observable and never silently reported as durable acceptance.
107
+ if (!this.flushTimer) this.flushTimer = setTimeout(() => {
108
+ this.persist().catch(error => { this.fault = error; this.emit('fault', error); });
109
+ }, 50);
110
+ this.emit('event', copy(event));
111
+ return event;
112
+ }
113
+
114
+ events(id, after = 0, limit = 200) {
115
+ const session = this.get(id);
116
+ if (!Number.isSafeInteger(after) || after < 0) throw new Error('Invalid event cursor');
117
+ const first = session.events[0]?.seq ?? session.cursor + 1;
118
+ const selected = session.events.filter(e => e.seq > after).slice(0, Math.min(Math.max(limit, 1), 500));
119
+ return copy({ events: selected, cursor: selected.at(-1)?.seq ?? after, latest: session.cursor, truncated: after < first - 1 });
120
+ }
121
+
122
+ async create({ provider, cwd, model, parentId, nativeId, ownerSessionId, projectId, title, route, nativeMode = false } = {}) {
123
+ this.healthy();
124
+ if (!this.providers.has(provider)) throw new Error(`Unknown provider: ${provider}`);
125
+ if (parentId) this.get(parentId);
126
+ if (nativeId && [...this.sessions.values()].some(s => s.provider === provider && s.nativeId === nativeId && this.drivers.has(s.id))) throw new Error('Native session already owned by BenchGo');
127
+ if (route && nativeId) throw new Error('不能为已有原生会话自动绑定隔离供应商,请新建会话。');
128
+ if (nativeMode && route) throw new Error('原生配置模式不能同时指定托管供应商。');
129
+ if (!nativeMode && !nativeId && this.routeService && this.toolProfiles?.list) {
130
+ const defaults = (await this.toolProfiles.list({ includeNative: false })).defaults;
131
+ const selected = Array.isArray(defaults) ? defaults.find(item => item.tool === provider) : defaults?.[provider];
132
+ if (!route && selected) route = { ...selected, ...(model !== undefined ? { model } : {}) };
133
+ else if (route && route.quickSettings === undefined && selected?.quickSettings) route = { ...route, quickSettings: selected.quickSettings };
134
+ }
135
+ if (route && !this.routeService) throw new Error('会话绑定服务不可用');
136
+ const binding = route ? await this.routeService.capture({ ...route, tool: provider, model: route.model ?? model }) : undefined;
137
+ const session = {
138
+ ...(binding ? { route: binding, routeMode: binding.mode } : { routeMode: 'native-unbound' }),
139
+ id: randomUUID(), provider, cwd: await workingDirectory(cwd), ...((binding?.model ?? model) ? { model: binding?.model ?? model } : {}),
140
+ ...(parentId ? { parentId } : {}), ...(nativeId ? { nativeId } : {}),
141
+ ...(ownerSessionId ? { ownerSessionId } : {}),
142
+ ...(projectId ? { projectId } : {}), ...(title ? { title: String(title).slice(0, 120) } : {}),
143
+ status: 'disconnected', archived: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
144
+ cursor: 0, events: [], receipts: {}, pendingApprovals: [],
145
+ };
146
+ this.sessions.set(session.id, session);
147
+ await this.persist();
148
+ await this.connect(session.id);
149
+ return this.snapshot(session.id);
150
+ }
151
+
152
+ async connect(id) {
153
+ this.healthy();
154
+ if (this.removals.has(id)) throw new Error('Session is being deleted');
155
+ if (this.archiving.has(id)) throw new Error('Session is being archived');
156
+ if (this.connecting.has(id)) return this.connecting.get(id);
157
+ if (this.drivers.has(id)) {
158
+ if (this.get(id).status !== 'failed') return this.snapshot(id);
159
+ await this.disconnect(id);
160
+ }
161
+ const task = this.connectInternal(id);
162
+ this.connecting.set(id, task);
163
+ try { return await task; } finally { this.connecting.delete(id); }
164
+ }
165
+
166
+ async connectInternal(id) {
167
+ const session = this.get(id);
168
+ if (session.archived) throw new Error('Unarchive session before connecting');
169
+ if (session.cursor && !session.nativeId) throw new Error('No durable native handle; refusing to create a replacement session');
170
+ if (session.nativeId && [...this.sessions.values()].some(s => s.id !== id && s.provider === session.provider && s.nativeId === session.nativeId && (this.drivers.has(s.id) || this.connecting.has(s.id)))) throw new Error('Native session already owned by BenchGo');
171
+ session.status = 'connecting';
172
+ const generation = (this.generations.get(id) ?? 0) + 1;
173
+ this.generations.set(id, generation);
174
+ const current = () => this.generations.get(id) === generation && !this.closing && session.status !== 'closed';
175
+ let failed;
176
+ const host = {
177
+ ...(this.delegationBridge ? { delegation: this.delegationBridge.forSession(id), delegate: args => { if (!current()) throw new Error('Native connection was released'); return this.delegate(id, args); } } : {}),
178
+ event: (type, data) => { if (current()) this.event(id, type, data); },
179
+ nativeId: value => { if (current()) { session.nativeId = value; this.event(id, 'session.native', { nativeId: value }); if (ACP_TOOL_DEFAULTS[session.provider]) return this.persist(); } },
180
+ failure: error => {
181
+ failed = session.route ? new Error('隔离工具连接失败,请检查工具运行状态。') : error;
182
+ if (current()) {
183
+ session.status = 'failed'; this.denyPending(id); this.event(id, 'session.error', { message: failed.message });
184
+ }
185
+ },
186
+ approval: (request, signal) => current() ? this.ask(id, request, signal) : Promise.resolve({ allow: false }),
187
+ };
188
+ let driver;
189
+ try {
190
+ const original = this.providers.get(session.provider), config = await this.providerConfig(session.provider, session);
191
+ const provider = Object.assign(Object.create(Object.getPrototypeOf(original)), original, { config });
192
+ const nativeSession = copy(session), managed = config.managedProfile;
193
+ if (managed?.configured && !nativeSession.model && !ACP_TOOL_DEFAULTS[session.provider]) nativeSession.model = managed.provider ? managed.provider + '/' + managed.model : managed.model;
194
+ driver = await provider.connect(nativeSession, host);
195
+ if (failed) throw failed;
196
+ if (driver.nativeId) session.nativeId = driver.nativeId;
197
+ this.drivers.set(id, driver); session.status = 'idle';
198
+ this.event(id, 'session.connected', { nativeId: session.nativeId ?? null });
199
+ await this.persist();
200
+ return this.snapshot(id);
201
+ } catch (error) {
202
+ await driver?.close();
203
+ this.delegationBridge?.revoke(id);
204
+ const safe = session.route ? Object.assign(new Error(error.code === 'SESSION_ROUTE' ? error.message : '隔离工具启动失败,请检查安装、协议和供应商配置。'), { code: error.code }) : error;
205
+ session.status = 'failed'; this.event(id, 'session.error', { message: safe.message });
206
+ throw safe;
207
+ }
208
+ }
209
+
210
+ async submit(id, text, { requestId = randomUUID() } = {}) {
211
+ return this.acceptInput(id, text, requestId, () => this.drivers.get(id).submit(text), 'prompt');
212
+ }
213
+
214
+ async commands(id) {
215
+ this.healthy(); this.get(id);
216
+ const driver = this.drivers.get(id);
217
+ if (!driver?.commands) throw new Error('Native command discovery is unavailable; connect a supported provider first');
218
+ return driver.commands();
219
+ }
220
+
221
+ async command(id, { name, arguments: args = '', requestId = randomUUID() } = {}) {
222
+ this.healthy();
223
+ if (typeof requestId !== 'string' || !requestId || requestId.length > 200) throw new Error('Invalid requestId');
224
+ const command = normalizeCommand(name, args);
225
+ const prompt = `/${command.name}${command.arguments ? ` ${command.arguments}` : ''}`;
226
+ const session = this.get(id);
227
+ // Durable retries never need a native connection or another catalog read.
228
+ if (Object.hasOwn(session.receipts, requestId)) return this.acceptInput(id, prompt, requestId, undefined, 'command');
229
+ if (session.status !== 'idle' || this.activeSubmissions.has(id)) throw new Error(`Session is ${session.status}; connect or wait for idle and native acknowledgement first`);
230
+ const driver = this.drivers.get(id);
231
+ if (!driver?.command) throw new Error('Native command execution is unavailable; connect a supported provider first');
232
+ const catalog = await this.commands(id);
233
+ if (!catalog.some(entry => entry.name === command.name)) throw new Error(`Unsupported native command: /${command.name}`);
234
+ driver.validateCommand?.(command);
235
+ if (this.drivers.get(id) !== driver) throw new Error('Native connection changed while discovering commands; retry');
236
+ return this.acceptInput(id, prompt, requestId, () => driver.command(command), 'command');
237
+ }
238
+
239
+ async acceptInput(id, text, requestId, dispatch, kind) {
240
+ this.healthy();
241
+ if (this.removals.has(id)) throw new Error('Session is being deleted');
242
+ if (this.archiving.has(id)) throw new Error('Session is being archived');
243
+ if (typeof text !== 'string' || !text.trim()) throw new Error('Prompt must not be empty');
244
+ if (typeof requestId !== 'string' || !requestId || requestId.length > 200) throw new Error('Invalid requestId');
245
+ const session = this.get(id);
246
+ if (Object.hasOwn(session.receipts, requestId)) {
247
+ const previous = session.receipts[requestId];
248
+ if (previous.text !== text || (previous.kind ?? 'prompt') !== kind) throw new Error('requestId was already used for a different prompt or command');
249
+ const inFlight = this.submissions.get(`${id}:${requestId}`);
250
+ if (inFlight) return inFlight;
251
+ return copy(previous.receipt);
252
+ }
253
+ if (session.status !== 'idle' || !this.drivers.has(id) || this.activeSubmissions.has(id)) throw new Error(`Session is ${session.status}; connect or wait for idle and native acknowledgement first`);
254
+ if (Object.keys(session.receipts).length >= 10_000) throw new Error('Session input receipt limit reached; create another session');
255
+ session.turnId = randomUUID(); session.status = 'running';
256
+ const receipt = { requestId, sessionId: id, turnId: session.turnId, status: 'accepted' };
257
+ let resolveSubmission, rejectSubmission;
258
+ const acceptance = new Promise((resolve, reject) => { resolveSubmission = resolve; rejectSubmission = reject; });
259
+ acceptance.catch(() => {});
260
+ this.submissions.set(`${id}:${requestId}`, acceptance); this.activeSubmissions.add(id);
261
+ session.receipts[requestId] = { text, receipt, kind };
262
+ this.event(id, 'input.accepted', { text, requestId, kind });
263
+ // Persist BEFORE the irreversible native submission. A crash can produce
264
+ // an uncertain receipt, but retries never blindly execute it twice.
265
+ try {
266
+ try { await this.persist(); }
267
+ catch (error) { this.fault = error; session.status = 'failed'; throw error; }
268
+ try { await dispatch(); }
269
+ catch (error) {
270
+ receipt.status = 'uncertain'; session.status = 'failed';
271
+ this.denyPending(id);
272
+ this.event(id, 'input.uncertain', { requestId, message: error.message });
273
+ await this.persist(); throw error;
274
+ }
275
+ await this.persist();
276
+ resolveSubmission(copy(receipt)); return copy(receipt);
277
+ } catch (error) { rejectSubmission(error); throw error; }
278
+ finally { this.submissions.delete(`${id}:${requestId}`); this.activeSubmissions.delete(id); }
279
+ }
280
+
281
+ async steer(id, text) {
282
+ this.healthy(); const session = this.get(id);
283
+ const driver = this.drivers.get(id);
284
+ if (session.status !== 'running' || !driver?.steer) throw new Error('Steering is unavailable for this session');
285
+ await driver.steer(text); this.event(id, 'input.steer', { text });
286
+ }
287
+ async interrupt(id) {
288
+ this.healthy(); const session = this.get(id);
289
+ if (!['running', 'waiting'].includes(session.status)) throw new Error('No active turn to interrupt');
290
+ this.denyPending(id);
291
+ await this.drivers.get(id).interrupt();
292
+ this.event(id, 'turn.interrupt-requested', {});
293
+ }
294
+
295
+ ask(id, request, signal) {
296
+ if (this.closing || signal?.aborted) return Promise.resolve({ allow: false });
297
+ const session = this.get(id); const approvalId = randomUUID();
298
+ return new Promise(resolve => {
299
+ const finish = answer => {
300
+ if (!this.pending.delete(approvalId)) return;
301
+ clearTimeout(timer); signal?.removeEventListener('abort', abort);
302
+ session.pendingApprovals = session.pendingApprovals.filter(p => p.id !== approvalId);
303
+ if (session.status === 'waiting' && !session.pendingApprovals.length) session.status = 'running';
304
+ this.event(id, 'approval.resolved', { id: approvalId, answer });
305
+ resolve(answer);
306
+ };
307
+ const abort = () => finish({ allow: false });
308
+ const timer = setTimeout(abort, this.approvalTimeout);
309
+ this.pending.set(approvalId, { sessionId: id, finish });
310
+ session.status = 'waiting';
311
+ const approval = copy({ id: approvalId, sessionId: id, turnId: session.turnId ?? null, ...request });
312
+ session.pendingApprovals.push(approval);
313
+ signal?.addEventListener('abort', abort, { once: true });
314
+ this.event(id, 'approval.requested', approval);
315
+ });
316
+ }
317
+
318
+ // Trusted human/controller API only: deliberately NOT exposed as a model tool.
319
+ approve(id, approvalId, answer) {
320
+ this.healthy(); const pending = this.pending.get(approvalId);
321
+ if (!pending || pending.sessionId !== id) throw new Error('Approval expired, already answered, or belongs to another session');
322
+ if (!answer || typeof answer !== 'object' || Array.isArray(answer)) throw new Error('Approval answer must be an object');
323
+ if (Object.hasOwn(answer, 'allow') && typeof answer.allow !== 'boolean') throw new Error('Approval allow must be boolean');
324
+ pending.finish(copy(answer));
325
+ }
326
+ denyPending(id) { for (const pending of [...this.pending.values()]) if (pending.sessionId === id) pending.finish({ allow: false }); }
327
+
328
+ async history(id) {
329
+ const driver = this.drivers.get(id); if (!driver?.history) throw new Error('Native history unavailable; use BenchGo events');
330
+ return driver.history();
331
+ }
332
+ async discover(id) {
333
+ const driver = this.drivers.get(id); if (!driver?.discover) throw new Error('Native session discovery unavailable for this provider');
334
+ return driver.discover();
335
+ }
336
+
337
+ async wait(id, { timeout = 30_000, signal } = {}) {
338
+ if (!Number.isFinite(timeout) || timeout < 0 || timeout > 60_000) throw new Error('wait timeout must be 0..60000 ms');
339
+ signal?.throwIfAborted();
340
+ if (terminal.has(this.get(id).status)) return this.snapshot(id);
341
+ return new Promise((resolve, reject) => {
342
+ const cleanup = () => { clearTimeout(timer); this.off('event', listener); signal?.removeEventListener('abort', abort); };
343
+ const listener = event => { if (event.sessionId === id && terminal.has(this.get(id).status)) { cleanup(); resolve(this.snapshot(id)); } };
344
+ const abort = () => { cleanup(); reject(signal.reason); };
345
+ const timer = setTimeout(() => { cleanup(); resolve(this.snapshot(id)); }, timeout);
346
+ this.on('event', listener); signal?.addEventListener('abort', abort, { once: true });
347
+ });
348
+ }
349
+
350
+ async disconnect(id) {
351
+ if (this.connecting.has(id)) await this.connecting.get(id).catch(() => {});
352
+ const session = this.get(id); session.status = 'closed';
353
+ this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
354
+ this.delegationBridge?.revoke(id);
355
+ this.denyPending(id);
356
+ const driver = this.drivers.get(id); this.drivers.delete(id);
357
+ await driver?.close();
358
+ this.event(id, 'session.closed', {}); await this.persist();
359
+ return this.snapshot(id);
360
+ }
361
+ async archive(id, archived = true) {
362
+ this.healthy();
363
+ if (this.removals.has(id)) throw new Error('Session is being deleted');
364
+ if (this.archiving.has(id)) throw new Error('Session is being archived');
365
+ if (archived && (['running', 'waiting', 'connecting'].includes(this.get(id).status) || this.activeSubmissions.has(id) || this.connecting.has(id))) throw new Error('Finish or interrupt the active native session before archiving it');
366
+ this.archiving.add(id);
367
+ try {
368
+ if (archived) await this.disconnect(id);
369
+ this.get(id).archived = archived;
370
+ await this.persist(); return this.snapshot(id);
371
+ } finally { this.archiving.delete(id); }
372
+ }
373
+
374
+ async remove(id) {
375
+ this.healthy();
376
+ if (this.removals.has(id)) return this.removals.get(id);
377
+ if (this.archiving.has(id)) throw new Error('Session is being archived');
378
+ const session = this.get(id);
379
+ if (['running', 'waiting', 'connecting'].includes(session.status) || this.activeSubmissions.has(id) || this.connecting.has(id)) throw new Error('Finish or interrupt the active native session before deleting it');
380
+ const task = (async () => {
381
+ await this.disconnect(id);
382
+ // Descendants remain independent conversations. Do not leave references
383
+ // to a parent record that no longer exists, and never delete their work.
384
+ const children = [...this.sessions.values()].filter(record => record.parentId === id);
385
+ const childOwners = children.map(child => ({ child, ownerSessionId: child.ownerSessionId, hadOwner: Object.hasOwn(child, 'ownerSessionId') }));
386
+ for (const child of children) {
387
+ delete child.parentId;
388
+ // A delegated child shared its parent's Harness owner. Once orphaned,
389
+ // it must not masquerade as that owner's primary native conversation.
390
+ // Independently adopted children keep their explicit Harness binding.
391
+ if (!child.harnessAdopted && child.ownerSessionId === session.ownerSessionId) child.ownerSessionId = `commander-web:detached:${child.id}`;
392
+ }
393
+ this.sessions.delete(id);
394
+ try { await this.persist(); }
395
+ catch (error) {
396
+ this.sessions.set(id, session);
397
+ for (const { child, ownerSessionId, hadOwner } of childOwners) {
398
+ child.parentId = id;
399
+ if (hadOwner) child.ownerSessionId = ownerSessionId;
400
+ else delete child.ownerSessionId;
401
+ }
402
+ this.fault = error;
403
+ throw error;
404
+ }
405
+ this.generations.delete(id);
406
+ return { id, removed: true };
407
+ })();
408
+ this.removals.set(id, task);
409
+ try { return await task; } finally { this.removals.delete(id); }
410
+ }
411
+ async close() {
412
+ if (this.closing) return;
413
+ this.closing = true;
414
+ await Promise.allSettled([...this.removals.values()]);
415
+ await Promise.allSettled([...this.connecting.values()]);
416
+ const results = await Promise.allSettled([...this.drivers.keys()].map(id => this.disconnect(id)));
417
+ await Promise.allSettled([...this.submissions.values()]);
418
+ try { await this.persist(); } finally { clearTimeout(this.flushTimer); await this.store.close(); }
419
+ const failure = results.find(r => r.status === 'rejected'); if (failure) throw failure.reason;
420
+ }
421
+ }