@keyou007/agent-bot 0.1.24-alpha.4 → 0.1.24-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +42 -6
  3. package/README.zh.md +42 -6
  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/AppServerPermissions.js +34 -0
  12. package/dist/codex/AppServerPermissions.js.map +1 -0
  13. package/dist/codex/CodexLocalActivityDetector.js +37 -5
  14. package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
  15. package/dist/codex/CodexRuntime.js +238 -127
  16. package/dist/codex/CodexRuntime.js.map +1 -1
  17. package/dist/codex/ProviderSwitch.js +12 -13
  18. package/dist/codex/ProviderSwitch.js.map +1 -1
  19. package/dist/codex/ThreadWriterProcess.js +19 -5
  20. package/dist/codex/ThreadWriterProcess.js.map +1 -1
  21. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  22. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  23. package/dist/config/loadConfig.js +4 -0
  24. package/dist/config/loadConfig.js.map +1 -1
  25. package/dist/config/schema.js +3 -0
  26. package/dist/config/schema.js.map +1 -1
  27. package/dist/feishu/CardRenderer.js +146 -72
  28. package/dist/feishu/CardRenderer.js.map +1 -1
  29. package/dist/feishu/FeishuConnector.js +1 -0
  30. package/dist/feishu/FeishuConnector.js.map +1 -1
  31. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  32. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  33. package/dist/index.js +43 -6
  34. package/dist/index.js.map +1 -1
  35. package/dist/local-files/CsvPreview.js +148 -0
  36. package/dist/local-files/CsvPreview.js.map +1 -0
  37. package/dist/local-files/LocalFileViewerServer.js +104 -14
  38. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  39. package/dist/local-files/TurnPreviewPage.js +128 -12
  40. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  41. package/dist/presentation/TurnStateReducer.js +80 -6
  42. package/dist/presentation/TurnStateReducer.js.map +1 -1
  43. package/dist/proxy/ProxySessionController.js +767 -260
  44. package/dist/proxy/ProxySessionController.js.map +1 -1
  45. package/dist/proxy/SessionOperationQueue.js +14 -0
  46. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  47. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  48. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  49. package/dist/state/StateStore.js +22 -0
  50. package/dist/state/StateStore.js.map +1 -1
  51. package/dist/state/TurnPreviewJournal.js +19 -7
  52. package/dist/state/TurnPreviewJournal.js.map +1 -1
  53. package/dist/state/migrations.js +6 -0
  54. package/dist/state/migrations.js.map +1 -1
  55. package/dist/supervision/SafeRestartNotifier.js +1 -0
  56. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  57. package/dist/supervision/SafeRestartScheduler.js +19 -6
  58. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  59. package/dist/terminal/TerminalCards.js +13 -0
  60. package/dist/terminal/TerminalCards.js.map +1 -0
  61. package/dist/terminal/TerminalLinks.js +76 -0
  62. package/dist/terminal/TerminalLinks.js.map +1 -0
  63. package/dist/terminal/TerminalPage.js +212 -0
  64. package/dist/terminal/TerminalPage.js.map +1 -0
  65. package/dist/terminal/TerminalProcess.js +71 -0
  66. package/dist/terminal/TerminalProcess.js.map +1 -0
  67. package/dist/terminal/TerminalServer.js +639 -0
  68. package/dist/terminal/TerminalServer.js.map +1 -0
  69. package/docs/technical-reference.md +53 -3
  70. package/docs/technical-reference.zh.md +52 -2
  71. package/npm-shrinkwrap.json +55 -5
  72. package/package.json +11 -4
  73. package/skills/agent-bot/SKILL.md +15 -2
  74. package/src/cli/controlProtocol.ts +2 -1
  75. package/src/cli/serverStatus.ts +1 -0
  76. package/src/cli.ts +6 -1
  77. package/src/codex/AppServerPermissions.ts +39 -0
  78. package/src/codex/CodexLocalActivityDetector.ts +34 -4
  79. package/src/codex/CodexRuntime.ts +243 -139
  80. package/src/codex/ProviderSwitch.ts +11 -12
  81. package/src/codex/ThreadWriterProcess.ts +17 -9
  82. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  83. package/src/config/loadConfig.ts +4 -0
  84. package/src/config/schema.ts +5 -1
  85. package/src/feishu/CardRenderer.ts +159 -72
  86. package/src/feishu/FeishuConnector.ts +1 -0
  87. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  88. package/src/feishu/types.ts +1 -0
  89. package/src/index.ts +44 -7
  90. package/src/local-files/CsvPreview.ts +132 -0
  91. package/src/local-files/LocalFileViewerServer.ts +102 -15
  92. package/src/local-files/TurnPreviewPage.ts +131 -13
  93. package/src/presentation/TurnStateReducer.ts +87 -7
  94. package/src/presentation/turnViewTypes.ts +9 -0
  95. package/src/proxy/ProxySessionController.ts +762 -259
  96. package/src/proxy/SessionOperationQueue.ts +13 -0
  97. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  98. package/src/runtime/types.ts +10 -3
  99. package/src/state/StateStore.ts +38 -1
  100. package/src/state/TurnPreviewJournal.ts +19 -7
  101. package/src/state/migrations.ts +6 -0
  102. package/src/supervision/SafeRestartNotifier.ts +1 -0
  103. package/src/supervision/SafeRestartScheduler.ts +18 -6
  104. package/src/terminal/TerminalCards.ts +18 -0
  105. package/src/terminal/TerminalLinks.ts +61 -0
  106. package/src/terminal/TerminalPage.ts +213 -0
  107. package/src/terminal/TerminalProcess.ts +71 -0
  108. package/src/terminal/TerminalServer.ts +513 -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({
@@ -160,7 +165,7 @@ let restartRequested = false;
160
165
  let pendingSelfUpdatePlanPath: string | undefined;
161
166
  let preparingSelfUpdate = false;
162
167
  const safeRestart = new SafeRestartScheduler({
163
- readActivity: () => store.getServerActivityState(),
168
+ readActivity: () => serverActivity(),
164
169
  onReady: (reason, notificationTargets) => pendingSelfUpdatePlanPath
165
170
  ? initiateSelfUpdate(reason, notificationTargets)
166
171
  : initiateRestart(reason, notificationTargets),
@@ -226,8 +231,39 @@ async function prepareAndScheduleUpdate(version: string, target: RestartNotifica
226
231
  }
227
232
  }
228
233
 
234
+ if (feishuOutbound && localFileViewer && config.terminal?.enabled !== false) {
235
+ const server = new TerminalServer({
236
+ stateDirectory: path.join(path.dirname(config.storage.sqlitePath), "terminal"),
237
+ ownerId: () => config.feishu.userOpenId,
238
+ canStart: () => !shuttingDown && !restartRequested && !safeRestart.scheduled && !safeRestart.restarting,
239
+ readJob: (jobId) => shellCommandJobs.readJob(jobId),
240
+ requestApproval: (authorization) => sendTerminalAuthorization(feishuOutbound, authorization),
241
+ updateCard: (messageId, view) => feishuOutbound.updateInteractiveCard(messageId, new CardRenderer().renderTerminalAuthorization(view)),
242
+ logger,
243
+ });
244
+ try {
245
+ const publicBaseUrl = await server.start(localFileViewer);
246
+ terminalServer = server;
247
+ logger.info({ publicBaseUrl }, "Interactive terminal service started (owner authorization required).");
248
+ } catch (error) {
249
+ await server.close();
250
+ logger.warn({ error }, "Terminal service unavailable; command cards remain usable.");
251
+ }
252
+ }
253
+
254
+ function serverActivity() {
255
+ const activity = store.getServerActivityState();
256
+ const activeTerminals = terminalServer?.activeCount ?? 0;
257
+ return { ...activity, activeTerminals };
258
+ }
259
+
229
260
  const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
230
261
  supervised,
262
+ terminalUrl: (jobId) => terminalServer?.createUrl(jobId),
263
+ authorizeTerminal: (action) => {
264
+ if (!terminalServer) throw new Error("网页终端未启用。");
265
+ return terminalServer.resolveApproval(action);
266
+ },
231
267
  showUpdates: (contextKey, userId) => manualUpdates.show(contextKey, userId),
232
268
  selectUpdate: (action, replyTarget) => manualUpdates.select(action, replyTarget),
233
269
  restart: requestRestart,
@@ -264,7 +300,7 @@ const controller = new ProxySessionController(config, store, runtimes, outbound,
264
300
  const changed = writeAgentExecutionDefaults(activeConfigPath, agentName, defaults);
265
301
  logger.info({ agentName, changed }, "Stored Agent execution defaults.");
266
302
  },
267
- });
303
+ }, undefined, undefined, shellCommandJobs);
268
304
  loadTurnDetails = (turnId) => controller.loadTurnDetails(turnId);
