@cubeai/dsh-feishu-channels 0.0.0-stage → 0.2.0

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 (65) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +15 -0
  3. package/README.md +56 -2
  4. package/client.js +4189 -0
  5. package/cordis.patch.yml +4 -0
  6. package/docs/coexistence.md +112 -0
  7. package/docs/dsh-feishu-channels-plan.md +1406 -0
  8. package/docs/ui-revamp-plan.md +263 -0
  9. package/docs/verification.md +120 -0
  10. package/icon.svg +17 -0
  11. package/locale/en.json +6 -0
  12. package/locale/zh.json +6 -0
  13. package/package.json +76 -4
  14. package/src/channels/connection-state.js +12 -0
  15. package/src/channels/diagnostics.js +77 -0
  16. package/src/channels/factory.js +20 -0
  17. package/src/channels/feishu-app.js +267 -0
  18. package/src/channels/feishu.js +399 -0
  19. package/src/channels/media.js +9 -0
  20. package/src/channels/meta.js +24 -0
  21. package/src/channels/qr/feishu.js +84 -0
  22. package/src/channels/qr/hub.js +229 -0
  23. package/src/channels/qr/render.js +8 -0
  24. package/src/channels/qr/shared.js +50 -0
  25. package/src/engine/abort.js +35 -0
  26. package/src/engine/access.js +23 -0
  27. package/src/engine/agent-options.js +19 -0
  28. package/src/engine/approval.js +70 -0
  29. package/src/engine/assistant-settings.js +38 -0
  30. package/src/engine/assistant-text.js +11 -0
  31. package/src/engine/atomic-file.js +45 -0
  32. package/src/engine/chat-commands.js +752 -0
  33. package/src/engine/choice-delivery.js +20 -0
  34. package/src/engine/choices.js +103 -0
  35. package/src/engine/command-locale.js +70 -0
  36. package/src/engine/command-messages.js +434 -0
  37. package/src/engine/command-permissions.js +38 -0
  38. package/src/engine/command-replies.js +59 -0
  39. package/src/engine/credentials.js +54 -0
  40. package/src/engine/deferred-delivery.js +260 -0
  41. package/src/engine/delivery-history.js +39 -0
  42. package/src/engine/file-delivery.js +215 -0
  43. package/src/engine/file-input.js +52 -0
  44. package/src/engine/file-log.js +25 -0
  45. package/src/engine/gateway.js +1337 -0
  46. package/src/engine/image-input.js +31 -0
  47. package/src/engine/json-state.js +25 -0
  48. package/src/engine/keyed-queue.js +20 -0
  49. package/src/engine/merge.js +56 -0
  50. package/src/engine/message-progress.js +274 -0
  51. package/src/engine/question.js +201 -0
  52. package/src/engine/quiet-logger.js +25 -0
  53. package/src/engine/reply-stream.js +86 -0
  54. package/src/engine/router.js +713 -0
  55. package/src/engine/secure-file.js +8 -0
  56. package/src/engine/seen-store.js +43 -0
  57. package/src/engine/session-export.js +64 -0
  58. package/src/engine/session-history.js +30 -0
  59. package/src/engine/session-id.js +62 -0
  60. package/src/engine/session-store.js +109 -0
  61. package/src/engine/session-title.js +29 -0
  62. package/src/engine/split.js +291 -0
  63. package/src/engine/workspace-path.js +9 -0
  64. package/src/index.js +62 -0
  65. package/src/manager.js +1325 -0
