codeksei 0.1.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 (80) hide show
  1. package/LICENSE +661 -0
  2. package/README.en.md +215 -0
  3. package/README.md +259 -0
  4. package/bin/codeksei.js +10 -0
  5. package/bin/cyberboss.js +11 -0
  6. package/package.json +86 -0
  7. package/scripts/install-background-tasks.ps1 +135 -0
  8. package/scripts/open_shared_wechat_thread.sh +94 -0
  9. package/scripts/open_wechat_thread.sh +117 -0
  10. package/scripts/shared-common.js +791 -0
  11. package/scripts/shared-open.js +46 -0
  12. package/scripts/shared-start.js +41 -0
  13. package/scripts/shared-status.js +74 -0
  14. package/scripts/shared-supervisor.js +141 -0
  15. package/scripts/shared-task-runner.ps1 +87 -0
  16. package/scripts/shared-watchdog.js +290 -0
  17. package/scripts/show_shared_status.sh +53 -0
  18. package/scripts/start_shared_app_server.sh +65 -0
  19. package/scripts/start_shared_wechat.sh +108 -0
  20. package/scripts/timeline-screenshot.sh +15 -0
  21. package/scripts/uninstall-background-tasks.ps1 +23 -0
  22. package/src/adapters/channel/weixin/account-store.js +135 -0
  23. package/src/adapters/channel/weixin/api-v2.js +258 -0
  24. package/src/adapters/channel/weixin/api.js +180 -0
  25. package/src/adapters/channel/weixin/context-token-store.js +84 -0
  26. package/src/adapters/channel/weixin/index.js +605 -0
  27. package/src/adapters/channel/weixin/legacy.js +567 -0
  28. package/src/adapters/channel/weixin/login-common.js +63 -0
  29. package/src/adapters/channel/weixin/login-legacy.js +124 -0
  30. package/src/adapters/channel/weixin/login-v2.js +186 -0
  31. package/src/adapters/channel/weixin/media-mime.js +22 -0
  32. package/src/adapters/channel/weixin/media-receive.js +370 -0
  33. package/src/adapters/channel/weixin/media-send.js +331 -0
  34. package/src/adapters/channel/weixin/message-utils-v2.js +282 -0
  35. package/src/adapters/channel/weixin/message-utils.js +199 -0
  36. package/src/adapters/channel/weixin/protocol.js +77 -0
  37. package/src/adapters/channel/weixin/redact.js +41 -0
  38. package/src/adapters/channel/weixin/reminder-queue-store.js +101 -0
  39. package/src/adapters/channel/weixin/sync-buffer-store.js +35 -0
  40. package/src/adapters/runtime/codex/events.js +252 -0
  41. package/src/adapters/runtime/codex/index.js +502 -0
  42. package/src/adapters/runtime/codex/message-utils.js +141 -0
  43. package/src/adapters/runtime/codex/model-catalog.js +106 -0
  44. package/src/adapters/runtime/codex/protocol-leak-monitor.js +75 -0
  45. package/src/adapters/runtime/codex/rpc-client.js +443 -0
  46. package/src/adapters/runtime/codex/session-store.js +376 -0
  47. package/src/app/channel-send-file-cli.js +57 -0
  48. package/src/app/diary-write-cli.js +620 -0
  49. package/src/app/note-auto-cli.js +201 -0
  50. package/src/app/note-sync-cli.js +130 -0
  51. package/src/app/project-radar-cli.js +165 -0
  52. package/src/app/reminder-write-cli.js +210 -0
  53. package/src/app/review-cli.js +134 -0
  54. package/src/app/system-checkin-poller.js +100 -0
  55. package/src/app/system-send-cli.js +129 -0
  56. package/src/app/timeline-event-cli.js +273 -0
  57. package/src/app/timeline-screenshot-cli.js +109 -0
  58. package/src/core/app.js +1810 -0
  59. package/src/core/branding.js +167 -0
  60. package/src/core/command-registry.js +609 -0
  61. package/src/core/config.js +84 -0
  62. package/src/core/default-targets.js +163 -0
  63. package/src/core/durable-note-schema.js +325 -0
  64. package/src/core/instructions-template.js +31 -0
  65. package/src/core/note-sync.js +433 -0
  66. package/src/core/project-radar.js +402 -0
  67. package/src/core/review-semantic.js +524 -0
  68. package/src/core/review.js +1081 -0
  69. package/src/core/shared-bridge-heartbeat.js +140 -0
  70. package/src/core/stream-delivery.js +990 -0
  71. package/src/core/system-message-dispatcher.js +68 -0
  72. package/src/core/system-message-queue-store.js +128 -0
  73. package/src/core/thread-state-store.js +135 -0
  74. package/src/core/timeline-screenshot-queue-store.js +134 -0
  75. package/src/core/workspace-alias.js +163 -0
  76. package/src/core/workspace-bootstrap.js +338 -0
  77. package/src/index.js +270 -0
  78. package/src/integrations/timeline/index.js +191 -0
  79. package/templates/weixin-instructions.md +53 -0
  80. package/templates/weixin-operations.md +69 -0