269
305
 
270
306
  if (feishuOutbound) {
@@ -327,7 +363,7 @@ function recoverPendingSelfUpdate(): void {
327
363
  pending.plan.reason ?? `Resume Agent Bot update to ${pending.plan.toVersion}`,
328
364
  notificationTarget,
329
365
  {
330
- restartImmediatelyIfIdle: isServerIdle(store.getServerActivityState()),
366
+ restartImmediatelyIfIdle: isServerIdle(serverActivity()),
331
367
  },
332
368
  );
333
369
  logger.warn(
@@ -468,7 +504,7 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
468
504
  }),
469
505
  safeRestartScheduled: safeRestart.scheduled,
470
506
  safeRestartReason: safeRestart.pendingReason,
471
- activity: store.getServerActivityState(),
507
+ activity: serverActivity(),
472
508
  },
473
509
  };
474
510
  case "server_restart": {
@@ -497,7 +533,7 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
497
533
  }
498
534
  assertSelfUpdatePlanPath(request.planPath, agentBotHome());
499
535
  const notificationTarget = inferServerRestartTarget(request.notificationSessionId);
500
- const restartImmediatelyIfIdle = isServerIdle(store.getServerActivityState());
536
+ const restartImmediatelyIfIdle = isServerIdle(serverActivity());
501
537
  pendingSelfUpdatePlanPath = request.planPath;
