@keyou007/agent-bot 0.1.24-alpha.5 → 0.1.24-alpha.7

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 (105) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +29 -3
  3. package/README.zh.md +29 -3
  4. package/SECURITY.md +12 -0
  5. package/config.example.yaml +5 -0
  6. package/dist/cli/controlProtocol.js.map +1 -1
  7. package/dist/cli/serverStatus.js +1 -0
  8. package/dist/cli/serverStatus.js.map +1 -1
  9. package/dist/cli.js +6 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/codex/CodexEventMapper.js +1 -0
  12. package/dist/codex/CodexEventMapper.js.map +1 -1
  13. package/dist/codex/CodexRuntime.js +247 -120
  14. package/dist/codex/CodexRuntime.js.map +1 -1
  15. package/dist/codex/ProviderSwitch.js +12 -13
  16. package/dist/codex/ProviderSwitch.js.map +1 -1
  17. package/dist/codex/TraePlanEntry.js +70 -0
  18. package/dist/codex/TraePlanEntry.js.map +1 -0
  19. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  20. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  21. package/dist/config/loadConfig.js +4 -0
  22. package/dist/config/loadConfig.js.map +1 -1
  23. package/dist/config/schema.js +3 -0
  24. package/dist/config/schema.js.map +1 -1
  25. package/dist/feishu/CardRenderer.js +92 -8
  26. package/dist/feishu/CardRenderer.js.map +1 -1
  27. package/dist/feishu/FeishuConnector.js +1 -0
  28. package/dist/feishu/FeishuConnector.js.map +1 -1
  29. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  30. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  31. package/dist/index.js +57 -7
  32. package/dist/index.js.map +1 -1
  33. package/dist/local-files/LocalFileViewerServer.js +51 -4
  34. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  35. package/dist/local-files/TurnPreviewPage.js +7 -2
  36. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  37. package/dist/presentation/TurnStateReducer.js +41 -6
  38. package/dist/presentation/TurnStateReducer.js.map +1 -1
  39. package/dist/proxy/ProxySessionController.js +620 -214
  40. package/dist/proxy/ProxySessionController.js.map +1 -1
  41. package/dist/proxy/SessionOperationQueue.js +14 -0
  42. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  43. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  44. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  45. package/dist/state/StateStore.js +22 -0
  46. package/dist/state/StateStore.js.map +1 -1
  47. package/dist/state/TurnPreviewJournal.js +19 -7
  48. package/dist/state/TurnPreviewJournal.js.map +1 -1
  49. package/dist/state/migrations.js +6 -0
  50. package/dist/state/migrations.js.map +1 -1
  51. package/dist/supervision/SafeRestartNotifier.js +33 -1
  52. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  53. package/dist/supervision/SafeRestartScheduler.js +22 -6
  54. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  55. package/dist/terminal/TerminalCards.js +13 -0
  56. package/dist/terminal/TerminalCards.js.map +1 -0
  57. package/dist/terminal/TerminalCommandTracker.js +74 -0
  58. package/dist/terminal/TerminalCommandTracker.js.map +1 -0
  59. package/dist/terminal/TerminalLinks.js +76 -0
  60. package/dist/terminal/TerminalLinks.js.map +1 -0
  61. package/dist/terminal/TerminalPage.js +212 -0
  62. package/dist/terminal/TerminalPage.js.map +1 -0
  63. package/dist/terminal/TerminalProcess.js +95 -0
  64. package/dist/terminal/TerminalProcess.js.map +1 -0
  65. package/dist/terminal/TerminalServer.js +676 -0
  66. package/dist/terminal/TerminalServer.js.map +1 -0
  67. package/docs/technical-reference.md +48 -0
  68. package/docs/technical-reference.zh.md +48 -0
  69. package/npm-shrinkwrap.json +55 -5
  70. package/package.json +11 -4
  71. package/skills/agent-bot/SKILL.md +11 -2
  72. package/src/cli/controlProtocol.ts +2 -1
  73. package/src/cli/serverStatus.ts +1 -0
  74. package/src/cli.ts +6 -1
  75. package/src/codex/CodexEventMapper.ts +1 -0
  76. package/src/codex/CodexRuntime.ts +244 -128
  77. package/src/codex/ProviderSwitch.ts +11 -12
  78. package/src/codex/TraePlanEntry.ts +63 -0
  79. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  80. package/src/config/loadConfig.ts +4 -0
  81. package/src/config/schema.ts +5 -1
  82. package/src/feishu/CardRenderer.ts +109 -8
  83. package/src/feishu/FeishuConnector.ts +1 -0
  84. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  85. package/src/feishu/types.ts +1 -0
  86. package/src/index.ts +56 -8
  87. package/src/local-files/LocalFileViewerServer.ts +48 -4
  88. package/src/local-files/TurnPreviewPage.ts +7 -2
  89. package/src/presentation/TurnStateReducer.ts +46 -7
  90. package/src/presentation/turnViewTypes.ts +5 -0
  91. package/src/proxy/ProxySessionController.ts +629 -211
  92. package/src/proxy/SessionOperationQueue.ts +13 -0
  93. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  94. package/src/runtime/types.ts +8 -3
  95. package/src/state/StateStore.ts +38 -1
  96. package/src/state/TurnPreviewJournal.ts +19 -7
  97. package/src/state/migrations.ts +6 -0
  98. package/src/supervision/SafeRestartNotifier.ts +35 -2
  99. package/src/supervision/SafeRestartScheduler.ts +25 -6
  100. package/src/terminal/TerminalCards.ts +18 -0
  101. package/src/terminal/TerminalCommandTracker.ts +74 -0
  102. package/src/terminal/TerminalLinks.ts +61 -0
  103. package/src/terminal/TerminalPage.ts +213 -0
  104. package/src/terminal/TerminalProcess.ts +93 -0
  105. package/src/terminal/TerminalServer.ts +551 -0
