@twinsearth/dsh-master 3.3.4 → 3.3.6

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 (54) hide show
  1. package/README.md +1 -0
  2. package/client.js +83 -4
  3. package/cordis.patch.yml +2 -0
  4. package/dist-host/agents/pjm-agent.js +18 -2
  5. package/dist-host/agents/pm-agent.js +23 -1
  6. package/dist-host/agents/prompts.js +16 -6
  7. package/dist-host/channel/abort-registry.js +93 -0
  8. package/dist-host/channel/protocol.js +114 -0
  9. package/dist-host/channel/server.js +349 -0
  10. package/dist-host/channel/stream-state.js +343 -0
  11. package/dist-host/channel/ws.js +199 -0
  12. package/dist-host/dual/documents.js +36 -0
  13. package/dist-host/dual/orchestrator.js +214 -0
  14. package/dist-host/dual/schema.js +153 -0
  15. package/dist-host/engine/atomic-switch.js +159 -0
  16. package/dist-host/engine/health-probe.js +90 -0
  17. package/dist-host/engine/index.js +18 -0
  18. package/dist-host/engine/llm-stream.js +215 -0
  19. package/dist-host/engine/plugin-updater.js +392 -0
  20. package/dist-host/engine/safe-update.js +150 -0
  21. package/dist-host/index.js +114 -2
  22. package/dist-host/kernel.js +7 -7
  23. package/dist-host/plugins/cost-optimizer.js +3 -3
  24. package/dist-host/plugins/dual-manager.js +230 -6
  25. package/docs/AGENTS-PM-PJM.md +90 -6
  26. package/docs/CHANGELOG.md +63 -0
  27. package/docs/CHANNEL-WEBSOCKET.md +169 -0
  28. package/docs/DESIGN-command-center.md +147 -0
  29. package/docs/ENGINE-PRIMITIVES.md +215 -0
  30. package/docs/GAP-ANALYSIS-fang-session.md +75 -0
  31. package/docs/REVIEW-PM-PJM-3.3.4.md +32 -6
  32. package/docs/examples/command-center/README.md +167 -0
  33. package/package.json +3 -1
  34. package/src/agents/pjm-agent.ts +18 -2
  35. package/src/agents/pm-agent.ts +21 -1
  36. package/src/agents/prompts.ts +16 -6
  37. package/src/channel/abort-registry.ts +85 -0
  38. package/src/channel/protocol.ts +110 -0
  39. package/src/channel/server.ts +289 -0
  40. package/src/channel/stream-state.ts +349 -0
  41. package/src/channel/ws.ts +182 -0
  42. package/src/dual/documents.ts +35 -0
  43. package/src/dual/orchestrator.ts +223 -0
  44. package/src/dual/schema.ts +148 -1
  45. package/src/engine/atomic-switch.ts +146 -0
  46. package/src/engine/health-probe.ts +88 -0
  47. package/src/engine/index.ts +20 -0
  48. package/src/engine/llm-stream.ts +189 -0
  49. package/src/engine/plugin-updater.ts +359 -0
  50. package/src/engine/safe-update.ts +148 -0
  51. package/src/index.ts +105 -2
  52. package/src/kernel.ts +7 -7
  53. package/src/plugins/cost-optimizer.ts +3 -3
  54. package/src/plugins/dual-manager.ts +211 -6