@@ -0,0 +1,24 @@
1
+ /** Feishu-only channel metadata. */
2
+ export const CHANNEL_ORDER = ['feishu'];
3
+
4
+ export const CHANNEL_META = {
5
+ feishu: {
6
+ id: 'feishu',
7
+ label: '飞书',
8
+ description: '通过飞书机器人接收并回复用户消息',
9
+ kind: 'qr-or-credentials',
10
+ fields: [
11
+ { key: 'appId', label: 'App ID' },
12
+ { key: 'appSecret', label: 'App Secret', secret: true },
13
+ ],
14
+ },
15
+ };
16
+
17
+ export function listChannelMeta() {
18
+ return CHANNEL_ORDER.map((id) => CHANNEL_META[id]);
19
+ }
20
+
21
+ export function supportsQr(id) {
22
+ const kind = CHANNEL_META[id]?.kind;
23
+ return kind === 'qr' || kind === 'qr-or-credentials';
24
+ }
@@ -0,0 +1,84 @@
1
+ /** 飞书设备注册扫码,自动创建机器人。 */
2
+ import { asRecord, cleanString, readJson } from './shared.js';
3
+ import { decorateQrUrl } from '../feishu-app.js';
4
+
5
+ const FEISHU_ACCOUNTS = 'https://accounts.feishu.cn';
6
+
7
+ export function parseFeishuBegin(body, now = Date.now()) {
8
+ const record = asRecord(body);
9
+ const err = cleanString(record.error);
10
+ if (err)
11
+ throw new Error(`飞书注册开始失败:${err}`);
12
+ const deviceCode = cleanString(record.device_code);
13
+ const qrUrl = cleanString(record.verification_uri_complete);
14
+ if (!deviceCode || !qrUrl)
15
+ throw new Error('飞书注册没有返回完整二维码');
16
+ const expiresIn = Number(record.expire_in ?? record.expires_in);
17
+ const interval = Number(record.interval);
18
+ return {
19
+ deviceCode,
20
+ qrUrl,
21
+ expiresAt: now + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 300) * 1000,
22
+ pollIntervalMs: (Number.isFinite(interval) && interval > 0 ? interval : 5) * 1000,
23
+ };
24
+ }
25
+
26
+ export function parseFeishuPoll(body) {
27
+ const record = asRecord(body);
28
+ const userInfo = asRecord(record.user_info);
29
+ const brand = cleanString(userInfo.tenant_brand);
30
+ if (brand?.toLowerCase() === 'lark')
31
+ return { status: 'failed', error: '检测到 Lark 账号;本插件仅支持飞书账号。' };
32
+ const appId = cleanString(record.client_id);
33
+ const appSecret = cleanString(record.client_secret);
34
+ if (appId && appSecret) {
35
+ const ownerOpenId = cleanString(userInfo.open_id);
36
+ return {
37
+ status: 'success',
38
+ credentials: ownerOpenId ? { appId, appSecret, ownerOpenId } : { appId, appSecret },
39
+ };
40
+ }
41
+ const err = cleanString(record.error);
42
+ if (err === 'authorization_pending')
43
+ return { status: 'waiting' };
44
+ if (err === 'slow_down')
45
+ return { status: 'waiting', pollIntervalMs: 10_000 };
46
+ if (err === 'access_denied')
47
+ return { status: 'failed', error: '用户拒绝授权' };
48
+ if (err === 'expired_token')
49
+ return { status: 'expired', error: '二维码已过期' };
50
+ if (err)
51
+ return { status: 'failed', error: err };
52
+ return { status: 'waiting' };
53
+ }
54
+
55
+ async function call(action, params, signal) {
56
+ const form = new URLSearchParams({ action, ...params });
57
+ const response = await fetch(`${FEISHU_ACCOUNTS}/oauth/v1/app/registration`, {
58
+ method: 'POST',
59
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
60
+ body: form.toString(),
61
+ redirect: 'error',
62
+ signal: signal ?? AbortSignal.timeout(15_000),
63
+ });
64
+ return readJson(response, `飞书${action}`);
65
+ }
66
+
67
+ export async function beginFeishuQr(options = {}, signal) {
68
+ if (options && typeof options.aborted === 'boolean') {
69
+ signal = options;
70
+ options = {};
71
+ }
72
+ await call('init', {}, signal);
73
+ const begun = parseFeishuBegin(await call('begin', {
74
+ archetype: 'PersonalAgent',
75
+ auth_method: 'client_secret',
76
+ request_user_info: 'open_id',
77
+ }, signal));
78
+ return {
79
+ qrUrl: decorateQrUrl(begun.qrUrl, { name: options?.name ?? options?.__setting_name }),
80
+ expiresAt: begun.expiresAt,
81
+ pollIntervalMs: begun.pollIntervalMs,
82
+ poll: async (pollSignal) => parseFeishuPoll(await call('poll', { device_code: begun.deviceCode }, pollSignal)),
83
+ };
84
+ }
@@ -0,0 +1,229 @@
1
+ import { CHANNEL_META } from '../meta.js';
2
+ import { qrPayloadOf, remainingSeconds, sleep } from './shared.js';
3
+ import { renderQrDataUrl } from './render.js';
4
+ import { beginFeishuQr } from './feishu.js';
5
+ export class PairingHub {
6
+ sessions = new Map();
7
+ onSuccess;
8
+ beginFn;
9
+ renderQr;
10
+ log;
11
+ constructor(options = {}) {
12
+ this.onSuccess = options.onSuccess;
13
+ this.beginFn = options.beginFn;
14
+ this.renderQr = options.renderQr ?? renderQrDataUrl;
15
+ this.log = options.log ?? (() => undefined);
16
+ }
17
+ supports(id) {
18
+ const kind = CHANNEL_META[id]?.kind;
19
+ return kind === 'qr' || kind === 'qr-or-credentials';
20
+ }
21
+ view(id) {
22
+ const session = this.sessions.get(id);
23
+ if (!session)
24
+ return { channelId: id, status: 'idle' };
25
+ return {
26
+ channelId: id,
27
+ status: session.status,
28
+ qrUrl: session.qrUrl,
29
+ qrImage: session.qrImage,
30
+ expiresAt: session.expiresAt,
31
+ remainingSeconds: remainingSeconds(session.expiresAt),
32
+ hint: session.hint,
33
+ error: session.error,
34
+ };
35
+ }
36
+ async start(id, extra = {}) {
37
+ if (!this.supports(id))
38
+ throw new Error(`${id} 不支持扫码绑定`);
39
+ if (this.sessions.get(id)?.status === 'saving')
40
+ return this.view(id);
41
+ this.cancel(id);
42
+ const session = {
43
+ channelId: id,
44
+ status: 'starting',
45
+ pollIntervalMs: 3000,
46
+ extra,
47
+ controller: new AbortController(),
48
+ };
49
+ this.sessions.set(id, session);
50
+ try {
51
+ const begun = await this.begin(id, session.controller.signal, extra);
52
+ if (this.sessions.get(id) !== session)
53
+ return this.view(id);
54
+ session.begin = begun;
55
+ const payload = qrPayloadOf(begun);
56
+ session.qrUrl = payload.qrUrl;
57
+ session.qrImage = payload.qrImage;
58
+ if (!session.qrImage && session.qrUrl) {
59
+ try {
60
+ session.qrImage = await this.renderQr(session.qrUrl);
61
+ }
62
+ catch (error) {
63
+ const detail = error instanceof Error ? error.message : String(error);
64
+ session.status = 'failed';
65
+ session.error = '二维码生成失败,请查看本机日志';
66
+ session.qrUrl = undefined;
67
+ session.begin?.dispose?.();
68
+ this.log(`[pairing] ${id} 本地二维码生成失败:${detail}`);
69
+ return this.view(id);
70
+ }
71
+ }
72
+ session.expiresAt = begun.expiresAt;
73
+ // 记住初始有效期窗口:连接器自动换码时按同一窗口顺延,而不是被固定 5 分钟误杀
74
+ const windowMs = (begun.expiresAt ?? 0) - Date.now();
75
+ if (windowMs > 0)
76
+ session.expiryWindowMs = windowMs;
77
+ session.pollIntervalMs = begun.pollIntervalMs;
78
+ session.status = 'waiting';
79
+ session.hint = hintOf(id);
80
+ void this.loop(session);
81
+ return this.view(id);
82
+ }
83
+ catch (error) {
84
+ if (this.sessions.get(id) !== session)
85
+ return this.view(id);
86
+ session.status = 'failed';
87
+ const detail = error instanceof Error ? error.message : String(error);
88
+ session.error = '二维码生成失败,请查看本机日志';
89
+ this.log(`[pairing] ${id} 生成二维码失败:${detail}`);
90
+ return this.view(id);
91
+ }
92
+ }
93
+ async refresh(id) {
94
+ const extra = this.sessions.get(id)?.extra ?? {};
95
+ return this.start(id, extra);
96
+ }
97
+ cancel(id) {
98
+ const session = this.sessions.get(id);
99
+ if (!session)
100
+ return this.view(id);
101
+ // 外部平台已经返回凭据后必须完成本地保存,避免关闭弹窗留下“机器人已创建但账号未落盘”。
102
+ if (session.status === 'saving')
103
+ return this.view(id);
104
+ session.begin?.dispose?.();
105
+ session.controller.abort();
106
+ if (session.status !== 'success') {
107
+ session.status = 'cancelled';
108
+ session.qrUrl = undefined;
109
+ session.qrImage = undefined;
110
+ }
111
+ return this.view(id);
112
+ }
113
+ dispose() {
114
+ for (const id of [...this.sessions.keys()])
115
+ this.cancel(id);
116
+ this.sessions.clear();
117
+ }
118
+ async begin(id, signal, extra = {}) {
119
+ if (this.beginFn)
120
+ return this.beginFn(id, signal, extra);
121
+ if (id === 'feishu')
122
+ return beginFeishuQr(extra, signal);
123
+ throw new Error(`${id} 不支持扫码绑定`);
124
+ }
125
+ async loop(session) {
126
+ const begun = session.begin;
127
+ if (!begun)
128
+ return;
129
+ while (!session.controller.signal.aborted && this.sessions.get(session.channelId) === session) {
130
+ if (session.expiresAt && Date.now() >= session.expiresAt) {
131
+ session.status = 'expired';
132
+ session.error = '二维码已过期';
133
+ session.qrUrl = undefined;
134
+ session.qrImage = undefined;
135
+ session.begin?.dispose?.();
136
+ return;
137
+ }
138
+ try {
139
+ const polled = await begun.poll(session.controller.signal);
140
+ if (polled.qrUrl && polled.qrUrl !== session.qrUrl) {
141
+ try {
142
+ const qrImage = await this.renderQr(polled.qrUrl);
143
+ if (this.sessions.get(session.channelId) !== session)
144
+ return;
145
+ session.qrUrl = polled.qrUrl;
146
+ session.qrImage = qrImage;
147
+ if (session.expiryWindowMs)
148
+ session.expiresAt = Date.now() + session.expiryWindowMs;
149
+ }
150
+ catch (error) {
151
+ const detail = error instanceof Error ? error.message : String(error);
152
+ session.status = 'failed';
153
+ session.error = '二维码生成失败,请查看本机日志';
154
+ session.qrUrl = undefined;
155
+ session.qrImage = undefined;
156
+ session.begin?.dispose?.();
157
+ this.log(`[pairing] ${session.channelId} 本地二维码刷新失败:${detail}`);
158
+ return;
159
+ }
160
+ }
161
+ if (this.sessions.get(session.channelId) !== session)
162
+ return;
163
+ if (polled.pollIntervalMs)
164
+ session.pollIntervalMs = polled.pollIntervalMs;
165
+ if (polled.status === 'success' && polled.credentials) {
166
+ session.status = 'saving';
167
+ session.qrUrl = undefined;
168
+ session.qrImage = undefined;
169
+ session.hint = '正在保存账号';
170
+ const credentials = { ...session.extra, ...polled.credentials };
171
+ try {
172
+ await this.onSuccess?.(session.channelId, credentials);
173
+ if (this.sessions.get(session.channelId) !== session)
174
+ return;
175
+ session.status = 'success';
176
+ session.hint = '绑定成功';
177
+ }
178
+ catch (error) {
179
+ if (this.sessions.get(session.channelId) !== session)
180
+ return;
181
+ session.status = 'failed';
182
+ const detail = error instanceof Error ? error.message : String(error);
183
+ session.error = '保存凭据失败,请查看本机日志';
184
+ this.log(`[pairing] ${session.channelId} 保存凭据失败:${detail}`);
185
+ }
186
+ return;
187
+ }
188
+ if (polled.status === 'scanned') {
189
+ session.status = 'scanned';
190
+ session.hint = '已扫码,请在手机上确认';
191
+ }
192
+ else if (polled.status === 'expired' || polled.status === 'failed') {
193
+ session.status = polled.status;
194
+ if (polled.status === 'failed') {
195
+ if (polled.error)
196
+ this.log(`[pairing] ${session.channelId} 绑定失败:${polled.error}`);
197
+ session.error = '绑定失败,请查看本机日志';
198
+ }
199
+ else {
200
+ session.error = polled.error;
201
+ }
202
+ session.qrUrl = undefined;
203
+ session.qrImage = undefined;
204
+ session.begin?.dispose?.();
205
+ return;
206
+ }
207
+ else if (session.status !== 'scanned') {
208
+ session.status = 'waiting';
209
+ }
210
+ }
211
+ catch (error) {
212
+ if (session.controller.signal.aborted)
213
+ return;
214
+ this.log(`[pairing] ${session.channelId} 轮询失败:${error instanceof Error ? error.message : String(error)}`);
215
+ }
216
+ try {
217
+ await sleep(session.pollIntervalMs, session.controller.signal);
218
+ }
219
+ catch {
220
+ return;
221
+ }
222
+ }
223
+ }
224
+ }
225
+ function hintOf(id) {
226
+ if (id === 'feishu')
227
+ return '请使用飞书扫描二维码,将自动创建机器人';
228
+ return '请使用飞书扫描二维码';
229
+ }
@@ -0,0 +1,8 @@
1
+ /** 把扫码内容画成本地 data URL,避免前端把链接当成图片。 */
2
+ export async function renderQrDataUrl(payload) {
3
+ const QRCode = await import('qrcode');
4
+ const toDataURL = QRCode.toDataURL ?? QRCode.default?.toDataURL;
5
+ if (!toDataURL)
6
+ throw new Error('缺少二维码生成库 qrcode');
7
+ return toDataURL(payload, { width: 240, margin: 1, errorCorrectionLevel: 'M' });
8
+ }
@@ -0,0 +1,50 @@
1
+ /** 扫码绑定公共工具。 */
2
+ export function cleanString(value) {
3
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
4
+ }
5
+ export function asRecord(value) {
6
+ return value && typeof value === 'object' && !Array.isArray(value)
7
+ ? value
8
+ : {};
9
+ }
10
+ export async function readJson(response, label) {
11
+ let body;
12
+ try {
13
+ body = await response.json();
14
+ }
15
+ catch (error) {
16
+ throw new Error(`${label} 返回了非 JSON`, { cause: error });
17
+ }
18
+ if (!response.ok)
19
+ throw new Error(`${label} HTTP ${response.status}`);
20
+ return asRecord(body);
21
+ }
22
+ export function sleep(ms, signal) {
23
+ return new Promise((resolve, reject) => {
24
+ if (signal?.aborted) {
25
+ reject(signal.reason ?? new Error('aborted'));
26
+ return;
27
+ }
28
+ const timer = setTimeout(resolve, ms);
29
+ signal?.addEventListener('abort', () => {
30
+ clearTimeout(timer);
31
+ reject(signal.reason ?? new Error('aborted'));
32
+ }, { once: true });
33
+ });
34
+ }
35
+ export function remainingSeconds(expiresAt, now = Date.now()) {
36
+ if (!expiresAt)
37
+ return undefined;
38
+ return Math.max(0, Math.ceil((expiresAt - now) / 1000));
39
+ }
40
+ export function isRasterQr(value) {
41
+ if (!value)
42
+ return false;
43
+ return /^data:image\//i.test(value) || /\.(png|jpe?g|gif|webp)(\?|$)/i.test(value);
44
+ }
45
+ export function qrPayloadOf(begun) {
46
+ if (isRasterQr(begun.qrImage))
47
+ return { qrUrl: begun.qrUrl, qrImage: begun.qrImage };
48
+ const qrUrl = begun.qrUrl || begun.qrImage;
49
+ return { qrUrl, qrImage: undefined };
50
+ }
@@ -0,0 +1,35 @@
1
+ export function timeoutSignal(timeoutMs, parent) {
2
+ const timeout = AbortSignal.timeout(timeoutMs);
3
+ return parent ? AbortSignal.any([parent, timeout]) : timeout;
4
+ }
5
+ export function isAbortError(error) {
6
+ return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError');
7
+ }
8
+ export function sleepWithSignal(ms, signal) {
9
+ if (!signal)
10
+ return new Promise((resolve) => setTimeout(resolve, ms));
11
+ if (signal.aborted)
12
+ return Promise.reject(signal.reason);
13
+ return new Promise((resolve, reject) => {
14
+ const timer = setTimeout(done, ms);
15
+ signal.addEventListener('abort', aborted, { once: true });
16
+ function done() {
17
+ signal.removeEventListener('abort', aborted);
18
+ resolve();
19
+ }
20
+ function aborted() {
21
+ clearTimeout(timer);
22
+ reject(signal.reason);
23
+ }
24
+ });
25
+ }
26
+ /** SDK 不接收 AbortSignal 时及时结束等待;后续发送仍需检查同一信号。 */
27
+ export function fileOperation(operation, signal) {
28
+ return new Promise((resolve, reject) => {
29
+ const aborted = () => reject(signal.reason);
30
+ signal.addEventListener('abort', aborted, { once: true });
31
+ operation.then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted));
32
+ if (signal.aborted)
33
+ aborted();
34
+ });
35
+ }
@@ -0,0 +1,23 @@
1
+ export function normalizeUserId(userId) {
2
+ return userId?.trim() ?? '';
3
+ }
4
+ export function isUserAllowed(allowlist, userId) {
5
+ const id = normalizeUserId(userId);
6
+ if (!id)
7
+ return false;
8
+ for (const item of allowlist) {
9
+ if (item === id)
10
+ return true;
11
+ }
12
+ return false;
13
+ }
14
+ export function decideAccess(input) {
15
+ if (input.kind === 'group') {
16
+ return input.addressed === false ? 'ignore' : 'allow';
17
+ }
18
+ return input.userAllowed ? 'allow' : 'deny';
19
+ }
20
+ /** 工具授权比普通消息更严:必须已在白名单,且只能在私聊里答复。 */
21
+ export function canAnswerToolApproval(input) {
22
+ return input.userAllowed && input.kind !== 'group';
23
+ }
@@ -0,0 +1,19 @@
1
+ /** 解析 IM 会话要用的模型,禁止把空字符串传给 agents.create。 */
2
+ import { replyText } from './command-locale.js';
3
+ export function resolveImAgentOptions(input) {
4
+ const provider = input.provider?.trim() || input.fallback?.provider?.trim() || '';
5
+ const model = input.model?.trim() || input.fallback?.model?.trim() || '';
6
+ if (!provider || !model) {
7
+ throw new Error(replyText('请先在设置 → 飞书频道 页面选择模型。'));
8
+ }
9
+ return { provider, model };
10
+ }
11
+ /** 用 ctx.get 读默认模型,避免未 inject 时直接访问属性抛错。 */
12
+ export function readHostDefaultModel(ctx) {
13
+ try {
14
+ return ctx.get?.('agentDefaultModel')?.currentSelection?.();
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
@@ -0,0 +1,70 @@
1
+ /** IM 审批等待上限。到期按取消结束,不按用户拒绝。 */
2
+ export const APPROVAL_WAIT_MS = 5 * 60 * 1000;
3
+ export class ApprovalBroker {
4
+ pending = new Map();
5
+ get size() {
6
+ return this.pending.size;
7
+ }
8
+ wait(key, timeoutMs, signal) {
9
+ if (this.pending.has(key))
10
+ return undefined;
11
+ if (signal?.aborted)
12
+ return Promise.resolve(undefined);
13
+ return new Promise((resolve) => {
14
+ const settle = (value) => {
15
+ const entry = this.pending.get(key);
16
+ if (!entry)
17
+ return;
18
+ if (entry.timer)
19
+ clearTimeout(entry.timer);
20
+ if (entry.signal && entry.onAbort)
21
+ entry.signal.removeEventListener('abort', entry.onAbort);
22
+ this.pending.delete(key);
23
+ resolve(value);
24
+ };
25
+ const entry = { resolve: settle, accepting: false };
26
+ if (timeoutMs !== undefined && timeoutMs > 0) {
27
+ entry.timer = setTimeout(() => settle('timeout'), timeoutMs);
28
+ entry.timer.unref?.();
29
+ }
30
+ if (signal) {
31
+ entry.signal = signal;
32
+ entry.onAbort = () => settle(undefined);
33
+ signal.addEventListener('abort', entry.onAbort, { once: true });
34
+ }
35
+ this.pending.set(key, entry);
36
+ });
37
+ }
38
+ token(key) { return this.pending.get(key); }
39
+ has(key) {
40
+ return this.pending.has(key);
41
+ }
42
+ activate(key) {
43
+ const entry = this.pending.get(key);
44
+ if (!entry)
45
+ return false;
46
+ entry.accepting = true;
47
+ return true;
48
+ }
49
+ isReady(key) {
50
+ return this.pending.get(key)?.accepting === true;
51
+ }
52
+ answer(key, allow) {
53
+ const entry = this.pending.get(key);
54
+ if (!entry || !entry.accepting)
55
+ return false;
56
+ entry.resolve(allow ? 'allow' : 'reject');
57
+ return true;
58
+ }
59
+ cancel(key) {
60
+ const entry = this.pending.get(key);
61
+ if (!entry)
62
+ return false;
63
+ entry.resolve(undefined);
64
+ return true;
65
+ }
66
+ dispose() {
67
+ for (const key of [...this.pending.keys()])
68
+ this.cancel(key);
69
+ }
70
+ }
@@ -0,0 +1,38 @@
1
+ export function normalizeAssistantModel(input) {
2
+ const provider = typeof input.provider === 'string' ? input.provider.trim() : '';
3
+ const model = typeof input.model === 'string' ? input.model.trim() : '';
4
+ if (!provider || !model)
5
+ return undefined;
6
+ const reasoningEffort = normalizeEffort(input.reasoningEffort);
7
+ return reasoningEffort ? { provider, model, reasoningEffort } : { provider, model };
8
+ }
9
+ export function pickAssistantModel(...candidates) {
10
+ for (const item of candidates) {
11
+ if (item?.provider && item.model)
12
+ return item;
13
+ }
14
+ return undefined;
15
+ }
16
+ export function normalizeWorkspacePath(input) {
17
+ if (typeof input !== 'string')
18
+ return undefined;
19
+ const cwd = input.trim();
20
+ return cwd === '' ? undefined : cwd;
21
+ }
22
+ export function normalizePermission(input, officialNames) {
23
+ if (typeof input !== 'string')
24
+ return undefined;
25
+ // 兼容 0.1.13 及更早版本保存的旧值,读取后统一迁移到 Chat 标准值。
26
+ const permission = input.trim() === 'full-access' ? 'danger-full-access' : input.trim();
27
+ if (!permission || (officialNames !== undefined && !officialNames.includes(permission)))
28
+ return undefined;
29
+ return permission;
30
+ }
31
+ export function normalizeEffort(input) {
32
+ if (typeof input !== 'string')
33
+ return undefined;
34
+ const effort = input.trim();
35
+ if (!effort || effort === 'none' || effort === 'default')
36
+ return undefined;
37
+ return effort;
38
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 提取可投递的助手正文;启用过滤时,含工具调用的整条消息返回 undefined。
3
+ * undefined 表示主动抑制,不是投递成功;空串表示消息本身没有文字。
4
+ */
5
+ export function assistantText(content = [], skipInterimText = false) {
6
+ const blocks = content ?? [];
7
+ if (skipInterimText && blocks.some(block => block.type === 'tool-call'))
8
+ return undefined;
9
+ return blocks.filter(block => block.type === 'text' && typeof block.text === 'string')
10
+ .map(block => block.text ?? '').join('\n').trim();
11
+ }
@@ -0,0 +1,45 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { basename, dirname, join } from 'node:path';
4
+ /** 在目标文件同目录写临时文件,再原子替换,避免进程中断留下半截内容。 */
5
+ export function writeFileAtomicSync(file, data, mode) {
6
+ const dir = dirname(file);
7
+ mkdirSync(dir, { recursive: true });
8
+ const temp = join(dir, `.${basename(file)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
9
+ let fd;
10
+ try {
11
+ fd = openSync(temp, 'wx', mode);
12
+ writeFileSync(fd, data, 'utf8');
13
+ fsyncSync(fd);
14
+ closeSync(fd);
15
+ fd = undefined;
16
+ if (mode !== undefined) {
17
+ try {
18
+ chmodSync(temp, mode);
19
+ }
20
+ catch { /* Windows 或受限文件系统不支持 POSIX mode */ }
21
+ }
22
+ renameSync(temp, file);
23
+ }
24
+ finally {
25
+ if (fd !== undefined) {
26
+ try {
27
+ closeSync(fd);
28
+ }
29
+ catch { /* 已关闭 */ }
30
+ }
31
+ try {
32
+ unlinkSync(temp);
33
+ }
34
+ catch { /* rename 成功或临时文件尚未创建 */ }
35
+ }
36
+ }
37
+ /** 在回退为空状态前保留不可解析文件的唯一副本。 */
38
+ export function backupCorruptFileSync(file) {
39
+ if (!existsSync(file))
40
+ return undefined;
41
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
42
+ const backup = `${file}.corrupt-${stamp}-${process.pid}`;
43
+ renameSync(file, backup);
44
+ return backup;
45
+ }