@keyou007/agent-bot 0.1.24-alpha.5 → 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 (96) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +27 -3
  3. package/README.zh.md +27 -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/CodexRuntime.js +202 -115
  12. package/dist/codex/CodexRuntime.js.map +1 -1
  13. package/dist/codex/ProviderSwitch.js +12 -13
  14. package/dist/codex/ProviderSwitch.js.map +1 -1
  15. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  16. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  17. package/dist/config/loadConfig.js +4 -0
  18. package/dist/config/loadConfig.js.map +1 -1
  19. package/dist/config/schema.js +3 -0
  20. package/dist/config/schema.js.map +1 -1
  21. package/dist/feishu/CardRenderer.js +66 -8
  22. package/dist/feishu/CardRenderer.js.map +1 -1
  23. package/dist/feishu/FeishuConnector.js +1 -0
  24. package/dist/feishu/FeishuConnector.js.map +1 -1
  25. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  26. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  27. package/dist/index.js +43 -6
  28. package/dist/index.js.map +1 -1
  29. package/dist/local-files/LocalFileViewerServer.js +51 -4
  30. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  31. package/dist/local-files/TurnPreviewPage.js +7 -2
  32. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  33. package/dist/presentation/TurnStateReducer.js +41 -6
  34. package/dist/presentation/TurnStateReducer.js.map +1 -1
  35. package/dist/proxy/ProxySessionController.js +615 -214
  36. package/dist/proxy/ProxySessionController.js.map +1 -1
  37. package/dist/proxy/SessionOperationQueue.js +14 -0
  38. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  39. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  40. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  41. package/dist/state/StateStore.js +22 -0
  42. package/dist/state/StateStore.js.map +1 -1
  43. package/dist/state/TurnPreviewJournal.js +19 -7
  44. package/dist/state/TurnPreviewJournal.js.map +1 -1
  45. package/dist/state/migrations.js +6 -0
  46. package/dist/state/migrations.js.map +1 -1
  47. package/dist/supervision/SafeRestartNotifier.js +1 -0
  48. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  49. package/dist/supervision/SafeRestartScheduler.js +19 -6
  50. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  51. package/dist/terminal/TerminalCards.js +13 -0
  52. package/dist/terminal/TerminalCards.js.map +1 -0
  53. package/dist/terminal/TerminalLinks.js +76 -0
  54. package/dist/terminal/TerminalLinks.js.map +1 -0
  55. package/dist/terminal/TerminalPage.js +212 -0
  56. package/dist/terminal/TerminalPage.js.map +1 -0
  57. package/dist/terminal/TerminalProcess.js +71 -0
  58. package/dist/terminal/TerminalProcess.js.map +1 -0
  59. package/dist/terminal/TerminalServer.js +639 -0
  60. package/dist/terminal/TerminalServer.js.map +1 -0
  61. package/docs/technical-reference.md +48 -0
  62. package/docs/technical-reference.zh.md +48 -0
  63. package/npm-shrinkwrap.json +55 -5
  64. package/package.json +11 -4
  65. package/skills/agent-bot/SKILL.md +9 -2
  66. package/src/cli/controlProtocol.ts +2 -1
  67. package/src/cli/serverStatus.ts +1 -0
  68. package/src/cli.ts +6 -1
  69. package/src/codex/CodexRuntime.ts +200 -123
  70. package/src/codex/ProviderSwitch.ts +11 -12
  71. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  72. package/src/config/loadConfig.ts +4 -0
  73. package/src/config/schema.ts +5 -1
  74. package/src/feishu/CardRenderer.ts +81 -8
  75. package/src/feishu/FeishuConnector.ts +1 -0
  76. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  77. package/src/feishu/types.ts +1 -0
  78. package/src/index.ts +44 -7
  79. package/src/local-files/LocalFileViewerServer.ts +48 -4
  80. package/src/local-files/TurnPreviewPage.ts +7 -2
  81. package/src/presentation/TurnStateReducer.ts +46 -7
  82. package/src/presentation/turnViewTypes.ts +5 -0
  83. package/src/proxy/ProxySessionController.ts +625 -211
  84. package/src/proxy/SessionOperationQueue.ts +13 -0
  85. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  86. package/src/runtime/types.ts +8 -3
  87. package/src/state/StateStore.ts +38 -1
  88. package/src/state/TurnPreviewJournal.ts +19 -7
  89. package/src/state/migrations.ts +6 -0
  90. package/src/supervision/SafeRestartNotifier.ts +1 -0
  91. package/src/supervision/SafeRestartScheduler.ts +18 -6
  92. package/src/terminal/TerminalCards.ts +18 -0
  93. package/src/terminal/TerminalLinks.ts +61 -0
  94. package/src/terminal/TerminalPage.ts +213 -0
  95. package/src/terminal/TerminalProcess.ts +71 -0
  96. package/src/terminal/TerminalServer.ts +513 -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;