@@ -4,6 +4,7 @@ import {
4
4
  appendSteerMessage as appendSteerMessageToState,
5
5
  createTurnViewState,
6
6
  compactTurnView,
7
+ continueTurnView,
7
8
  reduceTurnEvent,
8
9
  } from "../presentation/TurnStateReducer.js";
9
10
  import { splitMarkdown } from "../presentation/splitMarkdown.js";
@@ -74,6 +75,7 @@ interface TurnEntry {
74
75
  elapsedTimer?: ReturnType<typeof setInterval>;
75
76
  finalizing?: Promise<void>;
76
77
  lastFinalizedStatus?: TurnViewState["status"];
78
+ continuing?: boolean;
77
79
  }
78
80
 
79
81
  export class FeishuTurnPresenter {
@@ -158,6 +160,13 @@ export class FeishuTurnPresenter {
158
160
  const existing = this.pendingEntries.get(sessionId);
159
161
  if (existing) {
160
162
  await existing.initializing;
163
+ if (existing.continuing) {
164
+ existing.state = { ...existing.state, status: "running", error: undefined, completedAt: undefined, durationMs: undefined };
165
+ this.persist(existing.state.turnId, sessionId, existing.state, existing.contextKey);
166
+ existing.scheduler?.update(existing.state, "critical");
167
+ this.startElapsedUpdates(existing);
168
+ return existing.state.turnId;
169
+ }
161
170
  const images = localImagePaths === undefined ? existing.state.promptImagePaths : [...new Set(localImagePaths)];
162
171
  if ((prompt && existing.state.prompt !== prompt) || JSON.stringify(images) !== JSON.stringify(existing.state.promptImagePaths)) {
163
172
  existing.state = { ...existing.state, ...(prompt ? { prompt } : {}), ...(images ? { promptImagePaths: images } : {}) };
@@ -204,15 +213,15 @@ export class FeishuTurnPresenter {
204
213
  ): Promise<boolean> {
205
214
  const entry = this.pendingEntries.get(sessionId);
206
215
  if (!entry) return false;
207
- this.pendingEntries.delete(sessionId);
208
- this.stopElapsedUpdates(entry);
216
+ if (!entry.continuing) this.pendingEntries.delete(sessionId);
217
+ if (!entry.continuing) this.stopElapsedUpdates(entry);
209
218
  const completedAt = Date.now();
210
219
  entry.state = {
211
220
  ...entry.state,
212
- status: "failed",
221
+ status: entry.continuing ? "running" : "failed",
213
222
  error: message,
214
- completedAt,
215
- durationMs: Math.max(0, completedAt - entry.state.startedAt),
223
+ completedAt: entry.continuing ? undefined : completedAt,
224
+ durationMs: entry.continuing ? undefined : Math.max(0, completedAt - entry.state.startedAt),
216
225
  };
217
226
  this.persist(entry.state.turnId, sessionId, entry.state, entry.contextKey);
218
227
  await entry.initializing;
@@ -298,7 +307,7 @@ export class FeishuTurnPresenter {
298
307
  status: "running" as const,
299
308
  };
300
309
  this.store.previews?.seed(initial);
301
- this.store.previews?.user(turnId, activityId, text, localImagePaths);
310
+ this.store.previews?.user(initial.previewTurnId ?? turnId, activityId, text, localImagePaths);
302
311
  const state = this.cardState(appendSteerMessageToState(initial, activityId, text, localImagePaths));
303
312
  entry = { contextKey, state, initializing: Promise.resolve() } as TurnEntry;
304
313
  this.entries.set(turnId, entry);
@@ -309,7 +318,7 @@ export class FeishuTurnPresenter {
309
318
  }
310
319
 
311
320
  await entry.initializing;
312
- this.store.previews?.user(turnId, activityId, text, localImagePaths);
321
+ this.store.previews?.user(entry.state.previewTurnId ?? turnId, activityId, text, localImagePaths);
313
322
  entry.state = this.cardState(appendSteerMessageToState(entry.state, activityId, text, localImagePaths));
314
323
  this.persist(turnId, sessionId, entry.state, entry.contextKey);
315
324
  if (!entry.historySnapshot) entry.scheduler?.update(entry.state, "critical");
@@ -317,10 +326,50 @@ export class FeishuTurnPresenter {
317
326
 
318
327
  async onEvent(event: AgentEvent): Promise<void> {
319
328
  let existing = this.entries.get(event.turnId);
329
+ if (!existing) {
330
+ const saved = this.store.getTurnSnapshot(event.turnId);
331
+ if (isTurnViewState(saved) && saved.continuedToTurnId) return;
332
+ }
320
333
  let eventApplied = false;
334
+ if (event.type === "turn_completed" && event.approvedPlan) {
335
+ const entry = existing ?? this.createEntry({ type: "progress", sessionId: event.sessionId,
336
+ turnId: event.turnId, activityId: "plan-handoff", text: "方案已确认,继续执行" });
337
+ if (!entry || entry.continuing) return;
338
+ entry.continuing = true;
339
+ entry.historySnapshot = undefined;
340
+ this.store.previews?.seed(entry.state);
341
+ entry.state = { ...entry.state, status: "running", approval: undefined };
342
+ this.pendingEntries.set(event.sessionId, entry);
343
+ this.persist(event.turnId, event.sessionId, entry.state, entry.contextKey);
344
+ await entry.initializing;
345
+ await entry.scheduler?.flush(entry.state);
346
+ return;
347
+ }
321
348
  if (!existing && event.type === "turn_started") {
322
349
  const pending = this.pendingEntries.get(event.sessionId);
323
- if (pending) {
350
+ if (pending?.continuing) {
351
+ const previous = pending.state;
352
+ const previewTurnId = previous.previewTurnId ?? previous.turnId;
353
+ this.flushPersistence();
354
+ const state = continueTurnView(previous, event.turnId, event.startedAt);
355
+ this.store.previews?.handoff(previewTurnId, event.turnId, event.startedAt);
356
+ this.store.saveTurnSnapshot(previous.turnId, event.sessionId, {
357
+ ...previous, status: "completed", completedAt: event.startedAt,
358
+ durationMs: Math.max(0, event.startedAt - previous.startedAt),
359
+ finalResponse: "", continuedToTurnId: event.turnId, previewTurnId,
360
+ }, pending.contextKey);
361
+ if (pending.messageId) this.store.saveTurnDelivery(event.turnId, { progressMessageId: pending.messageId });
362
+ this.entries.delete(previous.turnId);
363
+ this.pendingEntries.delete(event.sessionId);
364
+ pending.state = state;
365
+ pending.continuing = false;
366
+ pending.lastFinalizedStatus = undefined;
367
+ pending.historySnapshot = undefined;
368
+ this.entries.set(event.turnId, pending);
369
+ this.persist(event.turnId, event.sessionId, state, pending.contextKey);
370
+ existing = pending;
371
+ eventApplied = true;
372
+ } else if (pending) {
324
373
  const pendingTurnId = pending.state.turnId;
325
374
  const state = reduceTurnEvent(
326
375
  createTurnViewState(
@@ -362,8 +411,9 @@ export class FeishuTurnPresenter {
362
411
  if (!entry) return;
363
412
 
364
413
  if (existing && !eventApplied) {
414
+ event = presentationEvent(event, entry.state);
365
415
  this.store.previews?.seed(entry.state);
366
- this.store.previews?.event(event);
416
+ this.store.previews?.event(event, entry.state.previewTurnId);
367
417
  const previousStatus = entry.state.status;
368
418
  entry.state = this.cardState(reduceTurnEvent(entry.state, event));
369
419
  if (isTerminalViewStatus(previousStatus) && !event.type.startsWith("turn_")) entry.state.status = previousStatus;
@@ -374,6 +424,8 @@ export class FeishuTurnPresenter {
374
424
  if (!entry.scheduler) return;
375
425
 
376
426
  if (isTerminalEvent(event)) {
427
+ if (this.pendingEntries.get(event.sessionId) === entry) this.pendingEntries.delete(event.sessionId);
428
+ entry.continuing = false;
377
429
  this.stopElapsedUpdates(entry);
378
430
  if (entry.finalizing) await entry.finalizing;
379
431
  const terminalState = entry.state;
@@ -394,7 +446,8 @@ export class FeishuTurnPresenter {
394
446
  }
395
447
 
396
448
  async showDetails(contextKey: string, turnId: string): Promise<void> {
397
- const snapshot = this.store.previews?.load(turnId) ?? this.store.getTurnSnapshot(turnId);
449
+ const saved = this.store.getTurnSnapshot(turnId);
450
+ const snapshot = this.store.previews?.load(isTurnViewState(saved) ? saved.previewTurnId ?? turnId : turnId) ?? saved;
398
451
  if (!isTurnViewState(snapshot)) {
399
452
  await this.outbound.sendText(contextKey, "未找到这次执行的详情。可能已被清理。");
400
453
  return;
@@ -408,6 +461,7 @@ export class FeishuTurnPresenter {
408
461
  page: number | "latest",
409
462
  messageId?: string,
410
463
  ): Promise<void> {
464
+ turnId = this.latestPresentationTurn(turnId, contextKey, messageId);
411
465
  const entry = this.entries.get(turnId);
412
466
  if (entry) await entry.initializing;
413
467
  const ownsProgressCard = entry
@@ -430,7 +484,7 @@ export class FeishuTurnPresenter {
430
484
  }
431
485
 
432
486
  if (!entry.historySnapshot) {
433
- entry.historySnapshot = this.store.previews?.load(turnId) ?? entry.state;
487
+ entry.historySnapshot = this.store.previews?.load(entry.state.previewTurnId ?? turnId) ?? entry.state;
434
488
  await entry.scheduler?.flush();
435
489
  }
436
490
  const card = this.renderActivityHistory(entry.historySnapshot, page);
@@ -439,7 +493,8 @@ export class FeishuTurnPresenter {
439
493
  return;
440
494
  }
441
495
 
442
- const snapshot = this.store.previews?.load(turnId) ?? this.store.getTurnSnapshot(turnId);
496
+ const saved = this.store.getTurnSnapshot(turnId);
497
+ const snapshot = this.store.previews?.load(isTurnViewState(saved) ? saved.previewTurnId ?? turnId : turnId) ?? saved;
443
498
  if (!isTurnViewState(snapshot)) throw new Error("未找到这次执行的活动历史。");
444
499
  const card = page === "latest"
445
500
  ? this.renderTurn(snapshot)
@@ -477,9 +532,9 @@ export class FeishuTurnPresenter {
477
532
  const compact = compactTurnView(state);
478
533
  this.dirty.set(turnId, { state: compact, contextKey });
479
534
  if (!event || isTerminalViewStatus(state.status) || state.status === "waiting_for_approval") {
480
- this.store.previews.flush(turnId, true);
535
+ this.store.previews.flush(state.previewTurnId ?? turnId, true);
481
536
  this.flushPersistence();
482
- if (isTerminalViewStatus(state.status)) this.store.previews.release(turnId);
537
+ if (isTerminalViewStatus(state.status)) this.store.previews.release(state.previewTurnId ?? turnId);
483
538
  } else if (!this.persistTimer) {
484
539
  this.persistTimer = setTimeout(() => {
485
540
  try { this.flushPersistence(); } catch (error) { this.options.onError?.(error); }
@@ -519,7 +574,8 @@ export class FeishuTurnPresenter {
519
574
  this.sessionModelProviders.get(event.sessionId),
520
575
  );
521
576
  this.store.previews?.seed(initial);
522
- this.store.previews?.event(event);
577
+ event = presentationEvent(event, initial);
578
+ this.store.previews?.event(event, initial.previewTurnId);
523
579
  const state = this.cardState(reduceTurnEvent(initial, event));
524
580
  const entry = { contextKey, state, initializing: Promise.resolve() } as TurnEntry;
525
581
  this.entries.set(event.turnId, entry);
@@ -596,7 +652,21 @@ export class FeishuTurnPresenter {
596
652
  private turnPreviewUrl(state: TurnViewState): string | undefined {
597
653
  return state.turnId.startsWith("pending_")
598
654
  ? undefined
599
- : this.options.turnPreviewUrl?.(state.turnId);
655
+ : this.options.turnPreviewUrl?.(state.previewTurnId ?? state.turnId);
656
+ }
657
+
658
+ private latestPresentationTurn(turnId: string, contextKey: string, messageId?: string): string {
659
+ const seen = new Set<string>();
660
+ while (!seen.has(turnId)) {
661
+ seen.add(turnId);
662
+ const saved = this.store.getTurnSnapshot(turnId);
663
+ if (!isTurnViewState(saved) || !saved.continuedToTurnId) break;
664
+ const next = saved.continuedToTurnId;
665
+ if (this.store.getTurnContextKey?.(next) !== contextKey
666
+ || (messageId && this.store.getTurnDelivery(next)?.progressMessageId !== messageId)) break;
667
+ turnId = next;
668
+ }
669
+ return turnId;
600
670
  }
601
671
 
602
672
  private startElapsedUpdates(entry: TurnEntry): void {
@@ -717,6 +787,28 @@ export class FeishuTurnPresenter {
717
787
  }
718
788
  }
719
789
 
790
+ function presentationEvent(event: AgentEvent, state: TurnViewState): AgentEvent {
791
+ if (event.type === "turn_started" && state.segmentStartedAt !== undefined) return { ...event, startedAt: state.startedAt };
792
+ if (event.type === "turn_completed" && event.durationMs !== undefined && state.segmentStartedAt !== undefined) {
793
+ return { ...event, durationMs: Math.max(0, state.segmentStartedAt - state.startedAt) + event.durationMs };
794
+ }
795
+ if (state.continuedFromTurnId) {
796
+ // Tool/item IDs are only required to be unique within one runtime Turn.
797
+ const scoped = (id: string) => event.turnId + ":" + id;
798
+ if (event.type === "tool_started" || event.type === "tool_updated") return { ...event, tool: { ...event.tool, id: scoped(event.tool.id) } };
799
+ if (event.type === "tool_output_delta") return { ...event, toolId: scoped(event.toolId) };
800
+ if (event.type === "reasoning_delta" || event.type === "reasoning_completed") return { ...event, itemId: scoped(event.itemId) };
801
+ if (event.type === "progress") return { ...event,
802
+ activityId: (event.activityId?.startsWith("commentary:") ? "commentary:" : "") + scoped(event.activityId ?? "progress"),
803
+ ...(event.reasoning ? { reasoning: { ...event.reasoning, itemId: scoped(event.reasoning.itemId) } } : {}) };
804
+ if (event.type === "agent_text_delta" && event.replacesActivityId) return {
805
+ ...event, replacesActivityId: "commentary:" + scoped(event.replacesActivityId),
806
+ };
807
+ if (event.type === "context_compaction" && event.compactionId) return { ...event, compactionId: scoped(event.compactionId) };
808
+ }
809
+ return event;
810
+ }
811
+
720
812
  function normalizeTurnCardMarkdown(
721
813
  state: TurnViewState,
722
814
  localFileUrl: LocalFileUrlResolver | undefined,
@@ -7,6 +7,7 @@ export type { MergedForwardContent, ReferencedMessageContent } from "./MergedFor
7
7
 
8
8
  export interface IncomingMessage {
9
9
  messageId: string;
10
+ messageType?: string;
10
11
  createdAt?: number;
11
12
  contextKey: string;
12
13
  chatId?: string;
package/src/index.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import path from "node:path";
2
2
  import { spawn } from "node:child_process";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { TerminalServer } from "./terminal/TerminalServer.js";
5
+ import { sendTerminalAuthorization } from "./terminal/TerminalCards.js";
6
+ import { ShellCommandJobManager } from "./shell/ShellCommandJobManager.js";
4
7
  import { LocalControlServer } from "./cli/LocalControlServer.js";
5
8
  import { controlEndpoint, type ControlRequest, type ControlResponse } from "./cli/controlProtocol.js";
6
9
  import { readPackageVersion } from "./cli/packageVersion.js";
@@ -79,6 +82,8 @@ const transport = consoleOnly ? "console" : requireServerFeishuTransport(config.
79
82
  const logger = createLogger(config);
80
83
  const store = new StateStore(config.storage.sqlitePath, (error) => logger.error({ error }, "Failed to persist Turn preview journal."));
81
84
  let localFileViewer: LocalFileViewerServer | undefined;
85
+ let terminalServer: TerminalServer | undefined;
86
+ const shellCommandJobs = new ShellCommandJobManager(path.join(path.dirname(config.storage.sqlitePath), "command-jobs"));
82
87
  let loadTurnDetails: ((turnId: string) => Promise<unknown>) | undefined;
83
88
  if (config.fileViewer.enabled) {
84
89
  const viewer = new LocalFileViewerServer({
@@ -140,7 +145,14 @@ if (feishuOutbound) {
140
145
  workspaceKind: defaultAgent?.kind === "app-server" ? "projectless" : "project",
141
146
  defaultUserOpenId: config.feishu.userOpenId,
142
147
  }, metadataHydrator);
143
- safeRestartNotifier = new SafeRestartNotifier(store, feishuOutbound, renderer, logger);
148
+ safeRestartNotifier = new SafeRestartNotifier(store, feishuOutbound, renderer, logger, {
149
+ ownerId: () => config.feishu.userOpenId,
150
+ isCurrentSchedule: (scheduleId) => !shuttingDown && !restartRequested && safeRestart.isCurrentSchedule(scheduleId),
151
+ stopTerminal: (terminalId, userId) => {
152
+ if (!terminalServer) throw new Error("网页终端未启用。");
153
+ return terminalServer.stopActiveTerminal(terminalId, userId);
154
+ },
155
+ });
144
156
  }
145
157
 
146
158
  const consoleEnabled = consoleOnly || config.console.enabled || transport === "console";
@@ -160,7 +172,7 @@ let restartRequested = false;
160
172
  let pendingSelfUpdatePlanPath: string | undefined;
161
173
  let preparingSelfUpdate = false;
162
174
  const safeRestart = new SafeRestartScheduler({
163
- readActivity: () => store.getServerActivityState(),
175
+ readActivity: () => serverActivity(),
164
176
  onReady: (reason, notificationTargets) => pendingSelfUpdatePlanPath
165
177
  ? initiateSelfUpdate(reason, notificationTargets)
166
178
  : initiateRestart(reason, notificationTargets),
@@ -226,8 +238,43 @@ async function prepareAndScheduleUpdate(version: string, target: RestartNotifica
226
238
  }
227
239
  }
228
240
 
241
+ if (feishuOutbound && localFileViewer && config.terminal?.enabled !== false) {
242
+ const server = new TerminalServer({
243
+ stateDirectory: path.join(path.dirname(config.storage.sqlitePath), "terminal"),
244
+ ownerId: () => config.feishu.userOpenId,
245
+ canStart: () => !shuttingDown && !restartRequested && !safeRestart.scheduled && !safeRestart.restarting,
246
+ readJob: (jobId) => shellCommandJobs.readJob(jobId),
247
+ requestApproval: (authorization) => sendTerminalAuthorization(feishuOutbound, authorization),
248
+ updateCard: (messageId, view) => feishuOutbound.updateInteractiveCard(messageId, new CardRenderer().renderTerminalAuthorization(view)),
249
+ logger,
250
+ });
251
+ try {
252
+ const publicBaseUrl = await server.start(localFileViewer);
253
+ terminalServer = server;
254
+ logger.info({ publicBaseUrl }, "Interactive terminal service started (owner authorization required).");
255
+ } catch (error) {
256
+ await server.close();
257
+ logger.warn({ error }, "Terminal service unavailable; command cards remain usable.");
258
+ }
259
+ }
260
+
261
+ function serverActivity() {
262
+ const activity = store.getServerActivityState();
263
+ const terminalBlockers = terminalServer?.listActiveTerminals() ?? [];
264
+ return { ...activity, activeTerminals: terminalBlockers.length, terminalBlockers };
265
+ }
266
+
229
267
  const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
230
268
  supervised,
269
+ terminalUrl: (jobId) => terminalServer?.createUrl(jobId),
270
+ authorizeTerminal: (action) => {
271
+ if (!terminalServer) throw new Error("网页终端未启用。");
272
+ return terminalServer.resolveApproval(action);
273
+ },
274
+ stopSafeRestartTerminal: (action) => {
275
+ if (!safeRestartNotifier) throw new Error("安全重启通知未启用。");
276
+ return safeRestartNotifier.stopTerminal(action);
277
+ },
231
278
  showUpdates: (contextKey, userId) => manualUpdates.show(contextKey, userId),
232
279
  selectUpdate: (action, replyTarget) => manualUpdates.select(action, replyTarget),
233
280
  restart: requestRestart,
@@ -264,7 +311,7 @@ const controller = new ProxySessionController(config, store, runtimes, outbound,
264
311
  const changed = writeAgentExecutionDefaults(activeConfigPath, agentName, defaults);
265
312
  logger.info({ agentName, changed }, "Stored Agent execution defaults.");
266
313
  },
267
- });
314
+ }, undefined, undefined, shellCommandJobs);
268
315
  loadTurnDetails = (turnId) => controller.loadTurnDetails(turnId);
269
316
 
270
317
  if (feishuOutbound) {
@@ -327,7 +374,7 @@ function recoverPendingSelfUpdate(): void {
327
374
  pending.plan.reason ?? `Resume Agent Bot update to ${pending.plan.toVersion}`,
328
375
  notificationTarget,
329
376
  {
330
- restartImmediatelyIfIdle: isServerIdle(store.getServerActivityState()),
377
+ restartImmediatelyIfIdle: isServerIdle(serverActivity()),
331
378
  },
332
379
  );
333
380
  logger.warn(
@@ -468,7 +515,7 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
468
515
  }),
469
516
  safeRestartScheduled: safeRestart.scheduled,
470
517
  safeRestartReason: safeRestart.pendingReason,
471
- activity: store.getServerActivityState(),
518
+ activity: serverActivity(),
472
519
  },
473
520
  };
474
521
  case "server_restart": {
@@ -497,7 +544,7 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
497
544
  }
498
545
  assertSelfUpdatePlanPath(request.planPath, agentBotHome());
499
546
  const notificationTarget = inferServerRestartTarget(request.notificationSessionId);
500
- const restartImmediatelyIfIdle = isServerIdle(store.getServerActivityState());
547
+ const restartImmediatelyIfIdle = isServerIdle(serverActivity());
501
548
  pendingSelfUpdatePlanPath = request.planPath;
502
549
  safeRestart.schedule(request.reason, notificationTarget, { restartImmediatelyIfIdle });
503
550
  return {
@@ -636,6 +683,7 @@ async function shutdown(
636
683
  outbound.flushAll().catch((error: unknown) => logger.warn({ error }, "Failed to flush presenters.")),
637
684
  delay(5_000),
638
685
  ]);
686
+ await terminalServer?.close().catch((error: unknown) => logger.warn({ error }, "Failed to close terminals."));
639
687
  runtimes.close();
640
688
  await controlServer?.close().catch((error: unknown) => logger.warn({ error }, "Failed to close local control endpoint."));
641
689
  await localFileViewer?.close().catch((error: unknown) => logger.warn({ error }, "Failed to close local file viewer."));
@@ -708,8 +756,8 @@ function inferServerRestartTarget(notificationSessionId?: string): RestartNotifi
708
756
  return privateRestartNotificationTarget(config.feishu.userOpenId);
709
757
  }
710
758
 
711
- function isServerIdle(activity: { runningSessions: number; pendingFinalDeliveries: number }): boolean {
712
- return activity.runningSessions === 0 && activity.pendingFinalDeliveries === 0;
759
+ function isServerIdle(activity: { runningSessions: number; pendingFinalDeliveries: number; activeTerminals?: number }): boolean {
760
+ return activity.runningSessions === 0 && activity.pendingFinalDeliveries === 0 && !activity.activeTerminals;
713
761
  }
714
762
 
715
763
  function restartNotificationTargetForSession(
@@ -3,6 +3,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import { createRequire } from "node:module";
5
5
  import http, { type IncomingMessage, type ServerResponse } from "node:http";
6
+ import type { Duplex } from "node:stream";
6
7
  import path from "node:path";
7
8
  import hljs from "highlight.js/lib/common";
8
9
  import dos from "highlight.js/lib/languages/dos";
@@ -283,11 +284,18 @@ export interface LocalFileViewerAddress {
283
284
  publicConnectionKind?: NetworkConnectionKind;
284
285
  }
285
286
 
287
+ export interface LocalFileViewerService {
288
+ handleRequest(request: IncomingMessage, response: ServerResponse): void;
289
+ handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void;
290
+ close(): Promise<void>;
291
+ }
292
+
286
293
  export class LocalFileViewerServer {
287
294
  private server?: http.Server;
288
295
  private secret?: Buffer;
289
296
  private address?: LocalFileViewerAddress;
290
297
  private basePath = "";
298
+ private readonly services = new Map<string, LocalFileViewerService>();
291
299
 
292
300
  constructor(private readonly options: LocalFileViewerServerOptions) {}
293
301
 
@@ -315,6 +323,11 @@ export class LocalFileViewerServer {
315
323
  });
316
324
  });
317
325
  server.on("clientError", (_error, socket) => socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"));
326
+ server.on("upgrade", (request, socket, head) => {
327
+ const service = this.serviceFor(request);
328
+ if (service) service.handleUpgrade(request, socket, head);
329
+ else socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
330
+ });
318
331
 
319
332
  let selectedPort = preferredPort;
320
333
  try {
@@ -361,12 +374,36 @@ export class LocalFileViewerServer {
361
374
  }
362
375
 
363
376
  async close(): Promise<void> {
377
+ await Promise.all([...this.services.values()].map((service) => service.close()));
378
+ this.services.clear();
364
379
  const server = this.server;
365
380
  this.server = undefined;
366
381
  this.address = undefined;
367
382
  if (server) await closeServer(server);
368
383
  }
369
384
 
385
+ mountService(name: string, service: LocalFileViewerService): { publicBaseUrl: string; unmount(): void } {
386
+ if (!this.address || !this.server) throw new Error("Preview server is not running.");
387
+ if (!/^[a-z][a-z0-9-]*$/u.test(name) || ["preview", "turn-preview", "assets", "view", "raw"].includes(name)) {
388
+ throw new Error("Invalid Preview service route.");
389
+ }
390
+ const basePath = `${this.basePath}/${name}`;
391
+ if (this.services.has(basePath)) throw new Error("Preview service route is already mounted.");
392
+ this.services.set(basePath, service);
393
+ const url = new URL(this.address.publicBaseUrl);
394
+ url.pathname = basePath;
395
+ return { publicBaseUrl: url.toString(), unmount: () => {
396
+ if (this.services.get(basePath) === service) this.services.delete(basePath);
397
+ } };
398
+ }
399
+
400
+ private serviceFor(request: IncomingMessage): LocalFileViewerService | undefined {
401
+ try {
402
+ const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
403
+ return [...this.services].find(([base]) => pathname === base || pathname.startsWith(`${base}/`))?.[1];
404
+ } catch { return undefined; }
405
+ }
406
+
370
407
  createFileUrl(filePath: string, reference?: string): string | undefined {
371
408
  if (!this.address || !this.secret) return undefined;
372
409
  const absolutePath = normalizeAbsolutePath(filePath);
@@ -397,6 +434,8 @@ export class LocalFileViewerServer {
397
434
  }
398
435
 
399
436
  private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
437
+ const service = this.serviceFor(request);
438
+ if (service) { service.handleRequest(request, response); return; }
400
439
  if (request.method !== "GET" && request.method !== "HEAD") {
401
440
  response.setHeader("Allow", "GET, HEAD");
402
441
  this.sendHtml(response, 405, errorPage("不支持的请求", "该服务只接受只读请求。"), request.method === "HEAD");
@@ -433,14 +472,16 @@ export class LocalFileViewerServer {
433
472
  return;
434
473
  }
435
474
  const journal = this.options.previewJournal;
436
- const recorded = journal?.has(turnId);
475
+ const stored = journal?.has(turnId) ? undefined : this.options.getTurnSnapshot?.(turnId);
476
+ const journalId = isTurnPreviewState(stored) ? stored.previewTurnId ?? turnId : turnId;
477
+ const recorded = journal?.has(journalId);
437
478
  if (recorded && requestUrl.searchParams.get("events") === "1") {
438
- this.serveJournalEvents(request, response, turnId, journal!);
479
+ this.serveJournalEvents(request, response, journalId, journal!);
439
480
  return;
440
481
  }
441
482
  const detailKey = requestUrl.searchParams.get("detail");
442
483
  let snapshot = recorded
443
- ? journal!.load(turnId, detailKey ?? false)
484
+ ? journal!.load(journalId, detailKey ?? false)
444
485
  : this.options.getTurnSnapshot?.(turnId);
445
486
  if (!recorded && this.options.loadTurnSnapshot && request.method !== "HEAD"
446
487
  && requestUrl.searchParams.get("events") !== "1" && !requestUrl.searchParams.has("detail")) {
@@ -467,7 +508,7 @@ export class LocalFileViewerServer {
467
508
  const after = requestUrl.searchParams.get("detailAfter");
468
509
  const outputAppend = recorded && after && tool?.kind === "tool" && tool.tool.kind === "command" && !tool.tool.error
469
510
  && !/repl/i.test(tool.tool.title) && snapshot.previewCursor !== undefined
470
- ? journal!.outputAppend(turnId, tool.id, Number(after), snapshot.previewCursor) : undefined;
511
+ ? journal!.outputAppend(journalId, tool.id, Number(after), snapshot.previewCursor) : undefined;
471
512
  const detail = outputAppend !== undefined && tool?.kind === "tool"
472
513
  && (snapshot.fullToolOutputs?.[tool.id]?.length ?? 0) > outputAppend.length
473
514
  ? { key, revision: String(tool.tool.previewRevision), content: "", outputAppend,
@@ -965,6 +1006,9 @@ export class LocalFileViewerServer {
965
1006
  if (renderHtml) {
966
1007
  // A response-level sandbox also isolates this document when opened outside the viewer iframe.
967
1008
  response.setHeader("Content-Security-Policy", "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; media-src data: blob:; font-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
1009
+ } else {
1010
+ // Raw HTML/SVG/XML must not gain the shared origin's storage or execution privileges.
1011
+ response.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:; media-src data: blob:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
968
1012
  }
969
1013
  response.setHeader("Accept-Ranges", "bytes");
970
1014
  response.setHeader("Cache-Control", "no-store");
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { approvalActivityId } from "../presentation/TurnStateReducer.js";
2
3
  import path from "node:path";
3
4
  import { enableDiagramFences, DIAGRAM_PREVIEW_CLIENT_SCRIPT, DIAGRAM_PREVIEW_CSS } from "./DiagramPreview.js";
4
5
  import MarkdownIt from "markdown-it";
@@ -693,7 +694,10 @@ function renderApprovalNotice(request: ApprovalRequest, renderText: (value: stri
693
694
  : `<div class="markdown">${renderText(reason)}</div>`;
694
695
  const details = commandApproval
695
696
  ? `<details class="approval-details" data-activity-id="approval:${escapeAttribute(request.id)}"><summary>${language === "zh" ? "查看完整命令" : "View full command"}</summary><pre data-scroll-id="approval:${escapeAttribute(request.id)}:command">${escapeHtml(request.command!)}</pre></details>` : "";
696
- return `<section class="notice" data-preview-key="approval"><h2>${escapeHtml(title)}</h2>${description}${details}<p>${language === "zh" ? "请在飞书任务卡片中确认或拒绝。" : "Approve or reject using the task card in Feishu."}</p></section>`;
697
+ const instruction = request.automatic
698
+ ? language === "zh" ? "自动执行模式:正在自动同意。" : "Auto mode: approving automatically."
699
+ : language === "zh" ? "请在飞书任务卡片中确认或拒绝。" : "Approve or reject using the task card in Feishu.";
700
+ return `<section class="notice" data-preview-key="approval"><h2>${escapeHtml(title)}</h2>${description}${details}<p>${instruction}</p></section>`;
697
701
  }
698
702
 
699
703
  function renderFinalAnswer(markdown: string, turnId: string, renderText: (value: string) => string, language: TurnPreviewLanguage): string {
@@ -726,7 +730,8 @@ function renderTimeline(
726
730
  projectCwd?: string,
727
731
  deferDetails = false,
728
732
  ): string {
729
- const activities = state.activities ?? [];
733
+ const activities = state.approval
734
+ ? state.activities.filter((a) => a.id !== approvalActivityId(state.turnId, state.approval!.id)) : state.activities ?? [];
730
735
  const positions = new Map(activities.map((activity, index) => [activity.id, index]));
731
736
  const reasoning = new Map<number, TurnReasoningItem[]>();
732
737
  for (const item of turnReasoningItems(state)) {
@@ -1,4 +1,4 @@
1
- import type { AgentEvent, RemoteTurnDetails, ToolState } from "../runtime/types.js";
1
+ import type { AgentEvent, ApprovalDecision, ApprovalRequest, RemoteTurnDetails, ToolState } from "../runtime/types.js";
2
2
  import { appendGeneratedImageMarkdown } from "../utils/generatedImageMarkdown.js";
3
3
  import type { MessageReplyTarget } from "../feishu/types.js";
4
4
  import { formatStorageSize } from "../utils/formatStorageSize.js";
@@ -113,6 +113,13 @@ export function reduceTurnEvent(state: TurnViewState, event: AgentEvent, complet
113
113
  if (event.type === "turn_completed") next.finalResponse = event.finalResponse;
114
114
  if (event.type === "agent_text_delta") next.assistantText = state.assistantText + event.text;
115
115
  if (event.type === "plan_updated") next.plan = event.steps;
116
+ if (event.type === "approval_requested" || event.type === "approval_resolved") {
117
+ const request = event.type === "approval_requested" ? event.request
118
+ : state.approval?.id === event.requestId ? state.approval : undefined;
119
+ if (request) next.activities = next.activities.map((a) => a.id === approvalActivityId(event.turnId, request.id) && a.kind === "assistant"
120
+ ? { ...a, text: approvalHistoryText(request, event.type === "approval_resolved" ? event.decision : undefined,
121
+ event.type === "approval_resolved" && event.automatic) } : a);
122
+ }
116
123
  if (event.type === "progress") {
117
124
  const id = event.activityId ?? "progress";
118
125
  const old = state.activities.find((a) => a.id === id);
@@ -305,7 +312,7 @@ function reduceEvent(state: TurnViewState, event: AgentEvent): TurnViewState {
305
312
  return {
306
313
  ...state,
307
314
  ...tracking,
308
- status: state.approval ? "waiting_for_approval" : "tool_running",
315
+ status: state.approval && !state.approval.automatic ? "waiting_for_approval" : "tool_running",
309
316
  activeTool: bounded,
310
317
  activities: activityUpdate.activities,
311
318
  activitiesTruncated: state.activitiesTruncated || activityUpdate.truncated,
@@ -317,10 +324,21 @@ function reduceEvent(state: TurnViewState, event: AgentEvent): TurnViewState {
317
324
  case "tool_output_delta":
318
325
  return reduceToolOutputDelta(state, event.toolId, event.delta);
319
326
  case "approval_requested":
320
- return { ...state, status: "waiting_for_approval", approval: event.request };
327
+ return { ...state, status: event.request.automatic ? state.activeTool ? "tool_running" : "running" : "waiting_for_approval", approval: event.request,
328
+ activities: [...state.activities.filter((a) => a.id !== approvalActivityId(event.turnId, event.request.id)), {
329
+ kind: "assistant", id: approvalActivityId(event.turnId, event.request.id), text: bound(approvalHistoryText(event.request)),
330
+ }],
331
+ };
321
332
  case "approval_resolved":
322
333
  if (state.approval?.id !== event.requestId) return state;
323
- return { ...state, status: state.activeTool ? "tool_running" : "running", approval: undefined };
334
+ return {
335
+ ...state, status: state.activeTool ? "tool_running" : "running", approval: undefined,
336
+ activities: upsertActivity(state.activities,
337
+ state.activities.findIndex((a) => a.id === approvalActivityId(event.turnId, event.requestId)), {
338
+ kind: "assistant", id: approvalActivityId(event.turnId, event.requestId),
339
+ text: bound(approvalHistoryText(state.approval, event.decision, event.automatic)),
340
+ }).activities,
341
+ };
324
342
  case "turn_completed": {
325
343
  const durationMs = Math.max(0, event.durationMs ?? Date.now() - state.startedAt);
326
344
  return {
@@ -363,6 +381,27 @@ function reduceEvent(state: TurnViewState, event: AgentEvent): TurnViewState {
363
381
  }
364
382
  }
365
383
 
384
+ export function approvalActivityId(turnId: string, requestId: string): string {
385
+ return "approval:" + turnId + ":" + requestId;
386
+ }
387
+
388
+ export function continueTurnView(state: TurnViewState, turnId: string, startedAt: number): TurnViewState {
389
+ return { ...state, turnId, previewTurnId: state.previewTurnId ?? state.turnId,
390
+ continuedFromTurnId: state.turnId, continuedToTurnId: undefined, segmentStartedAt: startedAt,
391
+ status: "running", completedAt: undefined, durationMs: undefined, finalResponse: undefined,
392
+ error: undefined, approval: undefined, activeTool: undefined, assistantText: "", progressText: undefined };
393
+ }
394
+
395
+ function approvalHistoryText(request: ApprovalRequest, decision?: ApprovalDecision, automatic = false): string {
396
+ const labels = { accept: "允许一次", acceptForSession: "本会话允许", decline: "拒绝", cancel: "取消请求" };
397
+ const choice = decision ? request.options.find((option) => option.id === decision)?.label ?? labels[decision] : undefined;
398
+ const heading = choice ? automatic ? "自动同意(auto)" : "用户选择:" + choice
399
+ : request.automatic ? "正在自动同意(auto)" : "等待选择";
400
+ return ["**审批请求 · " + heading + "**", request.title, request.reason,
401
+ request.command ? "~~~\n" + request.command + "\n~~~" : undefined,
402
+ ].filter(Boolean).join("\n\n");
403
+ }
404
+
366
405
  export function appendSteerMessage(state: TurnViewState, id: string, text: string, localImagePaths?: string[]): TurnViewState {
367
406
  const normalized = text.trim();
368
407
  if (!normalized && !localImagePaths?.length) return state;
@@ -442,7 +481,7 @@ function reduceToolUpdate(state: TurnViewState, tool: ToolState, fullOutput?: st
442
481
  ...tracking,
443
482
  fullToolOutputs,
444
483
  fullToolErrors,
445
- status: state.approval ? "waiting_for_approval" : "tool_running",
484
+ status: state.approval && !state.approval.automatic ? "waiting_for_approval" : "tool_running",
446
485
  activeTool: bounded,
447
486
  activities: activityUpdate.activities,
448
487
  activitiesTruncated: state.activitiesTruncated || activityUpdate.truncated,
@@ -458,7 +497,7 @@ function reduceToolUpdate(state: TurnViewState, tool: ToolState, fullOutput?: st
458
497
  ...tracking,
459
498
  fullToolOutputs,
460
499
  fullToolErrors,
461
- status: state.approval ? "waiting_for_approval" : activeTool ? "tool_running" : "running",
500
+ status: state.approval && !state.approval.automatic ? "waiting_for_approval" : activeTool ? "tool_running" : "running",
462
501
  activeTool,
463
502
  activities: activityUpdate.activities,
464
503
  activitiesTruncated: state.activitiesTruncated || activityUpdate.truncated,
@@ -473,7 +512,7 @@ function reduceToolUpdate(state: TurnViewState, tool: ToolState, fullOutput?: st
473
512
  ...tracking,
474
513
  fullToolOutputs,
475
514
  fullToolErrors,
476
- status: state.approval ? "waiting_for_approval" : activeTool ? "tool_running" : "running",
515
+ status: state.approval && !state.approval.automatic ? "waiting_for_approval" : activeTool ? "tool_running" : "running",
477
516
  activeTool,
478
517
  activities: activityUpdate.activities,
479
518
  activitiesTruncated: state.activitiesTruncated || activityUpdate.truncated,