502
538
  safeRestart.schedule(request.reason, notificationTarget, { restartImmediatelyIfIdle });
503
539
  return {
@@ -636,6 +672,7 @@ async function shutdown(
636
672
  outbound.flushAll().catch((error: unknown) => logger.warn({ error }, "Failed to flush presenters.")),
637
673
  delay(5_000),
638
674
  ]);
675
+ await terminalServer?.close().catch((error: unknown) => logger.warn({ error }, "Failed to close terminals."));
639
676
  runtimes.close();
640
677
  await controlServer?.close().catch((error: unknown) => logger.warn({ error }, "Failed to close local control endpoint."));
641
678
  await localFileViewer?.close().catch((error: unknown) => logger.warn({ error }, "Failed to close local file viewer."));
@@ -708,8 +745,8 @@ function inferServerRestartTarget(notificationSessionId?: string): RestartNotifi
708
745
  return privateRestartNotificationTarget(config.feishu.userOpenId);
709
746
  }
710
747
 
711
- function isServerIdle(activity: { runningSessions: number; pendingFinalDeliveries: number }): boolean {
712
- return activity.runningSessions === 0 && activity.pendingFinalDeliveries === 0;
748
+ function isServerIdle(activity: { runningSessions: number; pendingFinalDeliveries: number; activeTerminals?: number }): boolean {
749
+ return activity.runningSessions === 0 && activity.pendingFinalDeliveries === 0 && !activity.activeTerminals;
713
750
  }
714
751
 
715
752
  function restartNotificationTargetForSession(
@@ -0,0 +1,132 @@
1
+ const MAX_ROWS = 500;
2
+ const MAX_COLUMNS = 50;
3
+ const MAX_CELL_CHARACTERS = 4_000;
4
+
5
+ export interface CsvPreview {
6
+ rows: string[][];
7
+ notices: string[];
8
+ }
9
+
10
+ export function csvSourcePreview(source: string): { text: string; truncated: boolean } {
11
+ const text = source.replace(/\r\n?/gu, "\n");
12
+ let offset = 0;
13
+ for (let line = 0; line < 2_000; line += 1) {
14
+ const newline = text.indexOf("\n", offset);
15
+ if (newline < 0 || newline === text.length - 1) return { text, truncated: false };
16
+ offset = newline + 1;
17
+ }
18
+ return { text: text.slice(0, offset - 1), truncated: true };
19
+ }
20
+
21
+ // Parse records rather than physical lines: quoted CSV fields may contain newlines.
22
+ export function parseCsvPreview(source: string, sourceTruncated = false): CsvPreview {
23
+ const text = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source;
24
+ const rows: string[][] = [];
25
+ const notices = new Set<string>();
26
+ let row: string[] = [];
27
+ let field = "";
28
+ let fieldCharacters = 0;
29
+ let state: "start" | "plain" | "quoted" | "closed" = "start";
30
+ let recordStarted = false;
31
+ const append = (character: string): void => {
32
+ if (row.length >= MAX_COLUMNS) return;
33
+ if (fieldCharacters < MAX_CELL_CHARACTERS) { field += character; fieldCharacters += 1; }
34
+ else notices.add(`过长单元格仅显示前 ${MAX_CELL_CHARACTERS} 个字符。`);
35
+ };
36
+ const endField = (): void => {
37
+ if (row.length < MAX_COLUMNS) row.push(field);
38
+ else notices.add(`表格仅显示前 ${MAX_COLUMNS} 列。`);
39
+ field = "";
40
+ fieldCharacters = 0;
41
+ state = "start";
42
+ };
43
+ if (sourceTruncated) notices.add("文件较大,仅读取开头 2 MiB;末尾不完整的记录不显示。可下载完整文件。");
44
+ let invalid = false;
45
+ for (let index = 0; index < text.length; index += 1) {
46
+ const character = String.fromCodePoint(text.codePointAt(index)!);
47
+ if (character.length === 2) index += 1;
48
+ recordStarted = true;
49
+ if (state === "quoted") {
50
+ if (character === '"') state = "closed";
51
+ else append(character);
52
+ continue;
53
+ }
54
+ if (character === '"') {
55
+ if (state === "start") state = "quoted";
56
+ else if (state === "closed") { append(character); state = "quoted"; }
57
+ else { invalid = true; break; }
58
+ } else if (character === ",") {
59
+ endField();
60
+ } else if (character === "\r" || character === "\n") {
61
+ if (character === "\r" && text[index + 1] === "\n") index += 1;
62
+ endField();
63
+ rows.push(row);
64
+ row = [];
65
+ recordStarted = false;
66
+ if (rows.length === MAX_ROWS && index + 1 < text.length) {
67
+ notices.add(`表格仅显示前 ${MAX_ROWS} 行。`);
68
+ return { rows, notices: [...notices] };
69
+ }
70
+ } else if (state === "closed") {
71
+ invalid = true;
72
+ break;
73
+ } else {
74
+ state = "plain";
75
+ append(character);
76
+ }
77
+ }
78
+ if (invalid || (!sourceTruncated && state === "quoted")) {
79
+ notices.add(`第 ${rows.length + 1} 条记录的引号格式不完整或不合法,已停止解析。请切换代码查看原文。`);
80
+ } else if (recordStarted && !sourceTruncated) {
81
+ endField();
82
+ rows.push(row);
83
+ }
84
+ return { rows, notices: [...notices] };
85
+ }
86
+
87
+ export function renderCsvPreview(source: string, sourceTruncated = false): string {
88
+ const { rows, notices } = parseCsvPreview(source, sourceTruncated);
89
+ const columns = Math.max(0, ...rows.map((row) => row.length));
90
+ const noticesHtml = notices.map((notice) => `<div class="notice">${escapeHtml(notice)}</div>`).join("");
91
+ if (!rows.length) {
92
+ return `<section data-view-panel="rendered">${noticesHtml}<div class="csv-summary">${notices.length ? "没有可显示的完整记录。" : "CSV 文件为空。"}</div></section>`;
93
+ }
94
+ const headings = Array.from({ length: columns }, (_, index) => {
95
+ const label = index < 26 ? String.fromCharCode(65 + index) : `A${String.fromCharCode(65 + index - 26)}`;
96
+ return `<th scope="col">${label}</th>`;
97
+ }).join("");
98
+ const body = rows.map((row, index) => {
99
+ const cells = row.map((value) => `<td><div class="csv-cell">${escapeHtml(value)}</div></td>`).join("");
100
+ const missing = columns - row.length;
101
+ return `<tr><th scope="row">${index + 1}</th>${cells}${missing ? `<td colspan="${missing}" class="csv-missing"></td>` : ""}</tr>`;
102
+ }).join("");
103
+ return `<section data-view-panel="rendered">${noticesHtml}<div class="csv-summary">已显示 ${rows.length} 行 · 最多 ${columns} 列 · 首行保留为数据</div><div class="csv-table-scroll" role="region" aria-label="CSV 表格" tabindex="0"><table class="csv-table"><thead><tr><th scope="col" aria-label="行号">#</th>${headings}</tr></thead><tbody>${body}</tbody></table></div></section>`;
104
+ }
105
+
106
+ function escapeHtml(value: string): string {
107
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
108
+ }
109
+
110
+ export const CSV_PREVIEW_CSS = `
111
+ .csv-summary { padding: 10px 12px; color: #646a73; font-size: 12px; }
112
+ .csv-table-scroll { isolation: isolate; max-width: 100%; max-height: calc(100dvh - var(--viewer-header-offset) - 40px); overflow: auto; background: #fff; }
113
+ .csv-table-scroll:focus-visible { outline: 2px solid #1456f0; outline-offset: -2px; }
114
+ .csv-table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; line-height: 1.5; }
115
+ .csv-table th, .csv-table td { padding: 7px 12px; text-align: left; vertical-align: top; border-right: 1px solid #dfe3e8; border-bottom: 1px solid #dfe3e8; }
116
+ .csv-table th { background: #f0f3f8; font-weight: 600; }
117
+ .csv-table thead th { position: sticky; top: 0; z-index: 1; }
118
+ .csv-table tr > th:first-child { position: sticky; left: 0; text-align: right; min-width: 3.5em; }
119
+ .csv-table thead th:first-child { z-index: 2; }
120
+ .csv-table tbody tr:nth-child(even) td { background: #f9fafc; }
121
+ .csv-table tbody tr:hover td { background: #edf3ff; }
122
+ .csv-cell { min-width: 4em; max-width: 32rem; white-space: pre-wrap; overflow-wrap: anywhere; }
123
+ @media (max-width: 640px) { .csv-cell { max-width: 20rem; } }
124
+ @media (prefers-color-scheme: dark) {
125
+ .csv-summary { color: #a6a9ad; }
126
+ .csv-table-scroll { background: #202124; }
127
+ .csv-table th, .csv-table td { border-color: #55585c; }
128
+ .csv-table th { background: #292a2d; }
129
+ .csv-table tbody tr:nth-child(even) td { background: #252629; }
130
+ .csv-table tbody tr:hover td { background: #292f3d; }
131
+ }
132
+ `;