@@ -0,0 +1,1810 @@
1
+ const os = require("os");
2
+ const path = require("path");
3
+ const crypto = require("crypto");
4
+ const fs = require("fs");
5
+ const { createWeixinChannelAdapter } = require("../adapters/channel/weixin");
6
+ const { persistIncomingWeixinAttachments } = require("../adapters/channel/weixin/media-receive");
7
+ const { createCodexRuntimeAdapter } = require("../adapters/runtime/codex");
8
+ const { findModelByQuery } = require("../adapters/runtime/codex/model-catalog");
9
+ const { createTimelineIntegration } = require("../integrations/timeline");
10
+ const { buildWeixinHelpText } = require("./command-registry");
11
+ const { resolvePreferredSenderId } = require("./default-targets");
12
+ const { StreamDelivery } = require("./stream-delivery");
13
+ const { ThreadStateStore } = require("./thread-state-store");
14
+ const { SystemMessageQueueStore } = require("./system-message-queue-store");
15
+ const { SystemMessageDispatcher } = require("./system-message-dispatcher");
16
+ const { TimelineScreenshotQueueStore } = require("./timeline-screenshot-queue-store");
17
+ const { writeSharedBridgeHeartbeat } = require("./shared-bridge-heartbeat");
18
+ const { ReminderQueueStore } = require("../adapters/channel/weixin/reminder-queue-store");
19
+ const { runSystemCheckinPoller } = require("../app/system-checkin-poller");
20
+
21
+ const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
22
+ const MIN_LONG_POLL_TIMEOUT_MS = 2_000;
23
+ const SESSION_EXPIRED_ERRCODE = -14;
24
+ const RETRY_DELAY_MS = 2_000;
25
+ const BACKOFF_DELAY_MS = 30_000;
26
+ const MAX_CONSECUTIVE_FAILURES = 3;
27
+ const FIRST_RUNTIME_EVENT_NOTICE_TIMEOUT_MS = 8_000;
28
+ const FIRST_RUNTIME_EVENT_FAILURE_TIMEOUT_MS = 45_000;
29
+ const STREAM_SETTLEMENT_TIMEOUT_MS = 90_000;
30
+
31
+ class CyberbossApp {
32
+ constructor(config) {
33
+ this.config = config;
34
+ this.channelAdapter = createWeixinChannelAdapter(config);
35
+ this.runtimeAdapter = createCodexRuntimeAdapter(config);
36
+ this.timelineIntegration = createTimelineIntegration(config);
37
+ this.threadStateStore = new ThreadStateStore();
38
+ this.systemMessageQueue = new SystemMessageQueueStore({ filePath: config.systemMessageQueueFile });
39
+ this.timelineScreenshotQueue = new TimelineScreenshotQueueStore({ filePath: config.timelineScreenshotQueueFile });
40
+ this.reminderQueue = new ReminderQueueStore({ filePath: config.reminderQueueFile });
41
+ this.systemMessageDispatcher = null;
42
+ this.streamDelivery = new StreamDelivery({
43
+ channelAdapter: this.channelAdapter,
44
+ sessionStore: this.runtimeAdapter.getSessionStore(),
45
+ weixinReplyMode: config.weixinReplyMode,
46
+ deliveryTraceEnabled: config.weixinDeliveryTrace,
47
+ onDeliveryFailure: (payload) => this.handleReplyDeliveryFailure(payload),
48
+ });
49
+ this.pendingRuntimeEventWatchdogs = new Map();
50
+ this.pendingTurnSettlementWatchdogs = new Map();
51
+ this.pendingWorkspaceBootstrapByThreadId = new Map();
52
+ this.runtimeEventChain = Promise.resolve();
53
+ this.runtimeAdapter.onEvent((event) => {
54
+ this.confirmPendingWorkspaceBootstrap(event);
55
+ this.clearRuntimeEventWatchdog(event?.payload?.threadId);
56
+ this.refreshTurnSettlementWatchdog(event);
57
+ this.threadStateStore.applyRuntimeEvent(event);
58
+ this.runtimeEventChain = this.runtimeEventChain
59
+ .catch(() => {})
60
+ .then(() => this.handleRuntimeEvent(event))
61
+ .catch((error) => {
62
+ const message = error instanceof Error ? error.stack || error.message : String(error);
63
+ console.error(`[codeksei] runtime event handling failed type=${event?.type || "(unknown)"} ${message}`);
64
+ });
65
+ });
66
+ }
67
+
68
+ printDoctor() {
69
+ console.log(JSON.stringify({
70
+ stateDir: this.config.stateDir,
71
+ channel: this.channelAdapter.describe(),
72
+ runtime: this.runtimeAdapter.describe(),
73
+ timeline: this.timelineIntegration.describe(),
74
+ threads: this.threadStateStore.snapshot(),
75
+ }, null, 2));
76
+ }
77
+
78
+ async login() {
79
+ await this.channelAdapter.login();
80
+ }
81
+
82
+ printAccounts() {
83
+ this.channelAdapter.printAccounts();
84
+ }
85
+
86
+ updateBridgeHeartbeat(patch) {
87
+ const filePath = normalizeText(this.config.sharedBridgeHeartbeatFile);
88
+ if (!filePath) {
89
+ return;
90
+ }
91
+ try {
92
+ writeSharedBridgeHeartbeat(filePath, patch);
93
+ } catch (error) {
94
+ console.error(`[codeksei] bridge heartbeat write failed: ${formatErrorMessage(error)}`);
95
+ }
96
+ }
97
+
98
+ async start() {
99
+ const account = this.channelAdapter.resolveAccount();
100
+ this.activeAccountId = account.accountId;
101
+ this.updateBridgeHeartbeat({
102
+ pid: process.pid,
103
+ status: "starting",
104
+ accountId: account.accountId,
105
+ workspaceRoot: this.config.workspaceRoot,
106
+ startedAt: new Date().toISOString(),
107
+ consecutiveFailures: 0,
108
+ lastError: "",
109
+ });
110
+ this.systemMessageDispatcher = new SystemMessageDispatcher({
111
+ queueStore: this.systemMessageQueue,
112
+ config: this.config,
113
+ accountId: account.accountId,
114
+ });
115
+ const runtimeState = await this.runtimeAdapter.initialize();
116
+ const knownContextTokens = Object.keys(this.channelAdapter.getKnownContextTokens()).length;
117
+ const syncBuffer = this.channelAdapter.loadSyncBuffer();
118
+ await this.restoreBoundThreadSubscriptions();
119
+ this.updateBridgeHeartbeat({
120
+ pid: process.pid,
121
+ status: "running",
122
+ accountId: account.accountId,
123
+ workspaceRoot: this.config.workspaceRoot,
124
+ codexEndpoint: runtimeState.endpoint,
125
+ consecutiveFailures: 0,
126
+ lastError: "",
127
+ });
128
+
129
+ console.log("[codeksei] bootstrap ok");
130
+ console.log(`[codeksei] channel=${this.channelAdapter.describe().id}`);
131
+ console.log(`[codeksei] runtime=${this.runtimeAdapter.describe().id}`);
132
+ console.log(`[codeksei] timeline=${this.timelineIntegration.describe().id}`);
133
+ console.log(`[codeksei] account=${account.accountId}`);
134
+ console.log(`[codeksei] baseUrl=${account.baseUrl}`);
135
+ console.log(`[codeksei] workspaceRoot=${this.config.workspaceRoot}`);
136
+ console.log(`[codeksei] knownContextTokens=${knownContextTokens}`);
137
+ console.log(`[codeksei] syncBuffer=${syncBuffer ? "ready" : "empty"}`);
138
+ console.log(`[codeksei] weixinReplyMode=${this.config.weixinReplyMode}`);
139
+ console.log(`[codeksei] weixinDeliveryTrace=${this.config.weixinDeliveryTrace ? "on" : "off"}`);
140
+ console.log(`[codeksei] codexEndpoint=${runtimeState.endpoint}`);
141
+ console.log(`[codeksei] codexModels=${runtimeState.models.length}`);
142
+ console.log("[codeksei] 最小消息链路已启动,正在等待微信消息。");
143
+ if (this.config.startWithCheckin) {
144
+ console.log("[codeksei] checkin: enabled");
145
+ void runSystemCheckinPoller(this.config).catch((error) => {
146
+ console.error(`[codeksei] checkin poller stopped: ${error.message}`);
147
+ });
148
+ }
149
+
150
+ const shutdown = createShutdownController(async () => {
151
+ await this.runtimeAdapter.close();
152
+ });
153
+
154
+ try {
155
+ let consecutiveFailures = 0;
156
+ while (!shutdown.stopped) {
157
+ try {
158
+ this.updateBridgeHeartbeat({
159
+ pid: process.pid,
160
+ status: "running",
161
+ accountId: account.accountId,
162
+ workspaceRoot: this.config.workspaceRoot,
163
+ codexEndpoint: runtimeState.endpoint,
164
+ lastPollStartedAt: new Date().toISOString(),
165
+ });
166
+ await this.flushDueReminders(account);
167
+ await this.flushPendingSystemMessages();
168
+ await this.flushPendingTimelineScreenshots(account);
169
+ const response = await this.channelAdapter.getUpdates({
170
+ syncBuffer: this.channelAdapter.loadSyncBuffer(),
171
+ timeoutMs: this.resolveLongPollTimeoutMs(),
172
+ });
173
+ assertWeixinUpdateResponse(response);
174
+ consecutiveFailures = 0;
175
+ this.updateBridgeHeartbeat({
176
+ pid: process.pid,
177
+ status: "running",
178
+ accountId: account.accountId,
179
+ workspaceRoot: this.config.workspaceRoot,
180
+ codexEndpoint: runtimeState.endpoint,
181
+ lastPollSucceededAt: new Date().toISOString(),
182
+ consecutiveFailures: 0,
183
+ lastError: "",
184
+ });
185
+ const messages = Array.isArray(response?.msgs) ? response.msgs : [];
186
+ for (const message of messages) {
187
+ if (shutdown.stopped) {
188
+ break;
189
+ }
190
+ await this.handleIncomingMessage(message);
191
+ }
192
+ await this.flushDueReminders(account);
193
+ await this.flushPendingSystemMessages();
194
+ await this.flushPendingTimelineScreenshots(account);
195
+ } catch (error) {
196
+ if (shutdown.stopped) {
197
+ break;
198
+ }
199
+
200
+ if (isSessionExpiredError(error)) {
201
+ throw new Error("微信会话已失效,请重新执行 `npm run login`");
202
+ }
203
+
204
+ consecutiveFailures += 1;
205
+ const errorMessage = formatErrorMessage(error);
206
+ this.updateBridgeHeartbeat({
207
+ pid: process.pid,
208
+ status: "degraded",
209
+ accountId: account.accountId,
210
+ workspaceRoot: this.config.workspaceRoot,
211
+ codexEndpoint: runtimeState.endpoint,
212
+ lastPollFailedAt: new Date().toISOString(),
213
+ consecutiveFailures,
214
+ lastError: errorMessage,
215
+ });
216
+ console.error(`[codeksei] poll failed: ${errorMessage}`);
217
+ await sleep(consecutiveFailures >= MAX_CONSECUTIVE_FAILURES ? BACKOFF_DELAY_MS : RETRY_DELAY_MS);
218
+ }
219
+ }
220
+ } finally {
221
+ shutdown.dispose();
222
+ this.updateBridgeHeartbeat({
223
+ pid: process.pid,
224
+ status: "stopped",
225
+ stoppedAt: new Date().toISOString(),
226
+ });
227
+ await this.runtimeAdapter.close();
228
+ }
229
+ }
230
+
231
+ async sendTimelineScreenshot({ senderId = "", args = [], outputFile = "" } = {}) {
232
+ const targetUserId = normalizeText(senderId) || this.resolveDefaultTerminalUser();
233
+ if (!targetUserId) {
234
+ throw new Error("无法确定时间轴截图要发送给哪个微信用户,先配置 CODEKSEI_ALLOWED_USER_IDS(或旧的 CYBERBOSS_ALLOWED_USER_IDS)");
235
+ }
236
+ const contextToken = this.channelAdapter.getKnownContextTokens()[targetUserId] || "";
237
+ if (!contextToken) {
238
+ throw new Error(`找不到用户 ${targetUserId} 的 context token,先让这个用户和 bot 聊过一次`);
239
+ }
240
+
241
+ const normalizedArgs = Array.isArray(args)
242
+ ? args.map((value) => String(value ?? "")).filter(Boolean)
243
+ : [];
244
+ const resolvedOutputFile = normalizeText(outputFile) || resolveTimelineScreenshotOutput(normalizedArgs);
245
+ const finalArgs = resolvedOutputFile
246
+ ? normalizedArgs
247
+ : [...normalizedArgs, "--output", path.join(os.tmpdir(), `codeksei-timeline-${Date.now()}.png`)];
248
+ const savedPath = resolveTimelineScreenshotOutput(finalArgs);
249
+
250
+ await this.channelAdapter.sendTyping({
251
+ userId: targetUserId,
252
+ status: 1,
253
+ contextToken,
254
+ }).catch(() => {});
255
+ await this.timelineIntegration.runSubcommand("screenshot", finalArgs);
256
+ await this.channelAdapter.sendFile({
257
+ userId: targetUserId,
258
+ filePath: savedPath,
259
+ contextToken,
260
+ });
261
+ await this.channelAdapter.sendTyping({
262
+ userId: targetUserId,
263
+ status: 0,
264
+ contextToken,
265
+ }).catch(() => {});
266
+ return { userId: targetUserId, filePath: savedPath };
267
+ }
268
+
269
+ async sendLocalFileToCurrentChat({ senderId = "", filePath = "" } = {}) {
270
+ const targetUserId = normalizeText(senderId) || this.resolveDefaultTerminalUser();
271
+ if (!targetUserId) {
272
+ throw new Error("无法确定文件要发送给哪个微信用户,先配置 CODEKSEI_ALLOWED_USER_IDS(或旧的 CYBERBOSS_ALLOWED_USER_IDS)");
273
+ }
274
+
275
+ const contextToken = this.channelAdapter.getKnownContextTokens()[targetUserId] || "";
276
+ if (!contextToken) {
277
+ throw new Error(`找不到用户 ${targetUserId} 的 context token,先让这个用户和 bot 聊过一次`);
278
+ }
279
+
280
+ const requestedPath = normalizeText(filePath);
281
+ if (!requestedPath) {
282
+ throw new Error("缺少要发送的文件路径");
283
+ }
284
+ const resolvedPath = path.resolve(requestedPath);
285
+ if (!fs.existsSync(resolvedPath)) {
286
+ throw new Error(`文件不存在: ${resolvedPath}`);
287
+ }
288
+ const stat = fs.statSync(resolvedPath);
289
+ if (!stat.isFile()) {
290
+ throw new Error(`只能发送文件,不能发送目录: ${resolvedPath}`);
291
+ }
292
+
293
+ await this.channelAdapter.sendTyping({
294
+ userId: targetUserId,
295
+ status: 1,
296
+ contextToken,
297
+ }).catch(() => {});
298
+ await this.channelAdapter.sendFile({
299
+ userId: targetUserId,
300
+ filePath: resolvedPath,
301
+ contextToken,
302
+ });
303
+ await this.channelAdapter.sendTyping({
304
+ userId: targetUserId,
305
+ status: 0,
306
+ contextToken,
307
+ }).catch(() => {});
308
+ return { userId: targetUserId, filePath: resolvedPath };
309
+ }
310
+
311
+ async handleIncomingMessage(message) {
312
+ const normalized = this.channelAdapter.normalizeIncomingMessage(message);
313
+ if (!normalized) {
314
+ return;
315
+ }
316
+
317
+ await this.handlePreparedMessage(normalized, { allowCommands: true });
318
+ }
319
+
320
+ resolveDefaultTerminalUser() {
321
+ return resolvePreferredSenderId({
322
+ config: this.config,
323
+ accountId: this.channelAdapter.resolveAccount().accountId,
324
+ sessionStore: this.runtimeAdapter.getSessionStore(),
325
+ });
326
+ }
327
+
328
+ async handlePreparedMessage(normalized, { allowCommands }) {
329
+ const bindingKey = this.runtimeAdapter.getSessionStore().buildBindingKey({
330
+ workspaceId: normalized.workspaceId,
331
+ accountId: normalized.accountId,
332
+ senderId: normalized.senderId,
333
+ });
334
+ this.streamDelivery.setReplyTarget(bindingKey, {
335
+ userId: normalized.senderId,
336
+ contextToken: normalized.contextToken,
337
+ provider: normalized.provider,
338
+ });
339
+
340
+ const command = parseChannelCommand(normalized.text);
341
+ if (allowCommands && command) {
342
+ await this.dispatchChannelCommand(normalized, command);
343
+ return;
344
+ }
345
+
346
+ const workspaceRoot = this.resolveWorkspaceRoot(bindingKey);
347
+ const prepared = await this.prepareIncomingMessageForRuntime(normalized, workspaceRoot);
348
+ if (!prepared) {
349
+ return;
350
+ }
351
+
352
+ await this.channelAdapter.sendTyping({
353
+ userId: normalized.senderId,
354
+ status: 1,
355
+ contextToken: normalized.contextToken,
356
+ }).catch(() => {});
357
+
358
+ try {
359
+ const turn = await this.runtimeAdapter.sendTextTurn({
360
+ bindingKey,
361
+ workspaceRoot,
362
+ text: prepared.text,
363
+ model: this.runtimeAdapter.getSessionStore().getCodexParamsForWorkspace(bindingKey, workspaceRoot).model,
364
+ accessMode: this.config.codexAccessMode,
365
+ metadata: {
366
+ workspaceId: prepared.workspaceId,
367
+ accountId: prepared.accountId,
368
+ senderId: prepared.senderId,
369
+ },
370
+ });
371
+ this.streamDelivery.queueReplyTargetForThread(turn.threadId, {
372
+ userId: prepared.senderId,
373
+ contextToken: prepared.contextToken,
374
+ provider: prepared.provider,
375
+ });
376
+ if (turn.workspaceBootstrapPending) {
377
+ this.queuePendingWorkspaceBootstrap({
378
+ bindingKey,
379
+ workspaceRoot,
380
+ threadId: turn.threadId,
381
+ });
382
+ }
383
+ this.scheduleRuntimeEventWatchdog({
384
+ bindingKey,
385
+ workspaceRoot,
386
+ normalized: prepared,
387
+ threadId: turn.threadId,
388
+ });
389
+ } catch (error) {
390
+ const messageText = error instanceof Error ? error.message : String(error || "unknown error");
391
+ await this.channelAdapter.sendText({
392
+ userId: normalized.senderId,
393
+ text: `处理失败:${messageText}`,
394
+ contextToken: normalized.contextToken,
395
+ }).catch(() => {});
396
+ }
397
+ }
398
+
399
+ scheduleRuntimeEventWatchdog({ bindingKey, workspaceRoot, normalized, threadId = "" }) {
400
+ const sessionStore = this.runtimeAdapter.getSessionStore();
401
+ const candidateThreadId = normalizeCommandArgument(threadId)
402
+ || sessionStore.getThreadIdForWorkspace(bindingKey, workspaceRoot);
403
+ const normalizedThreadId = normalizeCommandArgument(candidateThreadId);
404
+ if (!normalizedThreadId) {
405
+ return;
406
+ }
407
+
408
+ this.clearRuntimeEventWatchdog(normalizedThreadId);
409
+ const noticeTimer = setTimeout(async () => {
410
+ const watchdog = this.pendingRuntimeEventWatchdogs.get(normalizedThreadId);
411
+ if (!watchdog) {
412
+ return;
413
+ }
414
+ const currentThreadState = this.threadStateStore.getThreadState(normalizedThreadId);
415
+ if (currentThreadState?.status === "running" || currentThreadState?.turnId) {
416
+ return;
417
+ }
418
+ watchdog.noticeSent = true;
419
+ await this.channelAdapter.sendText({
420
+ userId: normalized.senderId,
421
+ contextToken: normalized.contextToken,
422
+ preserveBlock: true,
423
+ text: [
424
+ "这条消息已经发到 bridge,但 Codex runtime 还没有返回首个事件。",
425
+ "如果你看到 terminal 正在 reconnecting,这一轮大概率还卡在共享线程启动阶段。",
426
+ "先不用一直空等;如果稍后连上,消息会继续往下跑。",
427
+ `workspace: ${workspaceRoot}`,
428
+ `thread: ${normalizedThreadId}`,
429
+ ].join("\n"),
430
+ }).catch(() => {});
431
+ }, FIRST_RUNTIME_EVENT_NOTICE_TIMEOUT_MS);
432
+ const failureTimer = setTimeout(async () => {
433
+ this.pendingRuntimeEventWatchdogs.delete(normalizedThreadId);
434
+ const currentThreadState = this.threadStateStore.getThreadState(normalizedThreadId);
435
+ if (currentThreadState?.status === "running" || currentThreadState?.turnId) {
436
+ return;
437
+ }
438
+ await this.channelAdapter.sendTyping({
439
+ userId: normalized.senderId,
440
+ status: 0,
441
+ contextToken: normalized.contextToken,
442
+ }).catch(() => {});
443
+ await this.channelAdapter.sendText({
444
+ userId: normalized.senderId,
445
+ contextToken: normalized.contextToken,
446
+ preserveBlock: true,
447
+ text: [
448
+ "这条消息已经发到 bridge,但 Codex runtime 直到现在都没有返回首个事件。",
449
+ "如果 terminal 里的那轮 reconnecting 已经跑完 5 次,这条共享线程基本可以判定没有真正启动成功。",
450
+ `workspace: ${workspaceRoot}`,
451
+ `thread: ${normalizedThreadId}`,
452
+ "优先检查:共享 app-server 是否正常、当前终端是否接在同一个 thread、runtime 是否真的开始处理这条消息。",
453
+ "如果你正在帮用户排查,直接按这套顺序做:",
454
+ "1. 在项目目录执行 npm run shared:status",
455
+ "2. 如果 bridge 不在,先执行 npm run shared:start",
456
+ "3. 再开一个终端执行 npm run shared:open",
457
+ "4. 确认 terminal 里打开的是上面这条 thread,而不是另一条私有线程",
458
+ ].join("\n"),
459
+ }).catch(() => {});
460
+ }, FIRST_RUNTIME_EVENT_FAILURE_TIMEOUT_MS);
461
+ this.pendingRuntimeEventWatchdogs.set(normalizedThreadId, {
462
+ noticeTimer,
463
+ failureTimer,
464
+ noticeSent: false,
465
+ });
466
+ }
467
+
468
+ clearRuntimeEventWatchdog(threadId) {
469
+ const normalizedThreadId = normalizeCommandArgument(threadId);
470
+ if (!normalizedThreadId) {
471
+ return;
472
+ }
473
+ const watchdog = this.pendingRuntimeEventWatchdogs.get(normalizedThreadId);
474
+ if (!watchdog) {
475
+ return;
476
+ }
477
+ clearTimeout(watchdog.noticeTimer);
478
+ clearTimeout(watchdog.failureTimer);
479
+ this.pendingRuntimeEventWatchdogs.delete(normalizedThreadId);
480
+ }
481
+
482
+ refreshTurnSettlementWatchdog(event) {
483
+ const threadId = normalizeCommandArgument(event?.payload?.threadId);
484
+ const turnId = normalizeCommandArgument(event?.payload?.turnId);
485
+ if (!threadId || !turnId) {
486
+ return;
487
+ }
488
+
489
+ if (event.type === "runtime.turn.completed" || event.type === "runtime.turn.failed" || event.type === "runtime.approval.requested") {
490
+ this.clearTurnSettlementWatchdog(threadId, turnId);
491
+ return;
492
+ }
493
+ if (event.type !== "runtime.reply.delta" && event.type !== "runtime.reply.completed") {
494
+ return;
495
+ }
496
+
497
+ const watchdogKey = buildTurnSettlementWatchdogKey(threadId, turnId);
498
+ this.clearTurnSettlementWatchdog(threadId, turnId);
499
+ const timer = setTimeout(async () => {
500
+ this.pendingTurnSettlementWatchdogs.delete(watchdogKey);
501
+ const currentThreadState = this.threadStateStore.getThreadState(threadId);
502
+ if (!currentThreadState || currentThreadState.turnId !== turnId || currentThreadState.status !== "running") {
503
+ return;
504
+ }
505
+
506
+ const linked = this.runtimeAdapter.getSessionStore().findBindingForThreadId(threadId);
507
+ const workspaceRoot = normalizeText(linked?.workspaceRoot);
508
+ // Once a reply has already started streaming, hanging forever is worse
509
+ // than surfacing a partial answer. We only trip this guard after a long
510
+ // quiet period to avoid fighting normal long-running tool calls.
511
+ console.error(
512
+ `[codeksei] runtime settlement watchdog expired `
513
+ + `thread=${threadId} turn=${turnId} workspace=${workspaceRoot || "(unknown)"}`
514
+ );
515
+ await this.streamDelivery.finalizeAbandonedTurn({
516
+ threadId,
517
+ turnId,
518
+ trailingText: [
519
+ "【系统提示】",
520
+ "这一轮回复已经开始输出,但 Codex runtime 一直没有发回完成或失败事件。",
521
+ "我先把目前拿到的内容停在这里,避免你继续看到假 typing。",
522
+ "如果要继续,请直接再发一句“继续刚才那条未完回复”。",
523
+ ].join("\n"),
524
+ });
525
+ this.threadStateStore.markTurnFailed(
526
+ threadId,
527
+ turnId,
528
+ "这轮回复已经开始输出,但 Codex runtime 一直没有发回完成或失败事件。"
529
+ );
530
+ this.runtimeAdapter.getSessionStore().clearApprovalPrompt(threadId);
531
+ await this.stopTypingForThread(threadId);
532
+ }, STREAM_SETTLEMENT_TIMEOUT_MS);
533
+ this.pendingTurnSettlementWatchdogs.set(watchdogKey, { timer });
534
+ }
535
+
536
+ clearTurnSettlementWatchdog(threadId, turnId) {
537
+ const watchdogKey = buildTurnSettlementWatchdogKey(threadId, turnId);
538
+ if (!watchdogKey) {
539
+ return;
540
+ }
541
+ const watchdog = this.pendingTurnSettlementWatchdogs.get(watchdogKey);
542
+ if (!watchdog) {
543
+ return;
544
+ }
545
+ clearTimeout(watchdog.timer);
546
+ this.pendingTurnSettlementWatchdogs.delete(watchdogKey);
547
+ }
548
+
549
+ queuePendingWorkspaceBootstrap({ bindingKey, workspaceRoot, threadId }) {
550
+ const normalizedBindingKey = normalizeText(bindingKey);
551
+ const normalizedWorkspaceRoot = normalizeText(workspaceRoot);
552
+ const normalizedThreadId = normalizeText(threadId);
553
+ if (!normalizedBindingKey || !normalizedWorkspaceRoot || !normalizedThreadId) {
554
+ return;
555
+ }
556
+ this.pendingWorkspaceBootstrapByThreadId.set(normalizedThreadId, {
557
+ bindingKey: normalizedBindingKey,
558
+ workspaceRoot: normalizedWorkspaceRoot,
559
+ });
560
+ }
561
+
562
+ confirmPendingWorkspaceBootstrap(event) {
563
+ if (!event || event.type === "runtime.usage.updated") {
564
+ return;
565
+ }
566
+ const threadId = normalizeText(event?.payload?.threadId);
567
+ if (!threadId) {
568
+ return;
569
+ }
570
+ const pending = this.pendingWorkspaceBootstrapByThreadId.get(threadId);
571
+ if (!pending?.bindingKey || !pending?.workspaceRoot) {
572
+ return;
573
+ }
574
+ // Do not mark workspace bootstrap as done when sendUserMessage merely
575
+ // returns. In shared mode the runtime can still stall before emitting the
576
+ // first real thread event, and prematurely persisting success would skip the
577
+ // next retry's continuity bootstrap.
578
+ this.runtimeAdapter.getSessionStore().rememberWorkspaceBootstrapForThread(
579
+ pending.bindingKey,
580
+ pending.workspaceRoot,
581
+ threadId
582
+ );
583
+ this.pendingWorkspaceBootstrapByThreadId.delete(threadId);
584
+ }
585
+
586
+ async prepareIncomingMessageForRuntime(normalized, workspaceRoot) {
587
+ const attachments = Array.isArray(normalized.attachments) ? normalized.attachments : [];
588
+ if (!attachments.length) {
589
+ return {
590
+ ...normalized,
591
+ originalText: normalized.text,
592
+ text: buildCodexInboundText(normalized, { saved: [], failed: [] }, this.config),
593
+ attachments: [],
594
+ attachmentFailures: [],
595
+ };
596
+ }
597
+
598
+ const persisted = await persistIncomingWeixinAttachments({
599
+ attachments,
600
+ stateDir: this.config.stateDir,
601
+ cdnBaseUrl: this.config.weixinCdnBaseUrl,
602
+ messageId: normalized.messageId,
603
+ receivedAt: normalized.receivedAt,
604
+ });
605
+
606
+ if (!persisted.saved.length && persisted.failed.length && !String(normalized.text || "").trim()) {
607
+ await this.channelAdapter.sendText({
608
+ userId: normalized.senderId,
609
+ text: `图片/附件接收失败:${persisted.failed.map((item) => item.reason).join("; ")}`,
610
+ contextToken: normalized.contextToken,
611
+ preserveBlock: true,
612
+ }).catch(() => {});
613
+ return null;
614
+ }
615
+
616
+ const codexInboundText = buildCodexInboundText(normalized, persisted, this.config);
617
+ if (!codexInboundText) {
618
+ await this.channelAdapter.sendText({
619
+ userId: normalized.senderId,
620
+ text: `图片/附件接收失败:${persisted.failed.map((item) => item.reason).join("; ")}`,
621
+ contextToken: normalized.contextToken,
622
+ preserveBlock: true,
623
+ }).catch(() => {});
624
+ return null;
625
+ }
626
+
627
+ return {
628
+ ...normalized,
629
+ originalText: normalized.text,
630
+ text: codexInboundText,
631
+ attachments: persisted.saved,
632
+ attachmentFailures: persisted.failed,
633
+ };
634
+ }
635
+
636
+ async flushPendingSystemMessages() {
637
+ const pendingMessages = this.systemMessageDispatcher?.drainPending() || [];
638
+ for (const message of pendingMessages) {
639
+ try {
640
+ const dispatched = await this.dispatchSystemMessage(message);
641
+ if (!dispatched) {
642
+ this.systemMessageDispatcher.requeue(message);
643
+ }
644
+ } catch {
645
+ this.systemMessageDispatcher?.requeue(message);
646
+ }
647
+ }
648
+ }
649
+
650
+ async flushPendingTimelineScreenshots(account) {
651
+ const pendingJobs = this.timelineScreenshotQueue.drainForAccount(account.accountId);
652
+ for (const job of pendingJobs) {
653
+ try {
654
+ await this.sendTimelineScreenshot({
655
+ senderId: job.senderId,
656
+ args: job.args,
657
+ outputFile: job.outputFile,
658
+ });
659
+ } catch (error) {
660
+ const messageText = error instanceof Error ? error.message : String(error || "unknown error");
661
+ console.error(`[codeksei] timeline screenshot failed job=${job.id} ${messageText}`);
662
+ await this.channelAdapter.sendTyping({
663
+ userId: job.senderId,
664
+ status: 0,
665
+ }).catch(() => {});
666
+ await this.channelAdapter.sendText({
667
+ userId: job.senderId,
668
+ text: `时间轴截图失败:${messageText}`,
669
+ preserveBlock: true,
670
+ }).catch(() => {});
671
+ }
672
+ }
673
+ }
674
+
675
+ resolveLongPollTimeoutMs() {
676
+ if (this.systemMessageDispatcher?.hasPending()) {
677
+ return MIN_LONG_POLL_TIMEOUT_MS;
678
+ }
679
+ if (this.activeAccountId && this.timelineScreenshotQueue.hasPendingForAccount(this.activeAccountId)) {
680
+ return MIN_LONG_POLL_TIMEOUT_MS;
681
+ }
682
+
683
+ const nextDueAtMs = this.reminderQueue.peekNextDueAtMs();
684
+ if (!nextDueAtMs) {
685
+ return DEFAULT_LONG_POLL_TIMEOUT_MS;
686
+ }
687
+
688
+ const remainingMs = nextDueAtMs - Date.now();
689
+ if (remainingMs <= MIN_LONG_POLL_TIMEOUT_MS) {
690
+ return MIN_LONG_POLL_TIMEOUT_MS;
691
+ }
692
+ return Math.max(MIN_LONG_POLL_TIMEOUT_MS, Math.min(DEFAULT_LONG_POLL_TIMEOUT_MS, remainingMs));
693
+ }
694
+
695
+ async flushDueReminders(account) {
696
+ const dueReminders = this.reminderQueue
697
+ .listDue(Date.now())
698
+ .filter((reminder) => reminder.accountId === account.accountId);
699
+
700
+ for (const reminder of dueReminders) {
701
+ try {
702
+ this.systemMessageQueue.enqueue({
703
+ id: `reminder:${reminder.id}`,
704
+ accountId: reminder.accountId,
705
+ senderId: reminder.senderId,
706
+ workspaceRoot: this.resolveReminderWorkspaceRoot(reminder),
707
+ text: buildReminderSystemTrigger(reminder, this.config),
708
+ createdAt: new Date().toISOString(),
709
+ });
710
+ } catch {
711
+ this.reminderQueue.enqueue({
712
+ ...reminder,
713
+ dueAtMs: Date.now() + 5_000,
714
+ });
715
+ }
716
+ }
717
+ }
718
+
719
+ resolveReminderWorkspaceRoot(reminder) {
720
+ const bindingKey = this.runtimeAdapter.getSessionStore().buildBindingKey({
721
+ workspaceId: this.config.workspaceId,
722
+ accountId: reminder.accountId,
723
+ senderId: reminder.senderId,
724
+ });
725
+ return this.runtimeAdapter.getSessionStore().getActiveWorkspaceRoot(bindingKey) || this.config.workspaceRoot;
726
+ }
727
+
728
+ async dispatchSystemMessage(message) {
729
+ const prepared = this.systemMessageDispatcher?.buildPreparedMessage(message, this.channelAdapter.getKnownContextTokens()[message.senderId] || "");
730
+ if (!prepared) {
731
+ throw new Error("system message could not be prepared");
732
+ }
733
+ const bindingKey = this.runtimeAdapter.getSessionStore().buildBindingKey({
734
+ workspaceId: prepared.workspaceId,
735
+ accountId: prepared.accountId,
736
+ senderId: prepared.senderId,
737
+ });
738
+ const workspaceRoot = prepared.workspaceRoot || this.resolveWorkspaceRoot(bindingKey);
739
+ const threadId = this.runtimeAdapter.getSessionStore().getThreadIdForWorkspace(bindingKey, workspaceRoot);
740
+ const threadState = threadId ? this.threadStateStore.getThreadState(threadId) : null;
741
+ if (threadState?.status === "running" || hasRpcId(threadState?.pendingApproval?.requestId)) {
742
+ return false;
743
+ }
744
+ await this.handlePreparedMessage(prepared, { allowCommands: false });
745
+ return true;
746
+ }
747
+
748
+ async handleReplyDeliveryFailure({
749
+ threadId,
750
+ turnId = "",
751
+ error,
752
+ sentText = "",
753
+ }) {
754
+ const normalizedThreadId = normalizeCommandArgument(threadId);
755
+ const normalizedTurnId = normalizeCommandArgument(turnId);
756
+ if (!normalizedThreadId) {
757
+ return;
758
+ }
759
+
760
+ const messageText = error instanceof Error ? error.message : String(error || "unknown error");
761
+ const deliveryFailureText = isPersistentWeixinSendFailure(error)
762
+ ? "微信发送层连续失败(sendMessage ret=-2),本地已停止继续投递这轮回复。"
763
+ : `回复投递失败:${messageText}`;
764
+ const linked = this.runtimeAdapter.getSessionStore().findBindingForThreadId(normalizedThreadId);
765
+ const workspaceRoot = normalizeText(linked?.workspaceRoot);
766
+
767
+ console.error(
768
+ `[codeksei] reply delivery degraded `
769
+ + `thread=${normalizedThreadId} turn=${normalizedTurnId || "(pending)"} `
770
+ + `workspace=${workspaceRoot || "(unknown)"} `
771
+ + `sentChars=${String(sentText || "").length} `
772
+ + `reason=${messageText}`
773
+ );
774
+
775
+ this.clearRuntimeEventWatchdog(normalizedThreadId);
776
+ if (normalizedTurnId) {
777
+ this.clearTurnSettlementWatchdog(normalizedThreadId, normalizedTurnId);
778
+ }
779
+ // Delivery failure is a local terminal state even if Codex later finishes
780
+ // the turn, otherwise the bridge UI keeps showing a ghost "still replying".
781
+ this.runtimeAdapter.getSessionStore().clearApprovalPrompt(normalizedThreadId);
782
+ this.threadStateStore.markTurnFailed(normalizedThreadId, normalizedTurnId, deliveryFailureText);
783
+ await this.stopTypingForThread(normalizedThreadId);
784
+ }
785
+
786
+ async dispatchChannelCommand(normalized, command) {
787
+ switch (command.name) {
788
+ case "bind":
789
+ await this.handleBindCommand(normalized, command);
790
+ return;
791
+ case "status":
792
+ await this.handleStatusCommand(normalized);
793
+ return;
794
+ case "new":
795
+ await this.handleNewCommand(normalized);
796
+ return;
797
+ case "reread":
798
+ await this.handleRereadCommand(normalized);
799
+ return;
800
+ case "switch":
801
+ await this.handleSwitchCommand(normalized, command);
802
+ return;
803
+ case "stop":
804
+ await this.handleStopCommand(normalized);
805
+ return;
806
+ case "yes":
807
+ case "always":
808
+ case "no":
809
+ await this.handleApprovalCommand(normalized, command);
810
+ return;
811
+ case "model":
812
+ await this.handleModelCommand(normalized, command);
813
+ return;
814
+ case "help":
815
+ await this.handleHelpCommand(normalized);
816
+ return;
817
+ default:
818
+ await this.channelAdapter.sendText({
819
+ userId: normalized.senderId,
820
+ text: buildWeixinHelpText(),
821
+ contextToken: normalized.contextToken,
822
+ });
823
+ }
824
+ }
825
+
826
+ async handleBindCommand(normalized, command) {
827
+ const workspaceRoot = resolveBindWorkspaceRoot(command.args, this.config.workspaceRoot);
828
+ if (!workspaceRoot) {
829
+ await this.channelAdapter.sendText({
830
+ userId: normalized.senderId,
831
+ text: "用法:/bind [绝对路径]",
832
+ contextToken: normalized.contextToken,
833
+ });
834
+ return;
835
+ }
836
+
837
+ if (!isAbsoluteWorkspacePath(workspaceRoot)) {
838
+ await this.channelAdapter.sendText({
839
+ userId: normalized.senderId,
840
+ text: "只支持绝对路径绑定。",
841
+ contextToken: normalized.contextToken,
842
+ });
843
+ return;
844
+ }
845
+
846
+ const stats = await fs.promises.stat(workspaceRoot).catch(() => null);
847
+ if (!stats?.isDirectory()) {
848
+ await this.channelAdapter.sendText({
849
+ userId: normalized.senderId,
850
+ text: `项目不存在:${workspaceRoot}`,
851
+ contextToken: normalized.contextToken,
852
+ });
853
+ return;
854
+ }
855
+
856
+ // Bind the canonical real path so junction aliases do not fork thread/model
857
+ // state across multiple workspace keys on Windows.
858
+ const canonicalWorkspaceRoot = normalizeWorkspacePath(
859
+ await fs.promises.realpath(workspaceRoot).catch(() => workspaceRoot)
860
+ ) || workspaceRoot;
861
+
862
+ const bindingKey = this.runtimeAdapter.getSessionStore().buildBindingKey({
863
+ workspaceId: normalized.workspaceId,
864
+ accountId: normalized.accountId,
865
+ senderId: normalized.senderId,
866
+ });
867
+ this.runtimeAdapter.getSessionStore().setActiveWorkspaceRoot(bindingKey, canonicalWorkspaceRoot);
868
+ await this.channelAdapter.sendText({
869
+ userId: normalized.senderId,
870
+ text: `已绑定项目。\n\nworkspace: ${canonicalWorkspaceRoot}\n下一条普通消息会按当前 workspace 检查是否需要补读稳定入口。`,
871
+ contextToken: normalized.contextToken,
872
+ });
873
+ }
874
+
875
+ async handleStatusCommand(normalized) {
876
+ const bindingKey = this.runtimeAdapter.getSessionStore().buildBindingKey({
877
+ workspaceId: normalized.workspaceId,
878
+ accountId: normalized.accountId,
879
+ senderId: normalized.senderId,
880
+ });
881
+ const workspaceRoot = this.resolveWorkspaceRoot(bindingKey);
882
+ const threadId = this.runtimeAdapter.getSessionStore().getThreadIdForWorkspace(bindingKey, workspaceRoot);
883
+ const threadState = threadId ? this.threadStateStore.getThreadState(threadId) : null;
884
+ const usage = this.threadStateStore.getLatestUsage();
885
+ const lines = [
886
+ `workspace: ${workspaceRoot}`,
887
+ `thread: ${threadId || "(none)"}`,
888
+ `status: ${threadState?.status || "idle"}`,
889
+ `model: ${this.runtimeAdapter.getSessionStore().getCodexParamsForWorkspace(bindingKey, workspaceRoot).model || "(default)"}`,
890
+ ];
891
+ if (threadState?.lastError) {
892
+ lines.push(`lastError: ${threadState.lastError}`);
893
+ }
894
+ if (usage) {
895
+ const usageParts = [];
896
+ if (usage.modelContextWindow > 0 && usage.lastTotalTokens > 0) {
897
+ usageParts.push(`last ${formatCompactNumber(usage.lastTotalTokens)}/${formatCompactNumber(usage.modelContextWindow)}`);
898
+ } else if (usage.lastTotalTokens > 0) {
899
+ usageParts.push(`last ${formatCompactNumber(usage.lastTotalTokens)}`);
900
+ }
901
+ if (usage.primaryUsedPercent > 0) {
902
+ usageParts.push(`5h ${usage.primaryUsedPercent}%`);
903
+ }
904
+ if (usage.secondaryUsedPercent > 0) {
905
+ usageParts.push(`7d ${usage.secondaryUsedPercent}%`);
906
+ }
907
+ if (usageParts.length) {
908
+ lines.push(`usage: ${usageParts.join(" | ")}`);
909
+ }
910
+ }
911
+ await this.channelAdapter.sendText({
912
+ userId: normalized.senderId,
913
+ text: lines.join("\n"),
914
+ contextToken: normalized.contextToken,
915
+ });
916
+ }
917
+
918
+ async handleNewCommand(normalized) {
919
+ const bindingKey = this.runtimeAdapter.getSessionStore().buildBindingKey({
920
+ workspaceId: normalized.workspaceId,
921
+ accountId: normalized.accountId,
922
+ senderId: normalized.senderId,
923
+ });
924
+ const workspaceRoot = this.resolveWorkspaceRoot(bindingKey);
925
+ this.runtimeAdapter.getSessionStore().clearThreadIdForWorkspace(bindingKey, workspaceRoot);
926
+ await this.channelAdapter.sendText({
927
+ userId: normalized.senderId,
928
+ text: `已切到新线程草稿。\n\nworkspace: ${workspaceRoot}\n下一条普通消息会先按当前 workspace 重建上下文入口。`,
929
+ contextToken: normalized.contextToken,
930
+ });
931
+ }
932
+
933
+ async handleRereadCommand(normalized) {
934
+ const bindingKey = this.runtimeAdapter.getSessionStore().buildBindingKey({
935
+ workspaceId: normalized.workspaceId,
936
+ accountId: normalized.accountId,
937
+ senderId: normalized.senderId,
938
+ });
939
+ const workspaceRoot = this.resolveWorkspaceRoot(bindingKey);
940
+ const sessionStore = this.runtimeAdapter.getSessionStore();
941
+ const threadId = sessionStore.getThreadIdForWorkspace(bindingKey, workspaceRoot);
942
+ if (!threadId) {
943
+ await this.channelAdapter.sendText({
944
+ userId: normalized.senderId,
945
+ text: "当前还没有可用线程,先发一条普通消息开始。",
946
+ contextToken: normalized.contextToken,
947
+ });
948
+ return;
949
+ }
950
+
951
+ try {
952
+ this.streamDelivery.queueReplyTargetForThread(threadId, {
953
+ userId: normalized.senderId,
954
+ contextToken: normalized.contextToken,
955
+ provider: normalized.provider,
956
+ });
957
+ this.scheduleRuntimeEventWatchdog({
958
+ bindingKey,
959
+ workspaceRoot,
960
+ normalized,
961
+ threadId,
962
+ });
963
+ await this.runtimeAdapter.refreshThreadInstructions({
964
+ bindingKey,
965
+ threadId,
966
+ workspaceRoot,
967
+ model: sessionStore.getCodexParamsForWorkspace(bindingKey, workspaceRoot).model,
968
+ accessMode: this.config.codexAccessMode,
969
+ });
970
+ } catch (error) {
971
+ await this.channelAdapter.sendText({
972
+ userId: normalized.senderId,
973
+ text: `重读失败:${error instanceof Error ? error.message : String(error || "unknown error")}`,
974
+ contextToken: normalized.contextToken,
975
+ }).catch(() => {});
976
+ }
977
+ }
978
+
979
+ async handleSwitchCommand(normalized, command) {
980
+ const targetThreadId = normalizeCommandArgument(command.args);
981
+ if (!targetThreadId) {
982
+ await this.channelAdapter.sendText({
983
+ userId: normalized.senderId,
984
+ text: "用法:/switch <threadId>",
985
+ contextToken: normalized.contextToken,
986
+ });
987
+ return;
988
+ }
989
+
990
+ const bindingKey = this.runtimeAdapter.getSessionStore().buildBindingKey({
991
+ workspaceId: normalized.workspaceId,
992
+ accountId: normalized.accountId,
993
+ senderId: normalized.senderId,
994
+ });
995
+ const sessionStore = this.runtimeAdapter.getSessionStore();
996
+ const currentWorkspaceRoot = this.resolveWorkspaceRoot(bindingKey);
997
+ // A thread carries its own workspace continuity contract. If we switch back
998
+ // to a known old thread but keep today's active workspace, the next message
999
+ // would inject the wrong workspace bootstrap and silently redirect context.
1000
+ const knownTarget = sessionStore.findBindingForThreadId(targetThreadId);
1001
+ const workspaceRoot = knownTarget?.workspaceRoot || currentWorkspaceRoot;
1002
+ await this.runtimeAdapter.resumeThread({ threadId: targetThreadId });
1003
+ sessionStore.setThreadIdForWorkspace(bindingKey, workspaceRoot, targetThreadId);
1004
+ const switchedWorkspaceNotice = workspaceRoot !== currentWorkspaceRoot
1005
+ ? "\n已跟随这条 thread 的已知 workspace。"
1006
+ : "";
1007
+ await this.channelAdapter.sendText({
1008
+ userId: normalized.senderId,
1009
+ text: `已切换线程。\n\nworkspace: ${workspaceRoot}\nthread: ${targetThreadId}${switchedWorkspaceNotice}\n下一条普通消息会按当前 workspace 检查是否需要补读稳定入口。`,
1010
+ contextToken: normalized.contextToken,
1011
+ });
1012
+ }
1013
+
1014
+ async handleStopCommand(normalized) {
1015
+ const bindingKey = this.runtimeAdapter.getSessionStore().buildBindingKey({
1016
+ workspaceId: normalized.workspaceId,
1017
+ accountId: normalized.accountId,
1018
+ senderId: normalized.senderId,
1019
+ });
1020
+ const workspaceRoot = this.resolveWorkspaceRoot(bindingKey);
1021
+ const threadId = this.runtimeAdapter.getSessionStore().getThreadIdForWorkspace(bindingKey, workspaceRoot);
1022
+ const threadState = threadId ? this.threadStateStore.getThreadState(threadId) : null;
1023
+ if (!threadId || !threadState?.turnId || threadState.status !== "running") {
1024
+ await this.channelAdapter.sendText({
1025
+ userId: normalized.senderId,
1026
+ text: "当前没有正在运行的线程。",
1027
+ contextToken: normalized.contextToken,
1028
+ });
1029
+ return;
1030
+ }
1031
+
1032
+ await this.runtimeAdapter.cancelTurn({
1033
+ threadId,
1034
+ turnId: threadState.turnId,
1035
+ });
1036
+ await this.channelAdapter.sendText({
1037
+ userId: normalized.senderId,
1038
+ text: `已发送停止请求。\n\nthread: ${threadId}`,
1039
+ contextToken: normalized.contextToken,
1040
+ });
1041
+ }
1042
+
1043
+ async handleApprovalCommand(normalized, command) {
1044
+ const bindingKey = this.runtimeAdapter.getSessionStore().buildBindingKey({
1045
+ workspaceId: normalized.workspaceId,
1046
+ accountId: normalized.accountId,
1047
+ senderId: normalized.senderId,
1048
+ });
1049
+ const workspaceRoot = this.resolveWorkspaceRoot(bindingKey);
1050
+ const threadId = this.runtimeAdapter.getSessionStore().getThreadIdForWorkspace(bindingKey, workspaceRoot);
1051
+ const threadState = threadId ? this.threadStateStore.getThreadState(threadId) : null;
1052
+ const approval = threadState?.pendingApproval || null;
1053
+ if (!threadId || approval?.requestId == null || String(approval.requestId).trim() === "") {
1054
+ await this.channelAdapter.sendText({
1055
+ userId: normalized.senderId,
1056
+ text: "当前没有待处理的授权请求。",
1057
+ contextToken: normalized.contextToken,
1058
+ });
1059
+ return;
1060
+ }
1061
+
1062
+ const decision = command.name === "no" ? "decline" : "accept";
1063
+ console.log(
1064
+ `[codeksei] approval response requested thread=${threadId} requestId=${approval.requestId} decision=${decision} workspace=${workspaceRoot}`
1065
+ );
1066
+ await this.runtimeAdapter.respondApproval({
1067
+ requestId: approval.requestId,
1068
+ decision,
1069
+ });
1070
+ this.runtimeAdapter.getSessionStore().clearApprovalPrompt(threadId);
1071
+ console.log(
1072
+ `[codeksei] approval response delivered thread=${threadId} requestId=${approval.requestId} decision=${decision}`
1073
+ );
1074
+ if (command.name === "always" && decision === "accept") {
1075
+ this.runtimeAdapter.getSessionStore().rememberApprovalPrefixForWorkspace(workspaceRoot, approval.commandTokens);
1076
+ }
1077
+ this.threadStateStore.resolveApproval(threadId, "running");
1078
+ const text = command.name === "always"
1079
+ ? "已记住该命令前缀,当前项目后续相同命令将自动放行。"
1080
+ : (command.name === "yes" ? "已允许本次请求。" : "已拒绝本次请求。");
1081
+ await this.channelAdapter.sendText({
1082
+ userId: normalized.senderId,
1083
+ text,
1084
+ contextToken: normalized.contextToken,
1085
+ });
1086
+ }
1087
+
1088
+ async handleModelCommand(normalized, command) {
1089
+ const bindingKey = this.runtimeAdapter.getSessionStore().buildBindingKey({
1090
+ workspaceId: normalized.workspaceId,
1091
+ accountId: normalized.accountId,
1092
+ senderId: normalized.senderId,
1093
+ });
1094
+ const workspaceRoot = this.resolveWorkspaceRoot(bindingKey);
1095
+ const query = normalizeCommandArgument(command.args);
1096
+ const sessionStore = this.runtimeAdapter.getSessionStore();
1097
+ const catalog = sessionStore.getAvailableModelCatalog();
1098
+ const currentModel = sessionStore.getCodexParamsForWorkspace(bindingKey, workspaceRoot).model;
1099
+
1100
+ if (!query) {
1101
+ const lines = [
1102
+ `当前模型: ${currentModel || "(default)"}`,
1103
+ ];
1104
+ if (catalog?.models?.length) {
1105
+ lines.push(`可用模型: ${catalog.models.map((item) => item.model).join("、")}`);
1106
+ } else {
1107
+ lines.push("可用模型: (未获取到模型列表)");
1108
+ }
1109
+ await this.channelAdapter.sendText({
1110
+ userId: normalized.senderId,
1111
+ text: lines.join("\n"),
1112
+ contextToken: normalized.contextToken,
1113
+ });
1114
+ return;
1115
+ }
1116
+
1117
+ const matched = findModelByQuery(catalog?.models || [], query);
1118
+ if (!matched) {
1119
+ await this.channelAdapter.sendText({
1120
+ userId: normalized.senderId,
1121
+ text: `未找到模型:${query}`,
1122
+ contextToken: normalized.contextToken,
1123
+ });
1124
+ return;
1125
+ }
1126
+
1127
+ sessionStore.setCodexParamsForWorkspace(bindingKey, workspaceRoot, {
1128
+ model: matched.model,
1129
+ });
1130
+ await this.channelAdapter.sendText({
1131
+ userId: normalized.senderId,
1132
+ text: `已切换模型。\n\nworkspace: ${workspaceRoot}\nmodel: ${matched.model}`,
1133
+ contextToken: normalized.contextToken,
1134
+ });
1135
+ }
1136
+
1137
+ async handleHelpCommand(normalized) {
1138
+ await this.channelAdapter.sendText({
1139
+ userId: normalized.senderId,
1140
+ text: buildWeixinHelpText(),
1141
+ contextToken: normalized.contextToken,
1142
+ });
1143
+ }
1144
+
1145
+ resolveWorkspaceRoot(bindingKey) {
1146
+ const sessionStore = this.runtimeAdapter.getSessionStore();
1147
+ return sessionStore.getActiveWorkspaceRoot(bindingKey) || this.config.workspaceRoot;
1148
+ }
1149
+
1150
+ async handleRuntimeEvent(event) {
1151
+ await this.streamDelivery.handleRuntimeEvent(event);
1152
+ if (!event) {
1153
+ return;
1154
+ }
1155
+ if (event.type === "runtime.turn.completed" || event.type === "runtime.turn.failed") {
1156
+ this.runtimeAdapter.getSessionStore().clearApprovalPrompt(event.payload.threadId);
1157
+ await this.stopTypingForThread(event.payload.threadId);
1158
+ if (event.type === "runtime.turn.failed") {
1159
+ await this.sendFailureToThread(event.payload.threadId, event.payload.text || "执行失败");
1160
+ }
1161
+ return;
1162
+ }
1163
+ if (event.type !== "runtime.approval.requested") {
1164
+ return;
1165
+ }
1166
+ const sessionStore = this.runtimeAdapter.getSessionStore();
1167
+ const linked = sessionStore.findBindingForThreadId(event.payload.threadId);
1168
+ if (!linked?.workspaceRoot) {
1169
+ return;
1170
+ }
1171
+ const allowlist = sessionStore.getApprovalCommandAllowlistForWorkspace(linked.workspaceRoot);
1172
+ const shouldAutoApprove = matchesBuiltInCommandPrefix(event.payload.commandTokens)
1173
+ || matchesCommandPrefix(event.payload.commandTokens, allowlist);
1174
+ if (!shouldAutoApprove) {
1175
+ const promptState = sessionStore.getApprovalPromptState(event.payload.threadId);
1176
+ const promptSignature = buildApprovalPromptSignature(event.payload);
1177
+ if (promptState?.signature && promptState.signature === promptSignature) {
1178
+ sessionStore.rememberApprovalPrompt(event.payload.threadId, event.payload.requestId, promptSignature);
1179
+ console.log(
1180
+ `[codeksei] approval prompt deduped thread=${event.payload.threadId} requestId=${event.payload.requestId}`
1181
+ );
1182
+ return;
1183
+ }
1184
+ sessionStore.rememberApprovalPrompt(event.payload.threadId, event.payload.requestId, promptSignature);
1185
+ await this.sendApprovalPrompt({
1186
+ bindingKey: linked.bindingKey,
1187
+ approval: event.payload,
1188
+ }).catch((error) => {
1189
+ sessionStore.clearApprovalPrompt(event.payload.threadId);
1190
+ throw error;
1191
+ });
1192
+ return;
1193
+ }
1194
+ await this.runtimeAdapter.respondApproval({
1195
+ requestId: event.payload.requestId,
1196
+ decision: "accept",
1197
+ }).catch(() => {});
1198
+ this.threadStateStore.resolveApproval(event.payload.threadId, "running");
1199
+ }
1200
+
1201
+ async stopTypingForThread(threadId) {
1202
+ const linked = this.runtimeAdapter.getSessionStore().findBindingForThreadId(threadId);
1203
+ const target = linked?.bindingKey ? this.resolveReplyTargetForBinding(linked.bindingKey) : null;
1204
+ if (!target) {
1205
+ return;
1206
+ }
1207
+ await this.channelAdapter.sendTyping({
1208
+ userId: target.userId,
1209
+ status: 0,
1210
+ contextToken: target.contextToken,
1211
+ }).catch(() => {});
1212
+ }
1213
+
1214
+ async sendFailureToThread(threadId, text) {
1215
+ const linked = this.runtimeAdapter.getSessionStore().findBindingForThreadId(threadId);
1216
+ const target = linked?.bindingKey ? this.resolveReplyTargetForBinding(linked.bindingKey) : null;
1217
+ if (!target) {
1218
+ return;
1219
+ }
1220
+ await this.channelAdapter.sendText({
1221
+ userId: target.userId,
1222
+ text: normalizeText(text) || "执行失败",
1223
+ contextToken: target.contextToken,
1224
+ }).catch(() => {});
1225
+ }
1226
+
1227
+ async sendApprovalPrompt({ bindingKey, approval }) {
1228
+ const target = this.resolveReplyTargetForBinding(bindingKey);
1229
+ if (!target) {
1230
+ console.warn(
1231
+ `[codeksei] approval prompt skipped binding=${bindingKey} requestId=${approval?.requestId || ""} reason=no_reply_target`
1232
+ );
1233
+ return;
1234
+ }
1235
+ console.log(
1236
+ `[codeksei] approval prompt sending binding=${bindingKey} user=${target.userId} requestId=${approval?.requestId || ""}`
1237
+ );
1238
+ await this.channelAdapter.sendTyping({
1239
+ userId: target.userId,
1240
+ status: 0,
1241
+ contextToken: target.contextToken,
1242
+ }).catch(() => {});
1243
+ await this.channelAdapter.sendText({
1244
+ userId: target.userId,
1245
+ text: buildApprovalPromptText(approval),
1246
+ contextToken: target.contextToken,
1247
+ preserveBlock: true,
1248
+ });
1249
+ console.log(
1250
+ `[codeksei] approval prompt delivered binding=${bindingKey} user=${target.userId} requestId=${approval?.requestId || ""}`
1251
+ );
1252
+ }
1253
+
1254
+ async restoreBoundThreadSubscriptions() {
1255
+ const sessionStore = this.runtimeAdapter.getSessionStore();
1256
+ const bindings = sessionStore.listBindings();
1257
+ const seenThreadIds = new Set();
1258
+
1259
+ for (const binding of bindings) {
1260
+ const bindingKey = normalizeText(binding?.bindingKey);
1261
+ if (!bindingKey) {
1262
+ continue;
1263
+ }
1264
+
1265
+ const target = this.resolveReplyTargetForBinding(bindingKey);
1266
+ if (target) {
1267
+ this.streamDelivery.setReplyTarget(bindingKey, target);
1268
+ }
1269
+
1270
+ const threadIdByWorkspaceRoot = binding?.threadIdByWorkspaceRoot && typeof binding.threadIdByWorkspaceRoot === "object"
1271
+ ? binding.threadIdByWorkspaceRoot
1272
+ : {};
1273
+ for (const threadId of Object.values(threadIdByWorkspaceRoot)) {
1274
+ const normalizedThreadId = normalizeCommandArgument(threadId);
1275
+ if (!normalizedThreadId || seenThreadIds.has(normalizedThreadId)) {
1276
+ continue;
1277
+ }
1278
+ seenThreadIds.add(normalizedThreadId);
1279
+ await this.runtimeAdapter.resumeThread({ threadId: normalizedThreadId }).catch(() => {});
1280
+ }
1281
+ }
1282
+ }
1283
+
1284
+ resolveReplyTargetForBinding(bindingKey) {
1285
+ const binding = this.runtimeAdapter.getSessionStore().getBinding(bindingKey) || null;
1286
+ const userId = normalizeCommandArgument(binding?.senderId);
1287
+ if (!userId) {
1288
+ return null;
1289
+ }
1290
+ const contextToken = this.channelAdapter.getKnownContextTokens()[userId] || "";
1291
+ if (!contextToken) {
1292
+ return null;
1293
+ }
1294
+ return {
1295
+ userId,
1296
+ contextToken,
1297
+ provider: "weixin",
1298
+ };
1299
+ }
1300
+ }
1301
+
1302
+ function formatCompactNumber(value) {
1303
+ const normalized = Number(value);
1304
+ if (!Number.isFinite(normalized) || normalized <= 0) {
1305
+ return "0";
1306
+ }
1307
+ if (normalized >= 1_000_000) {
1308
+ return `${Math.round(normalized / 100_000) / 10}m`;
1309
+ }
1310
+ if (normalized >= 1_000) {
1311
+ return `${Math.round(normalized / 100) / 10}k`;
1312
+ }
1313
+ return String(Math.round(normalized));
1314
+ }
1315
+
1316
+ function createShutdownController(onStop) {
1317
+ let stopped = false;
1318
+ let stoppingPromise = null;
1319
+
1320
+ const stop = async () => {
1321
+ if (stopped) {
1322
+ return stoppingPromise;
1323
+ }
1324
+ stopped = true;
1325
+ stoppingPromise = Promise.resolve().then(onStop);
1326
+ return stoppingPromise;
1327
+ };
1328
+
1329
+ const handleSignal = () => {
1330
+ stop().finally(() => {
1331
+ process.exit(0);
1332
+ });
1333
+ };
1334
+
1335
+ process.on("SIGINT", handleSignal);
1336
+ process.on("SIGTERM", handleSignal);
1337
+
1338
+ return {
1339
+ get stopped() {
1340
+ return stopped;
1341
+ },
1342
+ dispose() {
1343
+ process.off("SIGINT", handleSignal);
1344
+ process.off("SIGTERM", handleSignal);
1345
+ },
1346
+ };
1347
+ }
1348
+
1349
+ function assertWeixinUpdateResponse(response) {
1350
+ const ret = normalizeErrorCode(response?.ret);
1351
+ const errcode = normalizeErrorCode(response?.errcode);
1352
+ if ((ret !== 0 && ret !== null) || (errcode !== 0 && errcode !== null)) {
1353
+ const error = new Error(
1354
+ `weixin getUpdates ret=${ret ?? ""} errcode=${errcode ?? ""} errmsg=${normalizeText(response?.errmsg) || ""}`
1355
+ );
1356
+ error.ret = ret;
1357
+ error.errcode = errcode;
1358
+ throw error;
1359
+ }
1360
+ }
1361
+
1362
+ function isSessionExpiredError(error) {
1363
+ const ret = normalizeErrorCode(error?.ret);
1364
+ const errcode = normalizeErrorCode(error?.errcode);
1365
+ return ret === SESSION_EXPIRED_ERRCODE
1366
+ || errcode === SESSION_EXPIRED_ERRCODE
1367
+ || String(error?.message || "").includes("session expired")
1368
+ || String(error?.message || "").includes("会话已失效");
1369
+ }
1370
+
1371
+ function normalizeErrorCode(value) {
1372
+ if (value === undefined || value === null || value === "") {
1373
+ return null;
1374
+ }
1375
+ const numeric = Number(value);
1376
+ return Number.isFinite(numeric) ? numeric : null;
1377
+ }
1378
+
1379
+ function formatErrorMessage(error) {
1380
+ const raw = error instanceof Error ? error.message : String(error || "unknown error");
1381
+ if (isSessionExpiredError(error)) {
1382
+ return "微信会话已失效,请重新执行 `npm run login`";
1383
+ }
1384
+ return raw;
1385
+ }
1386
+
1387
+ function sleep(ms) {
1388
+ return new Promise((resolve) => setTimeout(resolve, ms));
1389
+ }
1390
+
1391
+ module.exports = { CyberbossApp };
1392
+
1393
+ function parseChannelCommand(text) {
1394
+ const normalized = typeof text === "string" ? text.trim() : "";
1395
+ if (!normalized.startsWith("/")) {
1396
+ return null;
1397
+ }
1398
+ const [rawName, ...rest] = normalized.slice(1).split(/\s+/);
1399
+ const name = normalizeCommandName(rawName);
1400
+ if (!name) {
1401
+ return null;
1402
+ }
1403
+ return {
1404
+ name,
1405
+ args: rest.join(" ").trim(),
1406
+ };
1407
+ }
1408
+
1409
+ function normalizeCommandName(value) {
1410
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
1411
+ }
1412
+
1413
+ const WINDOWS_DRIVE_PATH_RE = /^[A-Za-z]:\//;
1414
+ const WINDOWS_DRIVE_ROOT_RE = /^[A-Za-z]:\/$/;
1415
+ const WINDOWS_UNC_PREFIX_RE = /^\/\/\?\//;
1416
+
1417
+ function normalizeWorkspacePath(value) {
1418
+ const normalized = String(value || "").trim();
1419
+ if (!normalized) {
1420
+ return "";
1421
+ }
1422
+
1423
+ const fromFileUri = extractPathFromFileUri(normalized);
1424
+ const rawPath = fromFileUri || normalized;
1425
+ // WeChat + Chinese IME can turn Windows paths into mixed-width punctuation.
1426
+ // Normalize them here so `/bind E:\foo`, `/bind E:\foo`, and file URIs
1427
+ // all converge before we decide whether the path is absolute.
1428
+ const canonicalWindowsPath = rawPath
1429
+ .replace(/[:﹕]/g, ":")
1430
+ .replace(/[\]/g, "\\")
1431
+ .replace(/[/]/g, "/");
1432
+ const withForwardSlashes = canonicalWindowsPath.replace(/\\/g, "/").replace(WINDOWS_UNC_PREFIX_RE, "");
1433
+ const normalizedDrivePrefix = /^\/[A-Za-z]:\//.test(withForwardSlashes)
1434
+ ? withForwardSlashes.slice(1)
1435
+ : withForwardSlashes;
1436
+
1437
+ if (WINDOWS_DRIVE_ROOT_RE.test(normalizedDrivePrefix)) {
1438
+ return normalizedDrivePrefix;
1439
+ }
1440
+ if (WINDOWS_DRIVE_PATH_RE.test(normalizedDrivePrefix)) {
1441
+ return normalizedDrivePrefix.replace(/\/+$/g, "");
1442
+ }
1443
+ return normalizedDrivePrefix.replace(/\/+$/g, "");
1444
+ }
1445
+
1446
+ function isAbsoluteWorkspacePath(value) {
1447
+ const normalized = normalizeWorkspacePath(value);
1448
+ if (!normalized) {
1449
+ return false;
1450
+ }
1451
+ if (WINDOWS_DRIVE_PATH_RE.test(normalized)) {
1452
+ return true;
1453
+ }
1454
+ return path.posix.isAbsolute(normalized);
1455
+ }
1456
+
1457
+ function resolveBindWorkspaceRoot(value, defaultWorkspaceRoot) {
1458
+ const normalizedArg = normalizeCommandArgument(value);
1459
+ if (!normalizedArg || isDefaultWorkspaceAlias(normalizedArg)) {
1460
+ return normalizeWorkspacePath(defaultWorkspaceRoot);
1461
+ }
1462
+ return normalizeWorkspacePath(value);
1463
+ }
1464
+
1465
+ function isDefaultWorkspaceAlias(value) {
1466
+ const normalized = normalizeCommandArgument(value);
1467
+ return normalized === "."
1468
+ || normalized === "here"
1469
+ || normalized === "default"
1470
+ || normalized === "当前项目"
1471
+ || normalized === "本项目"
1472
+ || normalized === "这里";
1473
+ }
1474
+
1475
+ function extractPathFromFileUri(value) {
1476
+ const input = String(value || "").trim();
1477
+ if (!/^file:\/\//i.test(input)) {
1478
+ return "";
1479
+ }
1480
+
1481
+ try {
1482
+ const parsed = new URL(input);
1483
+ if (parsed.protocol !== "file:") {
1484
+ return "";
1485
+ }
1486
+ const pathname = decodeURIComponent(parsed.pathname || "");
1487
+ const withHost = parsed.host && parsed.host !== "localhost"
1488
+ ? `//${parsed.host}${pathname}`
1489
+ : pathname;
1490
+ return withHost;
1491
+ } catch {
1492
+ return "";
1493
+ }
1494
+ }
1495
+
1496
+ function normalizeCommandArgument(value) {
1497
+ return typeof value === "string" ? value.trim() : "";
1498
+ }
1499
+
1500
+ function normalizeText(value) {
1501
+ return typeof value === "string" ? value.trim() : "";
1502
+ }
1503
+
1504
+ function matchesCommandPrefix(commandTokens, allowlist) {
1505
+ const normalizedCommandTokens = Array.isArray(commandTokens)
1506
+ ? commandTokens.map((part) => normalizeCommandArgument(part)).filter(Boolean)
1507
+ : [];
1508
+ if (!normalizedCommandTokens.length || !Array.isArray(allowlist) || !allowlist.length) {
1509
+ return false;
1510
+ }
1511
+ return allowlist.some((prefix) => {
1512
+ if (!Array.isArray(prefix) || !prefix.length || prefix.length > normalizedCommandTokens.length) {
1513
+ return false;
1514
+ }
1515
+ return prefix.every((part, index) => normalizeCommandArgument(part) === normalizedCommandTokens[index]);
1516
+ });
1517
+ }
1518
+
1519
+ function matchesBuiltInCommandPrefix(commandTokens) {
1520
+ const normalized = normalizeCommandTokensForMatching(commandTokens);
1521
+ if (!normalized.length) {
1522
+ return false;
1523
+ }
1524
+
1525
+ if (normalized[0] === "npm") {
1526
+ const runIndex = normalized.indexOf("run");
1527
+ if (runIndex >= 0) {
1528
+ const scriptName = normalizeCommandArgument(normalized[runIndex + 1]);
1529
+ return isBuiltInScriptName(scriptName);
1530
+ }
1531
+ }
1532
+
1533
+ const executable = path.basename(normalized[0] || "");
1534
+ if ((executable === "sh" || executable === "bash" || executable === "zsh")
1535
+ && matchesBuiltInShellScript(normalized[1])) {
1536
+ return true;
1537
+ }
1538
+ if (executable === "node" || executable === "node.exe") {
1539
+ const binPath = normalizeCommandArgument(normalized[1]);
1540
+ if (binPath === "./bin/cyberboss.js"
1541
+ || binPath.endsWith("/bin/cyberboss.js")
1542
+ || binPath === "./bin/codeksei.js"
1543
+ || binPath.endsWith("/bin/codeksei.js")) {
1544
+ return matchesBuiltInCliCommand(normalized.slice(2));
1545
+ }
1546
+ }
1547
+
1548
+ if (executable === "cyberboss"
1549
+ || executable === "cyberboss.js"
1550
+ || executable === "codeksei"
1551
+ || executable === "codeksei.js") {
1552
+ return matchesBuiltInCliCommand(normalized.slice(1));
1553
+ }
1554
+
1555
+ return false;
1556
+ }
1557
+
1558
+ function normalizeCommandTokensForMatching(commandTokens) {
1559
+ const normalized = Array.isArray(commandTokens)
1560
+ ? commandTokens.map((part) => normalizeCommandArgument(part)).filter(Boolean)
1561
+ : [];
1562
+ if (normalized.length >= 3 && isShellWrapper(normalized[0], normalized[1])) {
1563
+ return splitCommandLine(normalized.slice(2).join(" "));
1564
+ }
1565
+ return normalized;
1566
+ }
1567
+
1568
+ function isShellWrapper(command, flag) {
1569
+ const executable = path.basename(normalizeCommandArgument(command));
1570
+ return (executable === "sh" || executable === "bash" || executable === "zsh") && flag === "-lc";
1571
+ }
1572
+
1573
+ function isBuiltInScriptName(scriptName) {
1574
+ return scriptName === "reminder:write"
1575
+ || scriptName === "diary:write"
1576
+ || scriptName === "note:auto"
1577
+ || scriptName === "note:maybe"
1578
+ || scriptName === "note:sync"
1579
+ || scriptName === "project:radar"
1580
+ || scriptName === "review:nightly"
1581
+ || scriptName === "review:weekly"
1582
+ || scriptName === "review:monthly"
1583
+ || scriptName.startsWith("timeline:");
1584
+ }
1585
+
1586
+ function matchesBuiltInShellScript(scriptPath) {
1587
+ const basename = path.basename(normalizeCommandArgument(scriptPath));
1588
+ return basename === "timeline-screenshot.sh";
1589
+ }
1590
+
1591
+ function matchesBuiltInCliCommand(tokens) {
1592
+ if (!Array.isArray(tokens) || tokens.length < 2) {
1593
+ return false;
1594
+ }
1595
+ const topic = normalizeCommandArgument(tokens[0]);
1596
+ const action = normalizeCommandArgument(tokens[1]);
1597
+ if (topic === "timeline") {
1598
+ return action === "write"
1599
+ || action === "build"
1600
+ || action === "serve"
1601
+ || action === "dev"
1602
+ || action === "screenshot"
1603
+ || action === "read"
1604
+ || action === "categories"
1605
+ || action === "proposals";
1606
+ }
1607
+ if (topic === "project") {
1608
+ return action === "radar";
1609
+ }
1610
+ if (topic === "note") {
1611
+ return action === "sync";
1612
+ }
1613
+ return (topic === "reminder" && action === "write")
1614
+ || (topic === "diary" && action === "write")
1615
+ || false;
1616
+ }
1617
+
1618
+ function splitCommandLine(input) {
1619
+ const tokens = [];
1620
+ let current = "";
1621
+ let quote = null;
1622
+ let escaped = false;
1623
+
1624
+ for (const char of String(input || "")) {
1625
+ if (escaped) {
1626
+ current += char;
1627
+ escaped = false;
1628
+ continue;
1629
+ }
1630
+ if (char === "\\") {
1631
+ escaped = true;
1632
+ continue;
1633
+ }
1634
+ if (quote) {
1635
+ if (char === quote) {
1636
+ quote = null;
1637
+ } else {
1638
+ current += char;
1639
+ }
1640
+ continue;
1641
+ }
1642
+ if (char === "\"" || char === "'") {
1643
+ quote = char;
1644
+ continue;
1645
+ }
1646
+ if (/\s/.test(char)) {
1647
+ if (current) {
1648
+ tokens.push(current);
1649
+ current = "";
1650
+ }
1651
+ continue;
1652
+ }
1653
+ current += char;
1654
+ }
1655
+
1656
+ if (current) {
1657
+ tokens.push(current);
1658
+ }
1659
+ return tokens;
1660
+ }
1661
+
1662
+ function buildApprovalPromptText(approval) {
1663
+ const reasonText = normalizeText(approval?.reason);
1664
+ const commandText = normalizeText(approval?.command);
1665
+ const sections = ["Codex 请求授权"];
1666
+
1667
+ if (reasonText && reasonText !== commandText) {
1668
+ sections.push(`操作说明:\n${reasonText}`);
1669
+ }
1670
+
1671
+ if (commandText) {
1672
+ sections.push(`待执行命令:\n${commandText}`);
1673
+ } else if (!reasonText) {
1674
+ sections.push("(unknown)");
1675
+ }
1676
+
1677
+ sections.push([
1678
+ "回复以下命令继续:",
1679
+ "/yes 本次允许",
1680
+ "/always 本项目后续同前缀自动允许",
1681
+ "/no 拒绝本次请求",
1682
+ ].join("\n"));
1683
+
1684
+ return sections.join("\n\n");
1685
+ }
1686
+
1687
+ function buildApprovalPromptSignature(approval) {
1688
+ const reasonText = normalizeText(approval?.reason);
1689
+ const commandText = normalizeText(approval?.command);
1690
+ const commandTokens = Array.isArray(approval?.commandTokens)
1691
+ ? approval.commandTokens.map((token) => normalizeCommandArgument(token)).filter(Boolean)
1692
+ : [];
1693
+ return JSON.stringify({
1694
+ reason: reasonText,
1695
+ command: commandText,
1696
+ commandTokens,
1697
+ });
1698
+ }
1699
+
1700
+ function buildReminderSystemTrigger(reminder, config = {}) {
1701
+ const reminderText = String(reminder?.text || "").trim();
1702
+ const userName = String(config?.userName || "").trim() || "用户";
1703
+ return [
1704
+ "A scheduled reminder is due.",
1705
+ `Send ${userName} one short and natural WeChat message.`,
1706
+ "Do not mention internal triggers.",
1707
+ "Do not mechanically repeat the reminder text.",
1708
+ `Reminder: ${reminderText}`,
1709
+ ].join("\n");
1710
+ }
1711
+
1712
+ function buildCodexInboundText(normalized, persisted = {}, config = {}) {
1713
+ const text = String(normalized?.text || "").trim();
1714
+ const saved = Array.isArray(persisted?.saved) ? persisted.saved : [];
1715
+ const failed = Array.isArray(persisted?.failed) ? persisted.failed : [];
1716
+ const userName = String(config?.userName || "").trim() || "用户";
1717
+ const localTime = formatWechatLocalTime(normalized?.receivedAt);
1718
+ const lines = [];
1719
+ if (localTime) {
1720
+ lines.push(`[${localTime}]`);
1721
+ }
1722
+ if (text) {
1723
+ if (lines.length) {
1724
+ lines.push("");
1725
+ }
1726
+ lines.push(text);
1727
+ }
1728
+
1729
+ if (saved.length) {
1730
+ if (lines.length) {
1731
+ lines.push("");
1732
+ }
1733
+ lines.push(`${userName} sent image/file attachments. They were saved under the local data directory:`);
1734
+ for (const item of saved) {
1735
+ const suffix = item.sourceFileName ? ` (original name: ${item.sourceFileName})` : "";
1736
+ lines.push(`- [${item.kind}] ${item.absolutePath}${suffix}`);
1737
+ }
1738
+ lines.push(`You must read these files before replying to ${userName}. Do not skip the read step.`);
1739
+ lines.push(`If the required local tool is missing, tell ${userName} exactly what is missing and that you cannot read the file yet. Do not pretend you already read it.`);
1740
+ }
1741
+
1742
+ if (failed.length) {
1743
+ if (lines.length) {
1744
+ lines.push("");
1745
+ }
1746
+ lines.push("Attachment intake errors:");
1747
+ for (const item of failed) {
1748
+ const label = item.sourceFileName || item.kind || "attachment";
1749
+ lines.push(`- ${label}: ${item.reason}`);
1750
+ }
1751
+ }
1752
+
1753
+ return lines.join("\n").trim();
1754
+ }
1755
+
1756
+ function formatWechatLocalTime(receivedAt) {
1757
+ const value = typeof receivedAt === "string" ? receivedAt.trim() : "";
1758
+ if (!value) {
1759
+ return "";
1760
+ }
1761
+ const parsed = new Date(value);
1762
+ if (Number.isNaN(parsed.getTime())) {
1763
+ return value;
1764
+ }
1765
+ return new Intl.DateTimeFormat("zh-CN", {
1766
+ timeZone: "Asia/Shanghai",
1767
+ year: "numeric",
1768
+ month: "2-digit",
1769
+ day: "2-digit",
1770
+ hour: "2-digit",
1771
+ minute: "2-digit",
1772
+ hour12: false,
1773
+ }).format(parsed).replace(/\//g, "-");
1774
+ }
1775
+
1776
+ function stringifyRpcId(value) {
1777
+ if (value == null) {
1778
+ return "";
1779
+ }
1780
+ return String(value).trim();
1781
+ }
1782
+
1783
+ function hasRpcId(value) {
1784
+ return stringifyRpcId(value) !== "";
1785
+ }
1786
+
1787
+ function isPersistentWeixinSendFailure(error) {
1788
+ const message = String(error?.message || error || "");
1789
+ return message.includes("sendMessage ret=-2");
1790
+ }
1791
+
1792
+ function buildTurnSettlementWatchdogKey(threadId, turnId) {
1793
+ const normalizedThreadId = normalizeCommandArgument(threadId);
1794
+ const normalizedTurnId = normalizeCommandArgument(turnId);
1795
+ if (!normalizedThreadId || !normalizedTurnId) {
1796
+ return "";
1797
+ }
1798
+ return `${normalizedThreadId}:${normalizedTurnId}`;
1799
+ }
1800
+
1801
+ function resolveTimelineScreenshotOutput(args) {
1802
+ const normalizedArgs = Array.isArray(args) ? args : [];
1803
+ for (let index = 0; index < normalizedArgs.length; index += 1) {
1804
+ if (String(normalizedArgs[index] || "").trim() !== "--output") {
1805
+ continue;
1806
+ }
1807
+ return String(normalizedArgs[index + 1] || "").trim();
1808
+ }
1809
+ return "";
1810
+ }