@@ -0,0 +1,159 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐。
4
+ /**
5
+ * 蓝绿指针切换(AtomicSwitcher 的可编译实现)。
6
+ *
7
+ * 只做一件事:把 `linkPath` 这个目录指针**原子地**指向 `targetDir`。
8
+ * 做法是「新链接写临时名 → rename 覆盖」,因为同文件系统内 rename 是原子的:
9
+ * 读者永远看到旧指针或新指针,不会看到中间态。
10
+ *
11
+ * 平台差异(不隐藏):
12
+ * * POSIX:`symlink(target, link, 'dir')`,rename 覆盖。
13
+ * * Windows:`symlink(..., 'junction')`。**junction 只能指向目录、不能跨卷**,
14
+ * 且不需要管理员权限(symlink 需要)。因此这里对 Windows 明确用 junction。
15
+ *
16
+ * 不做什么:不删旧目录、不动进程、不看护端口 —— 那些属于调用方(或瓮城的 VM 层)。
17
+ */
18
+ import fsDefault from 'node:fs/promises';
19
+ import path from 'node:path';
20
+ const noop = () => { };
21
+ function asString(value) { return String(value ?? '').trim(); }
22
+ async function exists(fs, target) {
23
+ try {
24
+ await fs.lstat(target);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ /**
32
+ * @param {{
33
+ * linkPath: string, targetDir: string,
34
+ * fs?: object, platform?: string,
35
+ * now?: () => number,
36
+ * }} input
37
+ */
38
+ export async function switchPointer(input = {}) {
39
+ const fs = input.fs ?? fsDefault;
40
+ const platform = String(input.platform ?? process.platform);
41
+ const now = typeof input.now === 'function' ? input.now : () => Date.now();
42
+ const linkPath = path.resolve(asString(input.linkPath));
43
+ const targetDir = path.resolve(asString(input.targetDir));
44
+ if (!asString(input.linkPath))
45
+ return { ok: false, code: 'link-path-required', error: 'linkPath is required' };
46
+ if (!asString(input.targetDir))
47
+ return { ok: false, code: 'target-dir-required', error: 'targetDir is required' };
48
+ if (linkPath === targetDir)
49
+ return { ok: false, code: 'self-reference', error: 'linkPath and targetDir must differ' };
50
+ // 目标必须是真实存在的目录:切换到一个不存在的绿槽比不切换更糟。
51
+ let targetStat = null;
52
+ try {
53
+ targetStat = await fs.stat(targetDir);
54
+ }
55
+ catch { /* handled below */ }
56
+ if (!targetStat || !targetStat.isDirectory()) {
57
+ return { ok: false, code: 'target-missing', error: `target directory does not exist: ${targetDir}` };
58
+ }
59
+ const parent = path.dirname(linkPath);
60
+ if (!(await exists(fs, parent)))
61
+ return { ok: false, code: 'parent-missing', error: `parent directory does not exist: ${parent}` };
62
+ // 现有指针必须是链接(或不存在);覆盖普通目录/文件会静默毁数据,直接拒绝。
63
+ if (await exists(fs, linkPath)) {
64
+ const current = await fs.lstat(linkPath);
65
+ if (!current.isSymbolicLink() && platform !== 'win32') {
66
+ return { ok: false, code: 'link-is-not-a-symlink', error: `${linkPath} exists and is not a symlink` };
67
+ }
68
+ if (!current.isSymbolicLink() && platform === 'win32') {
69
+ // Windows 上 junction 在 lstat 里呈现为 symlink;普通目录一律拒绝。
70
+ return { ok: false, code: 'link-is-not-a-junction', error: `${linkPath} exists and is not a junction` };
71
+ }
72
+ }
73
+ const tempLink = `${linkPath}.${process.pid}.${now()}.tmp`;
74
+ const type = platform === 'win32' ? 'junction' : 'dir';
75
+ const startedAt = now();
76
+ try {
77
+ await fs.rm(tempLink, { force: true, recursive: true }).catch(noop);
78
+ await fs.symlink(targetDir, tempLink, type);
79
+ // rename 覆盖仅在同一文件系统内原子;跨卷时调用方必须自己保证同卷。
80
+ // Windows 上 rename 不能覆盖已存在的 junction(EPERM),退回“先移开再放”,
81
+ // 并如实标记 atomic=false —— 这个平台限制是真实存在的,不假装完美。
82
+ let replaced = 'rename';
83
+ let atomic = true;
84
+ try {
85
+ await fs.rename(tempLink, linkPath);
86
+ }
87
+ catch (error) {
88
+ if (platform !== 'win32' || error?.code !== 'EPERM')
89
+ throw error;
90
+ const backup = `${linkPath}.${process.pid}.${now()}.bak`;
91
+ await fs.rename(linkPath, backup);
92
+ try {
93
+ await fs.rename(tempLink, linkPath);
94
+ }
95
+ catch (inner) {
96
+ await fs.rename(backup, linkPath).catch(noop);
97
+ throw inner;
98
+ }
99
+ await fs.rm(backup, { force: true, recursive: true }).catch(noop);
100
+ replaced = 'move-aside';
101
+ atomic = false;
102
+ }
103
+ let resolved = null;
104
+ try {
105
+ resolved = await fs.realpath(linkPath);
106
+ }
107
+ catch { /* 读取失败不影响切换结论 */ }
108
+ return {
109
+ ok: true,
110
+ code: 'switched',
111
+ linkPath,
112
+ targetDir,
113
+ linkType: type,
114
+ replaced,
115
+ atomic,
116
+ resolved,
117
+ resolvedMatchesTarget: resolved ? path.resolve(resolved) === targetDir : null,
118
+ elapsedMs: Math.max(0, now() - startedAt),
119
+ };
120
+ }
121
+ catch (error) {
122
+ await fs.rm(tempLink, { force: true, recursive: true }).catch(noop);
123
+ return { ok: false, code: 'switch-failed', error: String(error?.message ?? error), linkPath, targetDir };
124
+ }
125
+ }
126
+ /**
127
+ * 读指针当前指向(只读)。不存在 / 不是链接时给出具名原因,不抛。
128
+ */
129
+ export async function readPointer(input = {}) {
130
+ const fs = input.fs ?? fsDefault;
131
+ const linkPath = path.resolve(asString(input.linkPath));
132
+ if (!asString(input.linkPath))
133
+ return { ok: false, code: 'link-path-required', error: 'linkPath is required' };
134
+ if (!(await exists(fs, linkPath)))
135
+ return { ok: false, code: 'link-missing', error: `${linkPath} does not exist` };
136
+ try {
137
+ const stat = await fs.lstat(linkPath);
138
+ const target = await fs.readlink(linkPath).catch(() => null);
139
+ const resolved = await fs.realpath(linkPath).catch(() => null);
140
+ return { ok: true, linkPath, isSymlink: stat.isSymbolicLink(), target, resolved };
141
+ }
142
+ catch (error) {
143
+ return { ok: false, code: 'read-failed', error: String(error?.message ?? error) };
144
+ }
145
+ }
146
+ /**
147
+ * 用「先切到新槽、失败切回旧槽」的方式包装一次切换,失败时保证指针回到原目标。
148
+ */
149
+ export async function switchWithRollback(input = {}) {
150
+ const fs = input.fs ?? fsDefault;
151
+ const previous = await readPointer({ fs, linkPath: input.linkPath });
152
+ const switched = await switchPointer(input);
153
+ if (switched.ok)
154
+ return { ...switched, rollback: null };
155
+ if (!previous.ok || !previous.resolved)
156
+ return { ...switched, rollback: null };
157
+ const restore = await switchPointer({ ...input, targetDir: previous.resolved });
158
+ return { ...switched, rollback: restore };
159
+ }
@@ -0,0 +1,90 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck
4
+ /**
5
+ * 健康检查探针(HealthProbe 的可编译实现)。
6
+ *
7
+ * 只做「有界轮询 + 明确结论」:
8
+ * * 有总超时和最大尝试次数,不会无限等;
9
+ * * 任何一次拿到期望状态码即成功(默认 200–299);
10
+ * * 超时/连接失败返回具名原因,**不**把“没连上”当成“健康”。
11
+ */
12
+ const DEFAULT_INTERVAL_MS = 1_000;
13
+ const DEFAULT_TIMEOUT_MS = 30_000;
14
+ const DEFAULT_MAX_ATTEMPTS = 30;
15
+ const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
16
+ function positiveInt(value, fallback) {
17
+ const n = Number(value);
18
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
19
+ }
20
+ function sleep(ms) {
21
+ return new Promise((resolve) => setTimeout(resolve, Math.max(1, ms)));
22
+ }
23
+ /**
24
+ * @param {{
25
+ * url: string,
26
+ * fetchImpl?: typeof fetch,
27
+ * timeoutMs?: number, intervalMs?: number, maxAttempts?: number,
28
+ * expectStatus?: (status: number) => boolean,
29
+ * now?: () => number, sleepImpl?: (ms: number) => Promise<void>,
30
+ * }} input
31
+ */
32
+ export async function probeHealth(input = {}) {
33
+ const url = String(input.url ?? '').trim();
34
+ if (!url)
35
+ return { ok: false, code: 'url-required', error: 'url is required', attempts: 0 };
36
+ const fetchImpl = input.fetchImpl ?? fetch;
37
+ const timeoutMs = positiveInt(input.timeoutMs, DEFAULT_TIMEOUT_MS);
38
+ const intervalMs = positiveInt(input.intervalMs, DEFAULT_INTERVAL_MS);
39
+ const maxAttempts = positiveInt(input.maxAttempts, DEFAULT_MAX_ATTEMPTS);
40
+ const now = typeof input.now === 'function' ? input.now : () => Date.now();
41
+ const wait = typeof input.sleepImpl === 'function' ? input.sleepImpl : sleep;
42
+ const expectStatus = typeof input.expectStatus === 'function'
43
+ ? input.expectStatus
44
+ : (status) => status >= 200 && status < 300;
45
+ const startedAt = now();
46
+ const deadline = startedAt + timeoutMs;
47
+ // 单次请求超时必须与轮询间隔分开:拿 intervalMs 当请求超时会把「慢但有响应」误判成 probe-failed
48
+ const requestTimeoutMs = positiveInt(input.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS);
49
+ let attempts = 0;
50
+ let lastStatus = null;
51
+ let lastError = null;
52
+ while (attempts < maxAttempts) {
53
+ if (attempts > 0 && now() >= deadline)
54
+ break;
55
+ attempts += 1;
56
+ try {
57
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
58
+ const timer = controller ? setTimeout(() => controller.abort(), Math.max(1, Math.min(requestTimeoutMs, 30_000))) : null;
59
+ if (timer && typeof timer.unref === 'function')
60
+ timer.unref();
61
+ let response = null;
62
+ try {
63
+ response = await fetchImpl(url, { method: 'GET', signal: controller ? controller.signal : undefined });
64
+ }
65
+ finally {
66
+ if (timer)
67
+ clearTimeout(timer);
68
+ }
69
+ lastStatus = Number(response?.status ?? 0) || null;
70
+ lastError = null;
71
+ if (lastStatus !== null && expectStatus(lastStatus)) {
72
+ return { ok: true, code: 'healthy', status: lastStatus, attempts, elapsedMs: Math.max(0, now() - startedAt), url };
73
+ }
74
+ }
75
+ catch (error) {
76
+ lastError = String(error?.message ?? error);
77
+ }
78
+ if (now() >= deadline)
79
+ break;
80
+ if (attempts < maxAttempts)
81
+ await wait(intervalMs);
82
+ }
83
+ const elapsedMs = Math.max(0, now() - startedAt);
84
+ // 只要拿到过响应,就以状态码为准——不能因为某次连接失败把「服务返回 503」改写成 probe-failed
85
+ if (lastStatus !== null)
86
+ return { ok: false, code: 'unhealthy-status', status: lastStatus, attempts, elapsedMs, url };
87
+ if (lastError)
88
+ return { ok: false, code: 'probe-failed', error: lastError, status: lastStatus, attempts, elapsedMs, url };
89
+ return { ok: false, code: 'probe-timeout', attempts, elapsedMs, url };
90
+ }
@@ -0,0 +1,18 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck
4
+ /**
5
+ * 3.3.5 · 宿主侧引擎原语(不依赖任何 VM / 瓮城代码)。
6
+ *
7
+ * 这三个原语回答的是「切换/验证/回滚」的正确性,而不是「怎么管 VM」:
8
+ * * `switchPointer` / `switchWithRollback` —— 蓝绿指针原子切换,失败回到旧目标;
9
+ * * `probeHealth` —— 有界健康检查,不会把“连不上”当健康;
10
+ * * `runSafeUpdate` —— 快照 → 更新 → 冒烟 → 失败回滚,执行能力由调用方注入。
11
+ *
12
+ * 没有 `vm_create`/`vm_exec` 这类假设:房主不持有 VM;需要时由宿主或瓮城提供 executor。
13
+ */
14
+ export { switchPointer, readPointer, switchWithRollback } from "./atomic-switch.js";
15
+ export { probeHealth } from "./health-probe.js";
16
+ export { runSafeUpdate, switchAndVerify } from "./safe-update.js";
17
+ export { createPluginUpdater, buildSmokeScript, shellArgv, UPDATE_PHASES } from "./plugin-updater.js";
18
+ export { createLlmStreamer, llmStreamFn } from "./llm-stream.js";
@@ -0,0 +1,215 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck
4
+ /**
5
+ * 可中断的 LLM 流(3.3.5)。
6
+ *
7
+ * 真实宿主 API 是 `ctx.llm.stream(options)`(不是草案里的 `llm.client.chat.completions.create`):
8
+ * * `options.signal` 原生支持取消;
9
+ * * 取消后流会以 `{type:'finish', reason:{kind:'aborted'}}` 收尾;
10
+ * * `StreamChunk` 里文本增量是 `{type:'text-delta', text}`。
11
+ *
12
+ * 本模块负责「谁在跑、怎么停、停完怎么记账」:
13
+ * * 每个 key(会话/消息)最多一条在跑的流,新的一轮会先中止旧的;
14
+ * * 中止后**保留已产出的半截文本**(`partial`),供 UI 展示与上下文续写;
15
+ * * `dispose()` 中止全部(宿主卸载用);
16
+ * * **不声称**能验证服务端计费已停止——我们只能保证自己停止读取并传了 signal。
17
+ *
18
+ * 依赖注入:`streamFn(options) => AsyncIterable<StreamChunk>`,便于测试与替换。
19
+ */
20
+ import { createAbortRegistry } from "../channel/abort-registry.js";
21
+ const ABORTED = Symbol('aborted');
22
+ function watchAbort(signal) {
23
+ if (!signal)
24
+ return { promise: new Promise(() => { }), cancel: () => { } };
25
+ let listener = null;
26
+ const promise = new Promise((resolve) => {
27
+ if (signal.aborted) {
28
+ resolve(ABORTED);
29
+ return;
30
+ }
31
+ listener = () => resolve(ABORTED);
32
+ signal.addEventListener('abort', listener, { once: true });
33
+ });
34
+ return {
35
+ promise,
36
+ cancel: () => { try {
37
+ if (listener)
38
+ signal.removeEventListener('abort', listener);
39
+ }
40
+ catch { /* ignore */ } },
41
+ };
42
+ }
43
+ function chunkText(chunk) {
44
+ if (!chunk || typeof chunk !== 'object')
45
+ return '';
46
+ if (chunk.type === 'text-delta')
47
+ return String(chunk.text ?? '');
48
+ if (chunk.type === 'reasoning-delta')
49
+ return '';
50
+ return '';
51
+ }
52
+ /**
53
+ * @param {{
54
+ * streamFn: (options: object) => AsyncIterable<object>,
55
+ * registry?: object,
56
+ * onDelta?: (delta: string, meta: object) => void,
57
+ * logger?: { info?: Function, warn?: Function, error?: Function },
58
+ * }} deps
59
+ */
60
+ export function createLlmStreamer(deps = {}) {
61
+ if (typeof deps.streamFn !== 'function') {
62
+ return {
63
+ available: false,
64
+ reason: 'streamFn-required',
65
+ run: async () => ({ ok: false, code: 'streamFn-required', error: 'a streamFn(options) => AsyncIterable is required' }),
66
+ abort: () => false,
67
+ abortAll: () => [],
68
+ activeKeys: () => [],
69
+ dispose: () => ({ aborted: [] }),
70
+ };
71
+ }
72
+ const registry = deps.registry ?? createAbortRegistry();
73
+ const log = (level, message) => { try {
74
+ deps.logger?.[level]?.(message);
75
+ }
76
+ catch { /* ignore */ } };
77
+ const stallTimeoutMs = Number(deps.stallTimeoutMs) > 0 ? Number(deps.stallTimeoutMs) : 0;
78
+ let disposed = false;
79
+ /**
80
+ * 跑一条流并返回结构化结果(永不抛:错误变成 code)。
81
+ * @param {{key: string, options?: object, signal?: AbortSignal}} input
82
+ */
83
+ async function run(input = {}) {
84
+ const key = String(input.key ?? '').trim();
85
+ if (!key)
86
+ return { ok: false, code: 'key-required', error: 'key is required' };
87
+ if (disposed)
88
+ return { ok: false, code: 'streamer-disposed', error: 'streamer was disposed' };
89
+ const entry = registry.begin(key);
90
+ const signal = entry.signal;
91
+ const onDelta = typeof input.onDelta === 'function' ? input.onDelta : deps.onDelta;
92
+ const external = input.signal;
93
+ const onExternalAbort = () => entry.abort('external-abort');
94
+ if (external) {
95
+ if (external.aborted)
96
+ entry.abort('external-abort');
97
+ else
98
+ external.addEventListener('abort', onExternalAbort, { once: true });
99
+ }
100
+ let text = '';
101
+ let finish = null;
102
+ let aborted = false;
103
+ let usage = null;
104
+ const watch = watchAbort(signal);
105
+ const iteratorRef = { current: null };
106
+ try {
107
+ const iterator = deps.streamFn({ ...(input.options ?? {}), signal });
108
+ iteratorRef.current = iterator?.[Symbol.asyncIterator]?.() ?? null;
109
+ if (!iteratorRef.current)
110
+ return { ok: false, code: 'stream-not-async-iterable', error: 'streamFn must return an AsyncIterable', text, aborted: false };
111
+ while (true) {
112
+ // 卡住兜底:上游长时间不吐字(连接半死/网关静默)时主动中止,避免句柄挂着烧钱
113
+ const stall = stallTimeoutMs > 0
114
+ ? new Promise((resolve) => { const timer = setTimeout(() => resolve('stalled'), stallTimeoutMs); if (typeof timer.unref === 'function')
115
+ timer.unref(); })
116
+ : new Promise(() => { });
117
+ const step = await Promise.race([iteratorRef.current.next(), watch.promise, stall]);
118
+ if (step === 'stalled') {
119
+ entry.abort('stalled');
120
+ log('warn', `[llm-stream] upstream stalled >${stallTimeoutMs}ms key=${key}`);
121
+ return { ok: false, code: 'stalled', error: `no chunk within ${stallTimeoutMs}ms`, text, aborted: true, finish, usage, savedContent: text };
122
+ }
123
+ if (step === ABORTED) {
124
+ aborted = true;
125
+ break;
126
+ }
127
+ if (!step || step.done)
128
+ break;
129
+ const chunk = step.value;
130
+ if (signal.aborted) {
131
+ aborted = true;
132
+ break;
133
+ }
134
+ if (chunk?.type === 'usage' && chunk.usage)
135
+ usage = chunk.usage;
136
+ if (chunk?.type === 'finish') {
137
+ finish = chunk.reason ?? null;
138
+ if (finish?.kind === 'aborted')
139
+ aborted = true;
140
+ if (finish?.kind === 'error') {
141
+ return { ok: false, code: 'llm-error', error: finish.failure?.message ?? 'model call failed', failure: finish.failure, text, aborted: false, usage };
142
+ }
143
+ continue;
144
+ }
145
+ const delta = chunkText(chunk);
146
+ if (delta) {
147
+ text += delta;
148
+ try {
149
+ onDelta?.(delta, { key, text });
150
+ }
151
+ catch { /* 消费回调异常不影响流 */ }
152
+ }
153
+ }
154
+ if (aborted) {
155
+ log('info', `[llm-stream] aborted key=${key} after ${text.length} chars`);
156
+ return { ok: false, code: 'aborted', error: 'generation aborted by caller', text, aborted: true, finish, usage, savedContent: text };
157
+ }
158
+ log('info', `[llm-stream] done key=${key} chars=${text.length}`);
159
+ return { ok: true, code: 'completed', text, aborted: false, finish, usage };
160
+ }
161
+ catch (error) {
162
+ const message = String(error?.message ?? error);
163
+ const isAbort = signal.aborted || error?.name === 'AbortError' || /abort/iu.test(message);
164
+ if (isAbort) {
165
+ log('info', `[llm-stream] abort surfaced as error key=${key}: ${message}`);
166
+ return { ok: false, code: 'aborted', error: message, text, aborted: true, finish, usage, savedContent: text };
167
+ }
168
+ log('error', `[llm-stream] failed key=${key}: ${message}`);
169
+ return { ok: false, code: 'stream-failed', error: message, text, aborted: false, finish, usage };
170
+ }
171
+ finally {
172
+ watch.cancel();
173
+ try {
174
+ external?.removeEventListener?.('abort', onExternalAbort);
175
+ }
176
+ catch { /* ignore */ }
177
+ // 清理上游时**绝不能无限等**:卡死的异步生成器会把 return() 排在未完成的 next() 后面,
178
+ // await 它会永久挂住(这正是我们要防的僵尸句柄)。这里给一个有界让步。
179
+ try {
180
+ const closing = iteratorRef.current?.return?.();
181
+ if (closing && typeof closing.then === 'function') {
182
+ await Promise.race([
183
+ closing,
184
+ new Promise((resolve) => { const timer = setTimeout(resolve, 50); if (typeof timer.unref === 'function')
185
+ timer.unref(); }),
186
+ ]);
187
+ }
188
+ }
189
+ catch { /* 尽力关闭上游 */ }
190
+ entry.end();
191
+ }
192
+ }
193
+ return {
194
+ available: true,
195
+ run,
196
+ abort: (key, reason = 'user-interrupt') => registry.abort(String(key ?? ''), reason),
197
+ abortAll: (reason = 'shutdown') => registry.abortAll(reason),
198
+ activeKeys: () => registry.keys(),
199
+ dispose: () => {
200
+ if (disposed)
201
+ return { aborted: [] };
202
+ disposed = true;
203
+ return { aborted: registry.abortAll('disposed') };
204
+ },
205
+ };
206
+ }
207
+ /**
208
+ * 把真实 `ctx.llm.stream` 适配成 streamFn。
209
+ * 之所以包一层:只取需要的字段,且把 method 绑定到服务对象上(避免 this 丢失)。
210
+ */
211
+ export function llmStreamFn(llm) {
212
+ if (!llm || typeof llm.stream !== 'function')
213
+ return null;
214
+ return (options) => llm.stream(options);
215
+ }