@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
@@ -0,0 +1,13 @@
1
+ // Serialize settings changes and turn starts across every route bound to a task.
2
+ export class SessionOperationQueue {
3
+ private readonly tails = new Map<string, Promise<unknown>>();
4
+
5
+ run<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
6
+ const previous = this.tails.get(sessionId) ?? Promise.resolve();
7
+ const next = previous.catch(() => undefined).then(operation);
8
+ this.tails.set(sessionId, next);
9
+ return next.finally(() => {
10
+ if (this.tails.get(sessionId) === next) this.tails.delete(sessionId);
11
+ });
12
+ }
13
+ }
@@ -29,7 +29,7 @@ export class AcpRuntimeAdapter implements AgentRuntime {
29
29
  private readonly listeners = new Set<(event: RuntimeEvent) => void>();
30
30
  private readonly approvals = new Map<
31
31
  string,
32
- { sessionId: string; resolve: (value: JsonValue) => void; optionIds: Map<ApprovalDecision, string> }
32
+ { sessionId: string; turnId: string; resolve: (value: JsonValue) => void; optionIds: Map<ApprovalDecision, string> }
33
33
  >();
34
34
 
35
35
  constructor(private readonly acp: AcpSessionManager) {}
@@ -59,11 +59,12 @@ export class AcpRuntimeAdapter implements AgentRuntime {
59
59
  optionIds.set("decline", option.optionId);
60
60
  }
61
61
  }