@@ -132,6 +132,7 @@ export class SafeRestartNotifier {
132
132
  phase: status.phase,
133
133
  remainingMs: status.remainingMs,
134
134
  pendingFinalDeliveries: status.activity.pendingFinalDeliveries,
135
+ activeTerminals: status.activity.activeTerminals,
135
136
  waitingTasks,
136
137
  });
137
138
  const cardHash = JSON.stringify(card);
@@ -1,4 +1,5 @@
1
1
  export interface ServerActivityState {
2
+ activeTerminals?: number;
2
3
  runningSessions: number;
3
4
  pendingFinalDeliveries: number;
4
5
  latestInboundAt?: string;
@@ -49,6 +50,7 @@ export class SafeRestartScheduler {
49
50
  private idleInboundAt?: string;
50
51
  private scheduleId = 0;
51
52
  private restartImmediatelyIfIdle = false;
53
+ private restartingNow = false;
52
54
 
53
55
  constructor(private readonly options: SafeRestartSchedulerOptions) {}
54
56
 
@@ -56,6 +58,10 @@ export class SafeRestartScheduler {
56
58
  return Boolean(this.reason);
57
59
  }
58
60
 
61
+ get restarting(): boolean {
62
+ return this.restartingNow;
63
+ }
64
+
59
65
  get pendingReason(): string | undefined {
60
66
  return this.reason;
61
67
  }
@@ -131,14 +137,14 @@ export class SafeRestartScheduler {
131
137
  if (!reason) return;
132
138
  const scheduleId = this.scheduleId;
133
139
  const state = this.options.readActivity();
134
- if (state.runningSessions > 0 || state.pendingFinalDeliveries > 0) {
140
+ if (hasActiveWork(state) || state.pendingFinalDeliveries > 0) {
135
141
  this.idleSince = undefined;
136
142
  this.idleInboundAt = undefined;
137
143
  await this.emitStatus({
138
144
  scheduleId,
139
145
  reason,
140
146
  notificationTargets: this.notificationTargetsSnapshot(),
141
- phase: state.runningSessions > 0 ? "waiting_tasks" : "waiting_delivery",
147
+ phase: hasActiveWork(state) ? "waiting_tasks" : "waiting_delivery",
142
148
  activity: state,
143
149
  });
144
150
  return;
@@ -176,18 +182,18 @@ export class SafeRestartScheduler {
176
182
  }
177
183
  const confirmed = this.options.readActivity();
178
184
  if (
179
- confirmed.runningSessions > 0
185
+ hasActiveWork(confirmed)
180
186
  || confirmed.pendingFinalDeliveries > 0
181
187
  || (confirmed.latestInboundAt ?? "") !== this.idleInboundAt
182
188
  ) {
183
189
  this.idleSince = undefined;
184
190
  this.idleInboundAt = undefined;
185
- if (confirmed.runningSessions > 0 || confirmed.pendingFinalDeliveries > 0) {
191
+ if (hasActiveWork(confirmed) || confirmed.pendingFinalDeliveries > 0) {
186
192
  await this.emitStatus({
187
193
  scheduleId,
188
194
  reason,
189
195
  notificationTargets: this.notificationTargetsSnapshot(),
190
- phase: confirmed.runningSessions > 0 ? "waiting_tasks" : "waiting_delivery",
196
+ phase: hasActiveWork(confirmed) ? "waiting_tasks" : "waiting_delivery",
191
197
  activity: confirmed,
192
198
  });
193
199
  } else {
@@ -206,6 +212,7 @@ export class SafeRestartScheduler {
206
212
  }
207
213
 
208
214
  private async restart(reason: string, scheduleId: number, activity: ServerActivityState): Promise<void> {
215
+ this.restartingNow = true;
209
216
  const notificationTargets = this.notificationTargetsSnapshot();
210
217
  this.clear();
211
218
  await this.emitStatus({
@@ -216,7 +223,8 @@ export class SafeRestartScheduler {
216
223
  activity,
217
224
  remainingMs: 0,
218
225
  });
219
- await this.options.onReady(reason, notificationTargets);
226
+ try { await this.options.onReady(reason, notificationTargets); }
227
+ finally { this.restartingNow = false; }
220
228
  }
221
229
 
222
230
  private requestPoll(): void {
@@ -269,6 +277,10 @@ export class SafeRestartScheduler {
269
277
  }
270
278
  }
271
279
 
280
+ function hasActiveWork(state: ServerActivityState): boolean {
281
+ return state.runningSessions > 0 || (state.activeTerminals ?? 0) > 0;
282
+ }
283
+
272
284
  function normalizedNotificationTarget(
273
285
  value: RestartNotificationTarget | undefined,
274
286
  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,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
+ }
@@ -0,0 +1,213 @@
1
+ export function terminalPage(base: string, recovery = false): string {
2
+ base = base.replaceAll("&", "&amp;").replaceAll(String.fromCharCode(34), "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
3
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
4
+ <title>AgentBot Terminal</title><link rel="stylesheet" href="${base}/assets/xterm.css"><link rel="stylesheet" href="${base}/assets/terminal.css">
5
+ <script defer src="${base}/assets/xterm.js"></script><script defer src="${base}/assets/fit.js"></script><script defer src="${base}/assets/terminal.js"></script></head>
6
+ <body data-base="${base}" data-recovery="${recovery}"><header><strong>AgentBot <span>Terminal</span></strong><div id="status" role="status">尚未连接</div><button id="connect">请求连接</button><button id="interrupt" disabled>Ctrl+C</button><button id="close" disabled>关闭终端</button></header>
7
+ <section id="welcome"><h1>在浏览器中使用本机终端</h1><p>命令在 AgentBot 所在电脑上执行,拥有其系统用户权限。不会自动执行或接管原卡片中的命令。</p><p>点击请求连接,然后返回命令所在的飞书会话或话题,由 AgentBot 所有者核对验证码并授权。不要批准非本人发起的请求。</p><button id="welcome-connect" type="button">请求连接</button><p id="code"></p></section><div id="terminal"></div>
8
+ <footer id="details">前台连接时不因空闲关闭;后台空闲 30 分钟或网页断开 3 分钟后自动关闭 Shell · 成功重连取消断开计时并重置空闲计时 · 可从原会话的终端管理卡打开或关闭终端</footer></body></html>`;
9
+ }
10
+
11
+ export const TERMINAL_CSS = `
12
+ #welcome-connect{padding:10px 20px;font:inherit}
13
+ :root{color-scheme:dark;font:14px system-ui,sans-serif;background:#10141b;color:#dbe4f1}*{box-sizing:border-box}body{margin:0;height:100dvh;display:flex;flex-direction:column}header{display:flex;gap:12px;align-items:center;padding:12px 18px;background:#191f2b;border-bottom:1px solid #303a4d;flex-wrap:wrap}header strong{font-size:17px}header strong span{font-weight:400;color:#9aacc7}#status{flex:1;color:#adbfdb;font-size:13px}button{background:#25334a;border:1px solid #415775;border-radius:7px;padding:8px 12px;color:#e5efff;cursor:pointer}button:hover{background:#304661}button:disabled{opacity:.45;cursor:default}#close{border-color:#86505a}#welcome{margin:40px auto;max-width:780px;padding:0 24px;line-height:1.8}h1{font-size:25px}#code{font-size:24px;letter-spacing:3px;color:#84c1ff}#terminal{flex:1;min-height:0;padding:12px 14px;display:none}.xterm{height:100%}footer{font-size:12px;color:#8f9eb7;padding:9px 18px;border-top:1px solid #293142}body.connected #welcome{display:none}body.connected #terminal{display:block}@media(max-width:650px){header{padding:10px;gap:8px}#status{min-width:100%}footer{padding:7px 10px}#terminal{padding:6px}}
14
+ `;
15
+
16
+ export const TERMINAL_SCRIPT = String.raw`(() => {
17
+ const base = document.body.dataset.base;
18
+ const status = document.getElementById('status');
19
+ const connect = document.getElementById('connect');
20
+ const welcomeConnect = document.getElementById('welcome-connect');
21
+ const close = document.getElementById('close');
22
+ const interrupt = document.getElementById('interrupt');
23
+ const storageKey = 'agentbot-terminal:' + location.pathname;
24
+ const recovery = document.body.dataset.recovery === 'true';
25
+ if (recovery) document.querySelector('#welcome h1').textContent = '恢复已有终端';
26
+ let session, ws, term, observer, retryTimer, socketTimer, checking, visibilityTimer;
27
+ let socketReady = false;
28
+ let epoch = 0, failures = 0, stopped = false, pageHidden = false, offline = false, busy = false;
29
+ try { session = JSON.parse(sessionStorage.getItem(storageKey) || 'null'); } catch {}
30
+ const awake = () => !pageHidden && !document.hidden && !offline;
31
+ const post = async (route, data, controller = new AbortController()) => {
32
+ const timer = setTimeout(() => controller.abort(), 10000);
33
+ try {
34
+ const response = await fetch(base + '/api/' + route, { method:'POST', credentials:'omit', cache:'no-store',
35
+ signal:controller.signal, headers:{'Content-Type':'application/json'}, body:JSON.stringify(data) });
36
+ let value;
37
+ try { value = await response.json(); } catch {
38
+ throw Object.assign(new Error('服务返回了非 JSON 响应'), {httpStatus:response.status});
39
+ }
40
+ if (!response.ok) throw Object.assign(new Error(value.error || '请求失败'), {httpStatus:response.status});
41
+ return value;
42
+ } finally { clearTimeout(timer); }
43
+ };
44
+ function message(error) {
45
+ if (error?.name === 'AbortError') return '请求超时';
46
+ if (!error?.httpStatus && (error?.name === 'TypeError' || error?.message === 'Failed to fetch')) return '网络暂时不可用';
47
+ return error?.message || String(error);
48
+ }
49
+ function buttons(retry = false) {
50
+ for (const button of [connect, welcomeConnect]) {
51
+ button.textContent = session ? '重新连接' : recovery ? '请求恢复连接' : '请求连接';
52
+ button.disabled = busy || (!!session && !retry);
53
+ }
54
+ close.disabled = busy || !session;
55
+ }
56
+ function invalidate() {
57
+ epoch++; clearTimeout(retryTimer); clearTimeout(socketTimer);
58
+ clearTimeout(visibilityTimer); socketReady = false;
59
+ checking?.abort(); checking = undefined;
60
+ const old = ws; ws = undefined; old?.close(); interrupt.disabled = true;
61
+ if (term) term.options.disableStdin = true;
62
+ }
63
+ function forget() {
64
+ session = undefined; sessionStorage.removeItem(storageKey);
65
+ document.getElementById('code').textContent = ''; buttons(true);
66
+ }
67
+ function end(text) { stopped = true; invalidate(); forget(); status.textContent = text; }
68
+ function fail(text) { stopped = true; invalidate(); status.textContent = text; buttons(true); }
69
+ function schedule(delay) {
70
+ clearTimeout(retryTimer);
71
+ if (!stopped && awake() && session) retryTimer = setTimeout(check, delay);
72
+ }
73
+ function retry(stage, error) {
74
+ if (stopped || !session) return;
75
+ const seconds = Math.min(10, Math.pow(2, Math.min(failures++, 4)));
76
+ status.textContent = stage + ':' + message(error) + ',' + seconds + ' 秒后自动重试';
77
+ buttons(true); schedule(seconds * 1000);
78
+ }
79
+ function sendSize(cols, rows) {
80
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({type:'resize',cols:Math.max(2,Math.min(500,cols)),rows:Math.max(2,Math.min(200,rows))}));
81
+ }
82
+ function reportVisibility() {
83
+ clearTimeout(visibilityTimer);
84
+ if (stopped || !socketReady || ws?.readyState !== WebSocket.OPEN) return;
85
+ const foreground = awake();
86
+ ws.send(JSON.stringify({type:'visibility',foreground}));
87
+ if (foreground) visibilityTimer = setTimeout(reportVisibility, 30000);
88
+ }
89
+ function openSocket() {
90
+ if (stopped || !awake() || ws || !session) return;
91
+ const current = session, generation = epoch;
92
+ const url = new URL(base + '/socket', location.origin); url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
93
+ let socket;
94
+ try { socket = new WebSocket(url); } catch (error) { retry('WebSocket 连接失败', error); return; }
95
+ ws = socket; status.textContent = '授权已通过,正在连接终端…'; buttons();
96
+ const valid = () => ws === socket && generation === epoch && session === current && !stopped;
97
+ socketTimer = setTimeout(() => {
98
+ if (!valid()) return;
99
+ invalidate(); retry('WebSocket 连接超时', new Error('请检查网络是否支持 WebSocket'));
100
+ }, 15000);
101
+ socket.onopen = () => { if (valid()) socket.send(JSON.stringify({type:'auth',requestId:current.requestId,browserKey:current.browserKey})); };
102
+ socket.onmessage = (event) => {
103
+ if (!valid()) return;
104
+ try {
105
+ const result = JSON.parse(event.data);
106
+ if (result.type === 'ready') {
107
+ clearTimeout(socketTimer);
108
+ socketReady = true; reportVisibility();
109
+ document.body.classList.add('connected');
110
+ observer?.disconnect(); term?.dispose();
111
+ const terminal = new Terminal({cursorBlink:true,fontSize:14,scrollback:5000,disableStdin:result.replay === true,theme:{background:'#10141b'}});
112
+ term = terminal;
113
+ const active = () => valid() && term === terminal;
114
+ terminal.attachCustomKeyEventHandler((event) => {
115
+ if (event.altKey || event.shiftKey || event.ctrlKey === event.metaKey) return true;
116
+ const key = event.key.toLowerCase();
117
+ // Let the browser dispatch native clipboard events, including on HTTP.
118
+ // xterm handles the selected text and bracketed paste without sending ^C/^V.
119
+ return !(key === 'v' || (key === 'c' && terminal.hasSelection()));
120
+ });
121
+ const fit = new FitAddon.FitAddon(); terminal.loadAddon(fit); terminal.open(document.getElementById('terminal'));
122
+ terminal.onData((data) => {
123
+ if (active() && !terminal.options.disableStdin && socket.readyState === WebSocket.OPEN) for (let i=0; i<data.length; i+=4096) socket.send(JSON.stringify({type:'input',data:data.slice(i,i+4096)}));
124
+ });
125
+ terminal.onResize(({cols,rows}) => { if (active()) sendSize(cols,rows); });
126
+ observer = new ResizeObserver(() => { if (active()) fit.fit(); });
127
+ observer.observe(document.getElementById('terminal'));
128
+ fit.fit(); sendSize(terminal.cols,terminal.rows); failures = 0; interrupt.disabled = true;
129
+ status.textContent = '正在恢复终端画面…';
130
+ const restored = () => {
131
+ if (!active()) return;
132
+ terminal.options.disableStdin = false; terminal.focus();
133
+ status.textContent = '已连接 · ' + result.cwd; interrupt.disabled = false; buttons();
134
+ };
135
+ // xterm parses asynchronously. Keep replay silent until its write callback,
136
+ // before subsequently queued live output is parsed. Startup output is live.
137
+ if (result.output) terminal.write(result.output, restored); else restored();
138
+ } else if (result.type === 'data') term?.write(result.data);
139
+ else if (result.type === 'exit') end(result.message || '终端已结束');
140
+ else if (result.type === 'error') fail((result.stage === 'spawn' ? 'Shell 启动失败:' : '终端连接被拒绝:') + result.message);
141
+ } catch (error) { fail('终端界面初始化或消息处理失败:' + message(error)); }
142
+ };
143
+ socket.onclose = (event) => {
144
+ if (!valid()) return;
145
+ if (event.reason === 'Reconnected from another page') { fail('该终端已在另一页面连接,点击重新连接可恢复。'); return; }
146
+ invalidate(); retry('WebSocket 已断开', new Error('连接关闭' + (event.code ? '(' + event.code + ')' : '')));
147
+ };
148
+ socket.onerror = () => {
149
+ if (!valid()) return;
150
+ invalidate(); retry('WebSocket 连接失败', new Error('请检查网络是否支持 WebSocket'));
151
+ };
152
+ }
153
+ async function check() {
154
+ if (stopped || !awake() || !session || checking || ws || busy) return;
155
+ clearTimeout(retryTimer);
156
+ const current = session, generation = epoch, controller = new AbortController(); checking = controller;
157
+ try {
158
+ const result = await post('status', current, controller);
159
+ if (generation !== epoch || current !== session || stopped || !awake()) return;
160
+ if (result.state === 'approved' || result.state === 'running') openSocket();
161
+ else if (result.state === 'pending') { failures = 0; status.textContent = '等待原会话中的所有者授权'; buttons(); schedule(1500); }
162
+ else end(result.message || '授权已失效,请重新请求连接');
163
+ } catch (error) {
164
+ if (generation !== epoch || current !== session || stopped || !awake()) return;
165
+ if ([400,401,404,410].includes(error.httpStatus)) end('授权状态查询失败:' + message(error) + '。请重新请求连接。');
166
+ else if (error.httpStatus === 403) fail('授权状态查询被拒绝:' + message(error));
167
+ else retry('暂时无法查询授权状态', error);
168
+ } finally { if (checking === controller) checking = undefined; }
169
+ }
170
+ connect.onclick = welcomeConnect.onclick = async () => {
171
+ if (busy) return;
172
+ stopped = false; failures = 0; invalidate();
173
+ if (session) { status.textContent = '正在恢复已有终端授权…'; buttons(); check(); return; }
174
+ busy = true; buttons(); status.textContent = '正在请求飞书授权…';
175
+ try {
176
+ document.body.classList.remove('connected');
177
+ if (!globalThis.crypto?.getRandomValues) throw new Error('浏览器不支持安全随机数,请使用新版浏览器。');
178
+ const browserKey = Array.from(crypto.getRandomValues(new Uint8Array(32)), (n) => n.toString(16).padStart(2,'0')).join('');
179
+ const result = await post('request', {launch:location.pathname,browserKey});
180
+ session = {requestId:result.requestId,browserKey,code:result.code}; sessionStorage.setItem(storageKey,JSON.stringify(session));
181
+ document.getElementById('code').textContent = '验证码:' + result.code;
182
+ status.textContent = awake() ? '等待原会话中的所有者授权' : '返回网页后将自动查询授权状态';
183
+ } catch (error) { status.textContent = '请求授权失败:' + message(error); }
184
+ finally { busy = false; buttons(!session); }
185
+ check();
186
+ };
187
+ interrupt.onclick = () => { if (ws?.readyState === WebSocket.OPEN && !interrupt.disabled) ws.send(JSON.stringify({type:'input',data:String.fromCharCode(3)})); };
188
+ close.onclick = async () => {
189
+ if (busy || !session || !confirm('关闭该终端和 Shell?已脱离终端的后台进程可能继续运行。')) return;
190
+ busy = true; stopped = true; invalidate(); buttons();
191
+ try { await post('close',session); end('终端已关闭'); }
192
+ catch (error) {
193
+ if ([400,401,404,410].includes(error.httpStatus)) end('终端已失效或结束');
194
+ else status.textContent = '关闭请求失败:' + message(error) + ';未确认关闭,请重试';
195
+ } finally { busy = false; buttons(true); }
196
+ };
197
+ function suspend() {
198
+ invalidate();
199
+ if (session && !stopped) { status.textContent = '连接已暂停,返回网页或网络恢复后自动重连'; buttons(true); }
200
+ }
201
+ function resume() {
202
+ if (stopped || !awake() || busy || !session) return;
203
+ clearTimeout(retryTimer); check();
204
+ }
205
+ window.addEventListener('pagehide', () => { pageHidden = true; suspend(); });
206
+ window.addEventListener('pageshow', () => { pageHidden = false; resume(); });
207
+ document.addEventListener('visibilitychange', () => { reportVisibility(); if (document.hidden) { if (!ws) suspend(); } else resume(); });
208
+ window.addEventListener('offline', () => { offline = true; suspend(); });
209
+ window.addEventListener('online', () => { offline = false; resume(); });
210
+ window.addEventListener('focus', resume);
211
+ if (session?.code) document.getElementById('code').textContent = '验证码:' + session.code;
212
+ buttons(); check();
213
+ })();`;