62
- this.approvals.set(requestId, { sessionId: input.localSessionId, resolve, optionIds });
62
+ const turnId = active?.turnId ?? "unknown";
63
+ this.approvals.set(requestId, { sessionId: input.localSessionId, turnId, resolve, optionIds });
63
64
  this.emit({
64
65
  type: "approval_requested",
65
66
  sessionId: input.localSessionId,
66
- turnId: active?.turnId ?? "unknown",
67
+ turnId,
67
68
  request: {
68
69
  id: requestId,
69
70
  title: typeof params.toolCall.title === "string" ? params.toolCall.title : "ACP permission request",
@@ -185,6 +186,7 @@ export class AcpRuntimeAdapter implements AgentRuntime {
185
186
  this.approvals.delete(requestId);
186
187
  const optionId = pending.optionIds.get(decision) ?? pending.optionIds.get(decision === "cancel" ? "decline" : "accept");
187
188
  pending.resolve({ outcome: { outcome: "selected", optionId: optionId ?? decision } });
189
+ this.emit({ type: "approval_resolved", sessionId, turnId: pending.turnId, requestId, decision });
188
190
  }
189
191
 
190
192
  async listModels(): Promise<ModelOption[]> {
@@ -34,6 +34,7 @@ export interface RuntimeGoalUpdate {
34
34
  export type RuntimePrompt = string | {
35
35
  text: string;
36
36
  localImagePaths?: string[];
37
+ collaborationMode?: "default";
37
38
  };
38
39
 
39
40
  export interface PlanStep {
@@ -60,6 +61,7 @@ export interface ToolState {
60
61
  export interface ApprovalRequest {
61
62
  id: string;
62
63
  kind?: "mode_change";
64
+ automatic?: boolean;
63
65
  title: string;
64
66
  command?: string;
65
67
  reason?: string;
@@ -108,8 +110,8 @@ export type AgentEvent =
108
110
  | { type: "tool_updated"; sessionId: string; turnId: string; tool: ToolState }
109
111
  | { type: "tool_output_delta"; sessionId: string; turnId: string; toolId: string; delta: string }
110
112
  | { type: "approval_requested"; sessionId: string; turnId: string; request: ApprovalRequest }
111
- | { type: "approval_resolved"; sessionId: string; turnId: string; requestId: string; decision: ApprovalDecision }
112
- | { type: "turn_completed"; sessionId: string; turnId: string; finalResponse: string; durationMs?: number }
113
+ | { type: "approval_resolved"; sessionId: string; turnId: string; requestId: string; decision: ApprovalDecision; automatic?: boolean }
114
+ | { type: "turn_completed"; sessionId: string; turnId: string; finalResponse: string; durationMs?: number; approvedPlan?: boolean }
113
115
  | { type: "turn_cancelled"; sessionId: string; turnId: string }
114
116
  | { type: "turn_failed"; sessionId: string; turnId: string; message: string };
115
117
 
@@ -284,6 +286,9 @@ export interface AgentRuntime {
284
286
  getThreadWriterLockPath?(remoteSessionId: string): string | undefined;
285
287
  createSession(input: CreateRuntimeSessionInput): Promise<RuntimeSession>;
286
288
  resumeSession(input: ResumeRuntimeSessionInput): Promise<RuntimeSession>;
289
+ // Hydrate the saved same-thread snapshot without contacting the old Provider.
290
+ // The runtime must verify a resume before any subsequent turn or remote operation.
291
+ prepareSessionForSettings?(input: ResumeRuntimeSessionInput): Promise<RuntimeSession>;
287
292
  forkSession?(input: ForkRuntimeSessionInput): Promise<RuntimeSession>;
288
293
  getSession(localSessionId: string): RuntimeSession | undefined;
289
294
  // Drop local monitoring only; do not interrupt, unsubscribe, or archive remote work.
@@ -302,7 +307,7 @@ export interface AgentRuntime {
302
307
  steerTurn(sessionId: string, turnId: string, prompt: RuntimePrompt): Promise<void>;
303
308
  cancelTurn(sessionId: string, turnId: string): Promise<void>;
304
309
  interruptRemoteTurn?(remoteSessionId: string, turnId: string): Promise<void>;
305
- archiveRemoteSession?(remoteSessionId: string): Promise<void>;
310
+ archiveRemoteSession?(remoteSessionId: string, options?: { allowUnmaterialized?: boolean }): Promise<void>;
306
311
  closeSession(sessionId: string): Promise<void>;
307
312
  setTitle?(sessionId: string, title: string): Promise<void>;
308
313
  getGoal?(sessionId: string): Promise<RuntimeGoal | undefined>;
@@ -6,7 +6,17 @@ import path from "node:path";
6
6
  import Database from "better-sqlite3";
7
7
  import { migrations } from "./migrations.js";
8
8
  import type { UpdateCheckSchedule, UpdateNotice } from "../updates/types.js";
9
- import type { ConversationTurn } from "../runtime/types.js";
9
+ import type { ConversationTurn, RuntimeExecutionSettings } from "../runtime/types.js";
10
+
11
+ export interface PendingProviderSettings {
12
+ localSessionId: string;
13
+ contextKey: string;
14
+ settings: RuntimeExecutionSettings;
15
+ messageId?: string;
16
+ replyMessageId?: string;
17
+ activeTab?: "provider" | "model" | "thinking" | "permission" | "agent";
18
+ error?: string;
19
+ }
10
20
 
11
21
  export type SessionStatus = "starting" | "ready" | "running" | "closed" | "failed";
12
22
 
@@ -300,6 +310,27 @@ export class StateStore {
300
310
  return row ? JSON.parse(row.value_json) as UpdateCheckSchedule : undefined;
301
311
  }
302
312
 
313
+ getPendingProviderSettings(localSessionId: string): PendingProviderSettings | undefined {
314
+ const row = this.db.prepare("SELECT value_json FROM pending_provider_settings WHERE local_session_id = ?")
315
+ .get(localSessionId) as { value_json: string } | undefined;
316
+ return row ? JSON.parse(row.value_json) as PendingProviderSettings : undefined;
317
+ }
318
+
319
+ savePendingProviderSettings(pending: PendingProviderSettings): void {
320
+ this.db.prepare(`INSERT INTO pending_provider_settings (local_session_id, value_json) VALUES (?, ?)
321
+ ON CONFLICT(local_session_id) DO UPDATE SET value_json = excluded.value_json`)
322
+ .run(pending.localSessionId, JSON.stringify(pending));
323
+ }
324
+
325
+ deletePendingProviderSettings(localSessionId: string): void {
326
+ this.db.prepare("DELETE FROM pending_provider_settings WHERE local_session_id = ?").run(localSessionId);
327
+ }
328
+
329
+ listPendingProviderSettings(): PendingProviderSettings[] {
330
+ const rows = this.db.prepare("SELECT value_json FROM pending_provider_settings").all() as Array<{ value_json: string }>;
331
+ return rows.map((row) => JSON.parse(row.value_json) as PendingProviderSettings);
332
+ }
333
+
303
334
  saveUpdateCheckSchedule(schedule: UpdateCheckSchedule): void {
304
335
  this.db.prepare(`INSERT INTO update_check_schedule (id, value_json) VALUES (1, ?)
305
336
  ON CONFLICT(id) DO UPDATE SET value_json = excluded.value_json`).run(JSON.stringify(schedule));
@@ -684,6 +715,11 @@ export class StateStore {
684
715
  return row ? mapTurnAttempt(row) : undefined;
685
716
  }
686
717
 
718
+ hasTurnAttemptsForSession(localSessionId: string): boolean {
719
+ return Boolean(this.db.prepare("SELECT 1 FROM turn_attempts WHERE local_session_id = ? LIMIT 1")
720
+ .get(localSessionId));
721
+ }
722
+
687
723
  findIncompleteTurnAttemptForSession(localSessionId: string): TurnAttemptRecord | undefined {
688
724
  const row = this.db.prepare(`
689
725
  SELECT * FROM turn_attempts
@@ -982,6 +1018,7 @@ export class StateStore {
982
1018
  archiveSession(localSessionId: string): void {
983
1019
  const now = new Date().toISOString();
984
1020
  const archive = this.db.transaction(() => {
1021
+ this.deletePendingProviderSettings(localSessionId);
985
1022
  this.db.prepare(`
986
1023
  UPDATE sessions
987
1024
  SET status = 'closed', updated_at = ?
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash, type Hash } from "node:crypto";
4
4
  import type { AgentEvent, ToolState } from "../runtime/types.js";
5
- import { reduceTurnEvent } from "../presentation/TurnStateReducer.js";
5
+ import { approvalActivityId, continueTurnView, reduceTurnEvent } from "../presentation/TurnStateReducer.js";
6
6
  import type { TurnViewState } from "../presentation/turnViewTypes.js";
7
7
 
8
8
  type TextChange = { key: string; field: string; append: boolean; text: string };
@@ -10,6 +10,7 @@ export type PreviewRecord =
10
10
  | { kind: "seed"; state: TurnViewState }
11
11
  | { kind: "state"; patch: Partial<TurnViewState> }
12
12
  | { kind: "user"; id: string; text: string; images?: string[] }
13
+ | { kind: "handoff"; turnId: string; startedAt: number }
13
14
  | { kind: "event"; event: AgentEvent; texts: TextChange[] };
14
15
  type Stream = { length: number; hash: Hash };
15
16
  type Writer = { seeded?: boolean; pending: string[]; bytes: number; dirtyDisk?: boolean; streams: Map<string, Stream>; timings: Map<string, number>; timer?: NodeJS.Timeout; error?: unknown };
@@ -29,9 +30,10 @@ export class TurnPreviewJournal {
29
30
  has(turnId: string): boolean { return this.writers.has(turnId) || fs.existsSync(this.file(turnId)); }
30
31
 
31
32
  seed(state: TurnViewState): void {
32
- const writer = this.writer(state.turnId);
33
+ const turnId = state.previewTurnId ?? state.turnId;
34
+ const writer = this.writer(turnId);
33
35
  if (writer.seeded) return;
34
- this.append(state.turnId, { kind: "seed", state: { ...state, previewJournal: true } });
36
+ this.append(turnId, { kind: "seed", state: { ...state, previewJournal: true } });
35
37
  writer.seeded = true;
36
38
  for (const change of seedTexts(state)) this.track(writer, change);
37
39
  }
@@ -42,15 +44,19 @@ export class TurnPreviewJournal {
42
44
  const { activities, reasoningItems, fullToolOutputs, fullToolErrors, toolStatuses,
43
45
  completedTools, failedTools, activeTool, assistantText, progressText, plan, fileSummary,
44
46
  activitiesTruncated, previewCursor, ...patch } = state;
45
- this.append(state.turnId, { kind: "state", patch });
47
+ this.append(state.previewTurnId ?? state.turnId, { kind: "state", patch });
46
48
  }
47
49
 
48
50
  user(turnId: string, id: string, text: string, images?: string[]): void {
49
51
  this.append(turnId, { kind: "user", id, text, images });
50
52
  }
51
53
 
52
- event(event: AgentEvent): void {
53
- const writer = this.writer(event.turnId);
54
+ handoff(journalId: string, turnId: string, startedAt: number): void {
55
+ this.append(journalId, { kind: "handoff", turnId, startedAt });
56
+ }
57
+
58
+ event(event: AgentEvent, journalId = event.turnId): void {
59
+ const writer = this.writer(journalId);
54
60
  const texts: TextChange[] = [];
55
61
  const copy = { ...event } as AgentEvent;
56
62
  const capture = (key: string, field: string, text: string, append = false) => {
@@ -96,7 +102,7 @@ export class TurnPreviewJournal {
96
102
  });
97
103
  }
98
104
  }
99
- this.append(event.turnId, { kind: "event", event: copy, texts });
105
+ this.append(journalId, { kind: "event", event: copy, texts });
100
106
  }
101
107
 
102
108
  flush(turnId?: string, durable = false): void {
@@ -320,6 +326,10 @@ export class TurnPreviewProjection {
320
326
  return;
321
327
  }
322
328
  if (!this.state) return;
329
+ if (record.kind === "handoff") {
330
+ this.state = continueTurnView(this.state, record.turnId, record.startedAt);
331
+ return;
332
+ }
323
333
  if (record.kind === "state") {
324
334
  this.state = { ...this.state, ...record.patch, historyDetail: "full", previewJournal: true };
325
335
  return;
@@ -376,6 +386,8 @@ export class TurnPreviewProjection {
376
386
  if (event.type === "context_compaction") {
377
387
  for (const a of this.state.activities) if (!old.activities.includes(a)) this.changed.add(a.id);
378
388
  }
389
+ if (event.type === "approval_resolved") this.changed.add(approvalActivityId(event.turnId, event.requestId));
390
+ if (old.approval && !this.state.approval) this.changed.add(approvalActivityId(event.turnId, old.approval.id));
379
391
  if (this.details !== true && this.state.reasoningItems) this.state.reasoningItems = this.state.reasoningItems.map((r) => ({ ...r,
380
392
  summary: r.summary.map((t) => this.wantsFull(`reasoning:${r.itemId}:`) ? t : t.slice(0, 6000)), content: r.content.map((t) => this.wantsFull(`reasoning:${r.itemId}:`) ? t : t.slice(0, 6000)) }));
381
393
  this.state.activitiesTruncated = false;
@@ -217,4 +217,10 @@ export const migrations = [
217
217
  PRIMARY KEY (local_session_id, remote_session_id, context_key, message_id)
218
218
  );
219
219
  `,
220
+ `
221
+ CREATE TABLE IF NOT EXISTS pending_provider_settings (
222
+ local_session_id TEXT PRIMARY KEY,
223
+ value_json TEXT NOT NULL
224
+ );
225
+ `,
220
226
  ] as const;
@@ -1,7 +1,7 @@
1
1
  import type { Logger } from "pino";
2
2
  import { CardRenderer } from "../feishu/CardRenderer.js";
3
3
  import { isThreadContextKey } from "../feishu/contextKey.js";
4
- import type { FeishuOutbound } from "../feishu/types.js";
4
+ import type { CardAction, FeishuOutbound } from "../feishu/types.js";
5
5
  import type { StateStore } from "../state/StateStore.js";
6
6
  import type { RestartNotificationTarget, SafeRestartStatus } from "./SafeRestartScheduler.js";
7
7
 
@@ -9,11 +9,15 @@ const DEFAULT_INITIAL_CARD_DELAY_MS = 3_000;
9
9
 
10
10
  export interface SafeRestartNotifierOptions {
11
11
  initialCardDelayMs?: number;
12
+ ownerId?(): string | undefined;
13
+ isCurrentSchedule?(scheduleId: number): boolean;
14
+ stopTerminal?(terminalId: string, userId: string): Promise<boolean>;
12
15
  }
13
16
 
14
17
  export class SafeRestartNotifier {
15
18
  private readonly messageIds = new Map<string, string>();
16
19
  private readonly cardHashes = new Map<string, string>();
20
+ private readonly cardTerminalIds = new Map<string, Set<string>>();
17
21
  private readonly notificationTargets = new Map<string, RestartNotificationTarget>();
18
22
  private activeScheduleId?: number;
19
23
  private terminalScheduleId?: number;
@@ -55,6 +59,7 @@ export class SafeRestartNotifier {
55
59
  this.activeScheduleId = status.scheduleId;
56
60
  this.messageIds.clear();
57
61
  this.cardHashes.clear();
62
+ this.cardTerminalIds.clear();
58
63
  this.notificationTargets.clear();
59
64
  this.clearInitialCardTimer();
60
65
  this.pendingInitialStatus = status;
@@ -90,6 +95,25 @@ export class SafeRestartNotifier {
90
95
  return [...this.notificationTargets.values()].map((target) => ({ ...target }));
91
96
  }
92
97
 
98
+ async stopTerminal(action: CardAction): Promise<string> {
99
+ const ownerId = this.options.ownerId?.();
100
+ if (!ownerId || action.userId !== ownerId) throw new Error("仅 AgentBot 所有者可结束终端。");
101
+ const scheduleId = Number(action.value.scheduleId);
102
+ const terminalId = typeof action.value.terminalId === "string" ? action.value.terminalId : "";
103
+ if (!Number.isSafeInteger(scheduleId) || scheduleId <= 0 || !/^[a-f0-9]{32}$/u.test(terminalId)) {
104
+ throw new Error("安全重启卡片无效,请使用最新的状态卡片。");
105
+ }
106
+ if (scheduleId !== this.activeScheduleId || !this.options.isCurrentSchedule?.(scheduleId)
107
+ || !this.lastStatus || ["cancelled", "restarting"].includes(this.lastStatus.phase)
108
+ || !action.messageId || this.messageIds.get(action.contextKey) !== action.messageId
109
+ || !this.cardTerminalIds.get(action.contextKey)?.has(terminalId)) {
110
+ throw new Error("该安全重启卡片或终端操作已失效,请查看最新状态卡片。");
111
+ }
112
+ if (!this.options.stopTerminal) throw new Error("网页终端未启用。");
113
+ const stopped = await this.options.stopTerminal(terminalId, ownerId);
114
+ return stopped ? "已请求结束对应终端,安全重启将继续等待其余阻塞项。" : "该终端已结束或正在关闭,无需重复操作。";
115
+ }
116
+
93
117
  private publishPendingInitialStatus(): Promise<void> {
94
118
  this.clearInitialCardTimer();
95
119
  const status = this.pendingInitialStatus;
@@ -132,6 +156,9 @@ export class SafeRestartNotifier {
132
156
  phase: status.phase,
133
157
  remainingMs: status.remainingMs,
134
158
  pendingFinalDeliveries: status.activity.pendingFinalDeliveries,
159
+ activeTerminals: status.activity.activeTerminals,
160
+ terminalBlockers: status.activity.terminalBlockers,
161
+ contextKey,
135
162
  waitingTasks,
136
163
  });
137
164
  const cardHash = JSON.stringify(card);
@@ -140,12 +167,17 @@ export class SafeRestartNotifier {
140
167
  if (this.cardHashes.get(contextKey) === cardHash) return;
141
168
  try {
142
169
  await this.outbound.updateInteractiveCard(messageId, card);
143
- this.cardHashes.set(contextKey, cardHash);
170
+ if (this.activeScheduleId === status.scheduleId && this.messageIds.get(contextKey) === messageId) {
171
+ this.cardHashes.set(contextKey, cardHash);
172
+ this.cardTerminalIds.set(contextKey, new Set((status.activity.terminalBlockers ?? []).map((terminal) => terminal.terminalId)));
173
+ }
144
174
  return;
145
175
  } catch (error) {
146
176
  this.logger.warn({ error, contextKey, messageId }, "Failed to update safe restart status card; sending a replacement.");
177
+ if (this.activeScheduleId !== status.scheduleId) return;
147
178
  this.messageIds.delete(contextKey);
148
179
  this.cardHashes.delete(contextKey);
180
+ this.cardTerminalIds.delete(contextKey);
149
181
  }
150
182
  }
151
183
  try {
@@ -169,6 +201,7 @@ export class SafeRestartNotifier {
169
201
  if (this.activeScheduleId === status.scheduleId) {
170
202
  this.messageIds.set(contextKey, created);
171
203
  this.cardHashes.set(contextKey, cardHash);
204
+ this.cardTerminalIds.set(contextKey, new Set((status.activity.terminalBlockers ?? []).map((terminal) => terminal.terminalId)));
172
205
  } else {
173
206
  await this.stopCard(created, status, reason ?? status.reason);
174
207
  }
@@ -1,4 +1,8 @@
1
+ import type { ActiveTerminal } from "../terminal/TerminalServer.js";
2
+
1
3
  export interface ServerActivityState {
4
+ activeTerminals?: number;
5
+ terminalBlockers?: ActiveTerminal[];
2
6
  runningSessions: number;
3
7
  pendingFinalDeliveries: number;
4
8
  latestInboundAt?: string;
@@ -49,6 +53,7 @@ export class SafeRestartScheduler {
49
53
  private idleInboundAt?: string;
50
54
  private scheduleId = 0;
51
55
  private restartImmediatelyIfIdle = false;
56
+ private restartingNow = false;
52
57
 
53
58
  constructor(private readonly options: SafeRestartSchedulerOptions) {}
54
59
 
@@ -56,10 +61,18 @@ export class SafeRestartScheduler {
56
61
  return Boolean(this.reason);
57
62
  }
58
63
 
64
+ get restarting(): boolean {
65
+ return this.restartingNow;
66
+ }
67
+
59
68
  get pendingReason(): string | undefined {
60
69
  return this.reason;
61
70
  }
62
71
 
72
+ isCurrentSchedule(scheduleId: number): boolean {
73
+ return this.scheduled && !this.restartingNow && this.scheduleId === scheduleId;
74
+ }
75
+
63
76
  schedule(
64
77
  reason: string,
65
78
  notificationTarget?: RestartNotificationTarget,
@@ -131,14 +144,14 @@ export class SafeRestartScheduler {
131
144
  if (!reason) return;
132
145
  const scheduleId = this.scheduleId;
133
146
  const state = this.options.readActivity();
134
- if (state.runningSessions > 0 || state.pendingFinalDeliveries > 0) {
147
+ if (hasActiveWork(state) || state.pendingFinalDeliveries > 0) {
135
148
  this.idleSince = undefined;
136
149
  this.idleInboundAt = undefined;
137
150
  await this.emitStatus({
138
151
  scheduleId,
139
152
  reason,
140
153
  notificationTargets: this.notificationTargetsSnapshot(),
141
- phase: state.runningSessions > 0 ? "waiting_tasks" : "waiting_delivery",
154
+ phase: hasActiveWork(state) ? "waiting_tasks" : "waiting_delivery",
142
155
  activity: state,
143
156
  });
144
157
  return;
@@ -176,18 +189,18 @@ export class SafeRestartScheduler {
176
189
  }
177
190
  const confirmed = this.options.readActivity();
178
191
  if (
179
- confirmed.runningSessions > 0
192
+ hasActiveWork(confirmed)
180
193
  || confirmed.pendingFinalDeliveries > 0
181
194
  || (confirmed.latestInboundAt ?? "") !== this.idleInboundAt
182
195
  ) {
183
196
  this.idleSince = undefined;
184
197
  this.idleInboundAt = undefined;
185
- if (confirmed.runningSessions > 0 || confirmed.pendingFinalDeliveries > 0) {
198
+ if (hasActiveWork(confirmed) || confirmed.pendingFinalDeliveries > 0) {
186
199
  await this.emitStatus({
187
200
  scheduleId,
188
201
  reason,
189
202
  notificationTargets: this.notificationTargetsSnapshot(),
190
- phase: confirmed.runningSessions > 0 ? "waiting_tasks" : "waiting_delivery",
203
+ phase: hasActiveWork(confirmed) ? "waiting_tasks" : "waiting_delivery",
191
204
  activity: confirmed,
192
205
  });
193
206
  } else {
@@ -206,6 +219,7 @@ export class SafeRestartScheduler {
206
219
  }
207
220
 
208
221
  private async restart(reason: string, scheduleId: number, activity: ServerActivityState): Promise<void> {
222
+ this.restartingNow = true;
209
223
  const notificationTargets = this.notificationTargetsSnapshot();
210
224
  this.clear();
211
225
  await this.emitStatus({
@@ -216,7 +230,8 @@ export class SafeRestartScheduler {
216
230
  activity,
217
231
  remainingMs: 0,
218
232
  });
219
- await this.options.onReady(reason, notificationTargets);
233
+ try { await this.options.onReady(reason, notificationTargets); }
234
+ finally { this.restartingNow = false; }
220
235
  }
221
236
 
222
237
  private requestPoll(): void {
@@ -269,6 +284,10 @@ export class SafeRestartScheduler {
269
284
  }
270
285
  }
271
286
 
287
+ function hasActiveWork(state: ServerActivityState): boolean {
288
+ return state.runningSessions > 0 || (state.activeTerminals ?? 0) > 0;
289
+ }
290
+
272
291
  function normalizedNotificationTarget(
273
292
  value: RestartNotificationTarget | undefined,
274
293
  reason: string,
@@ -0,0 +1,18 @@
1
+ import { CardRenderer } from "../feishu/CardRenderer.js";
2
+ import { isThreadContextKey } from "../feishu/contextKey.js";
3
+ import type { FeishuOutbound } from "../feishu/types.js";
4
+ import type { TerminalCardView } from "./TerminalServer.js";
5
+
6
+ export async function sendTerminalAuthorization(
7
+ outbound: Pick<FeishuOutbound, "sendInteractiveCard" | "replyInteractiveCard">,
8
+ view: TerminalCardView,
9
+ ): Promise<string | undefined> {
10
+ const card = new CardRenderer().renderTerminalAuthorization(view);
11
+ if (isThreadContextKey(view.contextKey)) {
12
+ if (!outbound.replyInteractiveCard || !view.commandCardMessageId) {
13
+ throw new Error("无法向原话题发送终端授权卡,请重新打开命令卡片。");
14
+ }
15
+ return outbound.replyInteractiveCard(view.contextKey, { messageId: view.commandCardMessageId, replyInThread: true }, card);
16
+ }
17
+ return outbound.sendInteractiveCard(view.contextKey, card);
18
+ }
@@ -0,0 +1,74 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ export interface TerminalCommandState {
4
+ status: "unknown" | "idle" | "running";
5
+ command?: string;
6
+ source?: "shell" | "process";
7
+ }
8
+
9
+ export class TerminalCommandTracker {
10
+ readonly nonce = randomBytes(16).toString("hex");
11
+ private readonly prefix = String.fromCharCode(27) + "]777;agentbot;" + this.nonce + ";";
12
+ private pending = "";
13
+ private current: TerminalCommandState = { status: "unknown" };
14
+
15
+ snapshot(): TerminalCommandState { return { ...this.current }; }
16
+
17
+ consume(data: string): string {
18
+ this.pending += data;
19
+ let output = "";
20
+ while (this.pending) {
21
+ const start = this.pending.indexOf(this.prefix);
22
+ if (start < 0) {
23
+ let retained = Math.min(this.prefix.length - 1, this.pending.length);
24
+ while (retained && !this.prefix.startsWith(this.pending.slice(-retained))) retained--;
25
+ output += this.pending.slice(0, this.pending.length - retained);
26
+ this.pending = this.pending.slice(this.pending.length - retained);
27
+ break;
28
+ }
29
+ output += this.pending.slice(0, start); this.pending = this.pending.slice(start);
30
+ const end = this.pending.indexOf("\u0007", this.prefix.length);
31
+ if (end < 0 && this.pending.length <= 32_768) break;
32
+ if (end < 0 || end > 32_768) {
33
+ output += this.pending[0]; this.pending = this.pending.slice(1); continue;
34
+ }
35
+ const payload = this.pending.slice(this.prefix.length, end);
36
+ if (payload === "idle") this.current = { status: "idle", source: "shell" };
37
+ else if (payload.startsWith("command;") && /^[A-Za-z0-9+/]*={0,2}$/u.test(payload.slice(8))) {
38
+ const command = Buffer.from(payload.slice(8), "base64").toString("utf8")
39
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/gu, "").trim();
40
+ this.current = { status: "running", source: "shell", command: command.slice(0, 2_000) || undefined };
41
+ } else this.current = { status: "unknown" };
42
+ this.pending = this.pending.slice(end + 1);
43
+ }
44
+ return output;
45
+ }
46
+ }
47
+
48
+ export function powerShellTrackingScript(nonce: string): string {
49
+ if (!/^[a-f0-9]{32}$/u.test(nonce)) throw new Error("Invalid terminal tracking nonce.");
50
+ // Wrap the accepted line, not the history hook (which skips duplicate commands).
51
+ // Leave editing/history policy intact and call the original prompt/readline before altering $?.
52
+ return [
53
+ "$global:__AgentBotPrompt = $function:prompt",
54
+ "$global:__AgentBotMarker = [char]27 + ']777;agentbot;" + nonce + ";'",
55
+ "try {",
56
+ " Import-Module PSReadLine -ErrorAction Stop",
57
+ " $global:__AgentBotReadLine = $function:PSConsoleHostReadLine",
58
+ " if ($null -eq $global:__AgentBotReadLine) { throw 'PSConsoleHostReadLine unavailable' }",
59
+ " function global:PSConsoleHostReadLine {",
60
+ " $line = & $global:__AgentBotReadLine",
61
+ " if (-not [string]::IsNullOrWhiteSpace($line)) {",
62
+ " $text = if ($line.Length -gt 2000) { $line.Substring(0, 2000) } else { $line }",
63
+ " [Console]::Write($global:__AgentBotMarker + 'command;' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($text)) + [char]7)",
64
+ " }",
65
+ " return $line",
66
+ " }",
67
+ " function global:prompt {",
68
+ " $text = & $global:__AgentBotPrompt",
69
+ " [Console]::Write($global:__AgentBotMarker + 'idle' + [char]7)",
70
+ " return $text",
71
+ " }",
72
+ "} catch { [Console]::Write($global:__AgentBotMarker + 'unknown' + [char]7) }",
73
+ ].join("\n");
74
+ }
@@ -0,0 +1,61 @@
1
+ import { createHash, createHmac, randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+
6
+ const record = z.object({ token: z.string().regex(/^[A-Za-z0-9_-]{8}$/u), jobId: z.string().regex(/^[a-f0-9-]{36}$/u) });
7
+
8
+ export class TerminalLinks {
9
+ constructor(private readonly directory: string, private readonly secret: Buffer) {
10
+ fs.mkdirSync(directory, { recursive: true });
11
+ }
12
+
13
+ create(jobId: string): string {
14
+ if (!/^[a-f0-9-]{36}$/u.test(jobId)) throw new Error("Invalid terminal job ID.");
15
+ for (let attempt = 0; attempt < 16; attempt++) {
16
+ const token = createHmac("sha256", this.secret).update(`terminal:open:${jobId}:${attempt}`).digest("base64url").slice(0, 8);
17
+ if (this.resolve(token) === jobId) return token;
18
+ try {
19
+ fs.writeFileSync(this.filePath(token), JSON.stringify({ token, jobId }), { flag: "wx", mode: 0o600 });
20
+ return token;
21
+ } catch (error) {
22
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
23
+ if (this.resolve(token) === jobId) return token;
24
+ }
25
+ }
26
+ throw new Error("Unable to allocate a terminal short link.");
27
+ }
28
+
29
+ createRecovery(): string {
30
+ for (let attempt = 0; attempt < 16; attempt++) {
31
+ const token = randomBytes(6).toString("base64url");
32
+ try {
33
+ // Reserve the shared namespace permanently: an expired recovery must never open a new Shell.
34
+ fs.writeFileSync(this.filePath(token), JSON.stringify({ token, recovery: true }), { flag: "wx", mode: 0o600 });
35
+ return token;
36
+ } catch (error) {
37
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
38
+ }
39
+ }
40
+ throw new Error("Unable to allocate a terminal recovery link.");
41
+ }
42
+
43
+ resolve(token: string): string | undefined {
44
+ if (!/^[A-Za-z0-9_-]{8}$/u.test(token)) return undefined;
45
+ let content: string;
46
+ try { content = fs.readFileSync(this.filePath(token), "utf8"); }
47
+ catch (error) {
48
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
49
+ throw error;
50
+ }
51
+ let value: unknown;
52
+ try { value = JSON.parse(content) as unknown; } catch { return undefined; }
53
+ const parsed = record.safeParse(value);
54
+ return parsed.success && parsed.data.token === token ? parsed.data.jobId : undefined;
55
+ }
56
+
57
+ private filePath(token: string): string {
58
+ // Hash filenames so case-sensitive URL tokens also work on Windows filesystems.
59
+ return path.join(this.directory, `${createHash("sha256").update(token).digest("hex")}.json`);
60
+ }
61
+ }