@keyou007/agent-bot 0.1.24-alpha.6 → 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 (39) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +3 -1
  3. package/README.zh.md +3 -1
  4. package/dist/codex/CodexEventMapper.js +1 -0
  5. package/dist/codex/CodexEventMapper.js.map +1 -1
  6. package/dist/codex/CodexRuntime.js +46 -6
  7. package/dist/codex/CodexRuntime.js.map +1 -1
  8. package/dist/codex/TraePlanEntry.js +70 -0
  9. package/dist/codex/TraePlanEntry.js.map +1 -0
  10. package/dist/feishu/CardRenderer.js +27 -1
  11. package/dist/feishu/CardRenderer.js.map +1 -1
  12. package/dist/index.js +16 -3
  13. package/dist/index.js.map +1 -1
  14. package/dist/proxy/ProxySessionController.js +5 -0
  15. package/dist/proxy/ProxySessionController.js.map +1 -1
  16. package/dist/supervision/SafeRestartNotifier.js +32 -1
  17. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  18. package/dist/supervision/SafeRestartScheduler.js +3 -0
  19. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  20. package/dist/terminal/TerminalCommandTracker.js +74 -0
  21. package/dist/terminal/TerminalCommandTracker.js.map +1 -0
  22. package/dist/terminal/TerminalProcess.js +25 -1
  23. package/dist/terminal/TerminalProcess.js.map +1 -1
  24. package/dist/terminal/TerminalServer.js +44 -7
  25. package/dist/terminal/TerminalServer.js.map +1 -1
  26. package/npm-shrinkwrap.json +2 -2
  27. package/package.json +1 -1
  28. package/skills/agent-bot/SKILL.md +3 -1
  29. package/src/codex/CodexEventMapper.ts +1 -0
  30. package/src/codex/CodexRuntime.ts +45 -6
  31. package/src/codex/TraePlanEntry.ts +63 -0
  32. package/src/feishu/CardRenderer.ts +30 -2
  33. package/src/index.ts +14 -3
  34. package/src/proxy/ProxySessionController.ts +4 -0
  35. package/src/supervision/SafeRestartNotifier.ts +34 -2
  36. package/src/supervision/SafeRestartScheduler.ts +7 -0
  37. package/src/terminal/TerminalCommandTracker.ts +74 -0
  38. package/src/terminal/TerminalProcess.ts +24 -2
  39. package/src/terminal/TerminalServer.ts +45 -7
@@ -115,7 +115,7 @@ Feishu group renames update only the group's currently bound task title, not top
115
115
 
116
116
  Fork creation stores source-task and branch-Turn references without synchronizing the full local Turn list. The Feishu Turn card reuses local records and loads only the summary pages needed for the requested page, not the entire history. `task turns` loads the first page if necessary and returns available local records. This does not change the Agent's inherited context. Retry listing Turns after a temporary history-read failure; unsupported summary pagination never falls back to downloading full history.
117
117
 
118
- TraeX plan-mode confirmations follow the task permission mode. With `auto`, Agent Bot automatically accepts Enter Plan Mode and Approve Plan; do not also approve or send a continuation prompt. With `confirm`, `Approve Plan` / `Enter Plan Mode`, `Reject`, and `Cancel Request` require the user's choice. Approval requests and decisions stay in the same thinking card and live Preview, explicitly labeling automatic approvals. If an automatic response fails, the card offers manual confirmation with an error notice; never pretend it succeeded. Approved implementation continues in the same task and card. Explicit discussion-only or no-implementation instructions still apply. Preview cannot approve, cancelling a request does not itself stop the task, and waiting turns still block safe restart. Do not stop or force-restart merely to clear that wait. Missed notifications from older versions are not automatically replayed.
118
+ TraeX plan-mode confirmations follow the task permission mode. With `auto`, Agent Bot automatically accepts Enter Plan Mode and Approve Plan; do not also approve or send a continuation prompt. With `confirm`, `Approve Plan` / `Enter Plan Mode`, `Reject`, and `Cancel Request` require the user's choice. Approval requests and decisions stay in the same thinking card and live Preview, explicitly labeling automatic approvals. If TraeX reports no pending request after automatically entering Plan mode, Agent Bot verifies the matching completed EnterPlanMode call before recording success. Missing requests alone are not proof of approval. Unverified automatic failures still offer manual confirmation with an error notice; never pretend they succeeded. Approved implementation continues in the same task and card. Explicit discussion-only or no-implementation instructions still apply. Preview cannot approve, cancelling a request does not itself stop the task, and waiting turns still block safe restart. Do not stop or force-restart merely to clear that wait. Missed notifications from older versions are not automatically replayed.
119
119
 
120
120
  ## Change Settings
121
121
 
@@ -207,6 +207,8 @@ The safe-restart card has matching `Cancel` and `ForceRestart` actions. Clicking
207
207
 
208
208
  Each displayed running task on that card has a `Stop` action targeting its exact task, even across conversations. It uses normal Agent interruption and leaves the restart scheduled; the card updates as tasks stop. Only stop a task when the user requests it.
209
209
 
210
+ Blocking terminals are listed on the safe-restart card by their eight-character token, directory, activity/connection state, and current command when available. Activity means a connected foreground page, a known running command, or input/output within 30 seconds; inactive Shells still block restart. PowerShell/PSReadLine reports commands; other Shells may report only a foreground process or unknown. The owner-only End Terminal action stops exactly that Shell and waits for its exit without forcing restart. Never end a user's terminal merely to complete your own restart; require their explicit request. Stale/cancelled cards cannot close terminals.
211
+
210
212
  A bare `agentbot server restart` invoked inside an Agent Bot-started Agent returns restart status to the source task conversation. The same command from an ordinary terminal uses the configured user's private chat. Add `--task <task>` only to override either default.
211
213
 
212
214
  Never use `taskkill`, `Stop-Process`, or equivalent commands for routine restart management.
@@ -4,6 +4,7 @@ import type { PlanStep, ToolState } from "../runtime/types.js";
4
4
  const modeChangeRequest = z.object({
5
5
  requestId: z.string().min(1),
6
6
  targetMode: z.enum(["plan", "default"]),
7
+ sourceToolCallId: z.string().min(1).nullish(),
7
8
  reason: z.string(),
8
9
  allowedPrompts: z.array(z.object({ tool: z.string(), prompt: z.string() })).optional(),
9
10
  });
@@ -1,4 +1,5 @@
1
1
  import { statSync } from "node:fs";
2
+ import { setTimeout as delay } from "node:timers/promises";
2
3
  import type { Logger } from "pino";
3
4
  import type {
4
5
  AgentRuntime,
@@ -34,6 +35,7 @@ import { AppServerRequestError } from "./AppServerConnection.js";
34
35
  import { workspaceSandboxFromConfig, workspaceSandboxFromResponse, type TurnSandboxPolicy, type WorkspaceSandboxPolicy } from "./AppServerPermissions.js";
35
36
  import { formatCodexError, mapCodexNotification } from "./CodexEventMapper.js";
36
37
  import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
38
+ import { hasCompletedTraePlanEntry } from "./TraePlanEntry.js";
37
39
  import { detectProjectlessWorkspace } from "./ProjectlessWorkspace.js";
38
40
  import { threadWriterLockPath } from "./ThreadWriterProcess.js";
39
41
  import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, PROVIDER_SWITCH_EMPTY, ProviderSwitchError, providerSwitchFailure } from "./ProviderSwitch.js";
@@ -110,6 +112,7 @@ interface PendingModeChange {
110
112
  turnId: string;
111
113
  requestId: string;
112
114
  targetMode: "plan" | "default";
115
+ sourceToolCallId?: string;
113
116
  responding: boolean;
114
117
  decision?: ApprovalDecision;
115
118
  automatic?: boolean;
@@ -950,11 +953,15 @@ export class CodexRuntime implements AgentRuntime {
950
953
  try {
951
954
  const client = await this.client();
952
955
  if (this.modeChanges.get(requestId) !== modeChange) throw new Error("Approval request is no longer pending.");
953
- await client.request("mode/change/respond", {
954
- threadId: this.requireSession(sessionId).remoteSessionId,
955
- requestId: modeChange.requestId,
956
- decision: decision === "accept" ? "approved" : decision === "decline" ? "rejected" : "cancelled",
957
- }, CONTROL_REQUEST_TIMEOUT_MS);
956
+ try {
957
+ await client.request("mode/change/respond", {
958
+ threadId: this.requireSession(sessionId).remoteSessionId,
959
+ requestId: modeChange.requestId,
960
+ decision: decision === "accept" ? "approved" : decision === "decline" ? "rejected" : "cancelled",
961
+ }, CONTROL_REQUEST_TIMEOUT_MS);
962
+ } catch (error) {
963
+ if (!await this.reconcileCompletedPlanEntry(requestId, modeChange, error)) throw error;
964
+ }
958
965
  if (this.modeChanges.get(requestId) === modeChange) {
959
966
  this.confirmPlanContinuation(modeChange, decision);
960
967
  this.modeChanges.delete(requestId);
@@ -974,6 +981,36 @@ export class CodexRuntime implements AgentRuntime {
974
981
  this.emit({ type: "approval_resolved", sessionId, turnId: pending.turnId, requestId, decision });
975
982
  }
976
983
 
984
+ private async reconcileCompletedPlanEntry(
985
+ requestId: string, pending: PendingModeChange, error: unknown,
986
+ ): Promise<boolean> {
987
+ const session = this.sessions.get(pending.sessionId);
988
+ if (!pending.automatic || pending.decision !== "accept" || pending.targetMode !== "plan"
989
+ || this.provider.getAgentFamily?.() !== "traex" || !session?.rolloutPath
990
+ || !(error instanceof AppServerRequestError) || error.method !== "mode/change/respond" || error.code !== -32600
991
+ || error.serverMessage !== `failed to respond to mode change request: No pending mode change found for request_id: ${pending.requestId}`) {
992
+ return false;
993
+ }
994
+ const toolCallId = pending.sourceToolCallId ?? pending.requestId;
995
+ for (const waitMs of [0, 100, 200]) {
996
+ if (waitMs) await delay(waitMs);
997
+ if (this.modeChanges.get(requestId) !== pending || this.sessions.get(pending.sessionId) !== session
998
+ || session.activeTurnId !== pending.turnId) return false;
999
+ try {
1000
+ if (!await hasCompletedTraePlanEntry(session.rolloutPath, pending.turnId, toolCallId)) continue;
1001
+ if (this.modeChanges.get(requestId) !== pending || this.sessions.get(pending.sessionId) !== session
1002
+ || session.activeTurnId !== pending.turnId) return false;
1003
+ this.logger.info({ sessionId: pending.sessionId, turnId: pending.turnId, requestId, toolCallId },
1004
+ "Verified that TraeX already completed the automatic plan-mode entry.");
1005
+ return true;
1006
+ } catch (verificationError) {
1007
+ this.logger.debug({ error: verificationError, sessionId: pending.sessionId, requestId },
1008
+ "Could not verify TraeX plan-mode entry from the rollout tail.");
1009
+ }
1010
+ }
1011
+ return false;
1012
+ }
1013
+
977
1014
  async listModels(modelProvider?: string, fallbackModel?: string): Promise<ModelOption[]> {
978
1015
  return (await this.listModelsWithStatus(modelProvider, fallbackModel)).models;
979
1016
  }
@@ -1217,6 +1254,7 @@ export class CodexRuntime implements AgentRuntime {
1217
1254
  const pending: PendingModeChange = {
1218
1255
  sessionId, turnId: mapped.turnId, requestId: mapped.requestId,
1219
1256
  targetMode: mapped.targetMode, responding: false,
1257
+ sourceToolCallId: mapped.sourceToolCallId ?? undefined,
1220
1258
  };
1221
1259
  this.modeChanges.set(requestId, pending);
1222
1260
  const automatic = session.permissionMode === "auto" && this.provider.getAgentFamily?.() === "traex";
@@ -1236,8 +1274,9 @@ export class CodexRuntime implements AgentRuntime {
1236
1274
  this.emit({ type: "approval_requested", sessionId, turnId: mapped.turnId, request });
1237
1275
  if (automatic) {
1238
1276
  void this.resolveApproval(sessionId, requestId, "accept", true).catch((error: unknown) => {
1239
- this.logger.warn({ error, sessionId, requestId }, "Failed to automatically approve the TraeX mode change.");
1240
1277
  if (this.modeChanges.get(requestId) !== pending || session.activeTurnId !== mapped.turnId) return;
1278
+ this.logger.warn({ error, sessionId, requestId, turnId: mapped.turnId, targetMode: mapped.targetMode,
1279
+ sourceToolCallId: pending.sourceToolCallId }, "Failed to automatically approve the TraeX mode change.");
1241
1280
  this.emit({ type: "approval_requested", sessionId, turnId: mapped.turnId,
1242
1281
  request: { ...request, automatic: false, reason: ["自动同意失败,请手动确认。", request.reason].filter(Boolean).join("\n\n") } });
1243
1282
  });
@@ -0,0 +1,63 @@
1
+ import { open } from "node:fs/promises";
2
+ import { z } from "zod";
3
+
4
+ const MAX_TAIL_BYTES = 256 * 1024;
5
+ const mutation = z.object({
6
+ type: z.literal("history_mutation"),
7
+ payload: z.object({
8
+ version: z.literal(1),
9
+ turn_id: z.string(),
10
+ operation: z.string(),
11
+ items: z.array(z.object({
12
+ type: z.string(),
13
+ call_id: z.string().optional(),
14
+ name: z.string().optional(),
15
+ output: z.unknown().optional(),
16
+ })).optional(),
17
+ }),
18
+ });
19
+
20
+ // TraeX can finish EnterPlanMode without a mode/changeResolved notification.
21
+ // Read only a bounded tail, and require the exact call and its successful result.
22
+ export async function hasCompletedTraePlanEntry(
23
+ rolloutPath: string, turnId: string, toolCallId: string,
24
+ ): Promise<boolean> {
25
+ const file = await open(rolloutPath, "r");
26
+ try {
27
+ const { size } = await file.stat();
28
+ const start = Math.max(0, size - MAX_TAIL_BYTES);
29
+ const buffer = Buffer.alloc(size - start);
30
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, start);
31
+ const text = buffer.subarray(0, bytesRead).toString("utf8");
32
+ const lines = text.split("\n");
33
+ if (start > 0) lines.shift();
34
+ lines.pop(); // Ignore an uncommitted/partial final record.
35
+ let entered = false;
36
+ let matchedCall = false;
37
+ for (const line of lines) {
38
+ let value: unknown;
39
+ try { value = JSON.parse(line); } catch { continue; }
40
+ const parsed = mutation.safeParse(value);
41
+ if (!parsed.success || parsed.data.payload.turn_id !== turnId) continue;
42
+ const payload = parsed.data.payload;
43
+ if (payload.operation !== "append") {
44
+ matchedCall = false;
45
+ entered = false;
46
+ continue;
47
+ }
48
+ for (const item of payload.items ?? []) {
49
+ if (item.call_id !== toolCallId) continue;
50
+ if (item.type === "function_call") {
51
+ matchedCall = item.name === "EnterPlanMode";
52
+ entered = false;
53
+ } else if (item.type === "function_call_output") {
54
+ entered = matchedCall && typeof item.output === "string"
55
+ && item.output.startsWith("Entered plan mode.");
56
+ }
57
+ }
58
+ }
59
+ return entered;
60
+ } finally {
61
+ await file.close();
62
+ }
63
+ }
@@ -15,7 +15,7 @@ import type {
15
15
  import type { TurnActivity, TurnViewState, TurnViewStatus } from "../presentation/turnViewTypes.js";
16
16
  import { truncateMiddle, truncateText } from "../utils/markdown.js";
17
17
  import type { UpdateNotice } from "../updates/types.js";
18
- import type { TerminalCardView } from "../terminal/TerminalServer.js";
18
+ import type { ActiveTerminal, TerminalCardView } from "../terminal/TerminalServer.js";
19
19
  import { localCardImage } from "./LocalCardImage.js";
20
20
 
21
21
  export interface StartupStatusView {
@@ -61,6 +61,8 @@ export interface InitializationWelcomeView {
61
61
 
62
62
  export interface SafeRestartStatusView {
63
63
  activeTerminals?: number;
64
+ terminalBlockers?: ActiveTerminal[];
65
+ contextKey?: string;
64
66
  scheduleId: number;
65
67
  reason: string;
66
68
  phase: "waiting_tasks" | "waiting_delivery" | "countdown" | "restarting" | "cancelled" | "superseded";
@@ -1069,9 +1071,35 @@ export class CardRenderer {
1069
1071
  `**重启原因**:${inlineCode(view.reason)}`,
1070
1072
  `**重启倒计时**:${countdown}`,
1071
1073
  `**待投递结果**:${view.pendingFinalDeliveries} 条`,
1072
- ...(view.activeTerminals ? [`**活动终端**:${view.activeTerminals} 个,请在网页或原会话的终端管理卡中关闭。ForceRestart 会中断终端。`] : []),
1074
+ ...(view.activeTerminals ? [`**活动终端**:${view.activeTerminals} 个。结束终端会中断其中的命令;ForceRestart 会中断所有终端。`] : []),
1073
1075
  ];
1074
1076
  const elements: Record<string, unknown>[] = [markdown(lines.join("\n"))];
1077
+ if (view.terminalBlockers?.length) {
1078
+ elements.push({ tag: "hr" }, markdown(`**阻止重启的终端(${view.terminalBlockers.length})**`));
1079
+ for (const terminal of view.terminalBlockers) {
1080
+ const command = terminal.state === "closing" ? "正在结束,等待 Shell 退出"
1081
+ : terminal.state === "starting" ? "Shell 启动中"
1082
+ : terminal.command.status === "idle" ? "无(Shell 空闲)"
1083
+ : terminal.command.command ? inlineCode(escapeCardHtml(truncateText(terminal.command.command.replace(/\s+/gu, " "), 240)))
1084
+ : terminal.command.status === "running" ? "命令执行中(命令内容不可用)"
1085
+ : "未知(Shell 未提供命令信息)";
1086
+ const connection = terminal.connected ? terminal.foreground ? "网页前台" : "网页已连接" : "网页已断开";
1087
+ const label = `**终端**:${inlineCode(terminal.token)} · ${terminal.active ? "活跃" : "不活跃"} · ${connection}\n**目录**:${inlineCode(escapeCardHtml(truncateText(terminal.cwd, 240)))}\n**${terminal.command.source === "process" ? "前台进程" : "正在执行"}**:${command}`;
1088
+ elements.push({
1089
+ tag: "column_set", flex_mode: "none", horizontal_spacing: "8px",
1090
+ columns: [
1091
+ { tag: "column", width: "weighted", weight: 1, elements: [markdown(label)] },
1092
+ ...(actionable && terminal.state !== "closing" ? [{
1093
+ tag: "column", width: "auto", elements: [taskActionElement({
1094
+ text: "结束终端", type: "danger",
1095
+ value: { action: "safe_restart_terminal_stop", scheduleId: String(view.scheduleId), terminalId: terminal.terminalId, ...(view.contextKey ? { contextKey: view.contextKey } : {}) },
1096
+ })],
1097
+ }] : []),
1098
+ ],
1099
+ });
1100
+ }
1101
+ elements.push(markdown("活跃:前台连接、正在执行命令或最近 30 秒有输入输出。不活跃的 Shell 仍会阻止安全重启。"));
1102
+ }
1075
1103
  if (view.waitingTasks.length > 0) {
1076
1104
  const visible = view.waitingTasks.slice(0, 10);
1077
1105
  elements.push({ tag: "hr" }, markdown(`**当前等待的任务(${view.waitingTasks.length})**`));
package/src/index.ts CHANGED
@@ -145,7 +145,14 @@ if (feishuOutbound) {
145
145
  workspaceKind: defaultAgent?.kind === "app-server" ? "projectless" : "project",
146
146
  defaultUserOpenId: config.feishu.userOpenId,
147
147
  }, metadataHydrator);
148
- safeRestartNotifier = new SafeRestartNotifier(store, feishuOutbound, renderer, logger);
148
+ safeRestartNotifier = new SafeRestartNotifier(store, feishuOutbound, renderer, logger, {
149
+ ownerId: () => config.feishu.userOpenId,
150
+ isCurrentSchedule: (scheduleId) => !shuttingDown && !restartRequested && safeRestart.isCurrentSchedule(scheduleId),
151
+ stopTerminal: (terminalId, userId) => {
152
+ if (!terminalServer) throw new Error("网页终端未启用。");
153
+ return terminalServer.stopActiveTerminal(terminalId, userId);
154
+ },
155
+ });
149
156
  }
150
157
 
151
158
  const consoleEnabled = consoleOnly || config.console.enabled || transport === "console";
@@ -253,8 +260,8 @@ if (feishuOutbound && localFileViewer && config.terminal?.enabled !== false) {
253
260
 
254
261
  function serverActivity() {
255
262
  const activity = store.getServerActivityState();
256
- const activeTerminals = terminalServer?.activeCount ?? 0;
257
- return { ...activity, activeTerminals };
263
+ const terminalBlockers = terminalServer?.listActiveTerminals() ?? [];
264
+ return { ...activity, activeTerminals: terminalBlockers.length, terminalBlockers };
258
265
  }
259
266
 
260
267
  const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
@@ -264,6 +271,10 @@ const controller = new ProxySessionController(config, store, runtimes, outbound,
264
271
  if (!terminalServer) throw new Error("网页终端未启用。");
265
272
  return terminalServer.resolveApproval(action);
266
273
  },
274
+ stopSafeRestartTerminal: (action) => {
275
+ if (!safeRestartNotifier) throw new Error("安全重启通知未启用。");
276
+ return safeRestartNotifier.stopTerminal(action);
277
+ },
267
278
  showUpdates: (contextKey, userId) => manualUpdates.show(contextKey, userId),
268
279
  selectUpdate: (action, replyTarget) => manualUpdates.select(action, replyTarget),
269
280
  restart: requestRestart,
@@ -515,6 +515,7 @@ export interface ControlTaskReleaseResult {
515
515
  export interface ProxyLifecycle {
516
516
  terminalUrl?(jobId: string): string | undefined;
517
517
  authorizeTerminal?(action: CardAction): Promise<string>;
518
+ stopSafeRestartTerminal?(action: CardAction): Promise<string>;
518
519
  supervised?: boolean;
519
520
  restart(contextKey: string, force: boolean, replyTarget?: MessageReplyTarget): Promise<void>;
520
521
  cancelSafeRestart?(scheduleId: number): Promise<boolean>;
@@ -1035,6 +1036,9 @@ export class ProxySessionController {
1035
1036
  if (kind === "terminal_authorize") {
1036
1037
  if (!this.lifecycle?.authorizeTerminal) throw new Error("网页终端未启用。");
1037
1038
  await this.outbound.sendText(contextKey, await this.lifecycle.authorizeTerminal(scopedAction));
1039
+ } else if (kind === "safe_restart_terminal_stop") {
1040
+ if (!this.lifecycle?.stopSafeRestartTerminal) throw new Error("网页终端未启用。");
1041
+ await this.outbound.sendText(contextKey, await this.lifecycle.stopSafeRestartTerminal(scopedAction));
1038
1042
  } else if (kind === "agentbot_update_cancel") {
1039
1043
  if (!this.lifecycle?.cancelAutomaticUpdate) throw new Error("自动更新功能未启用。");
1040
1044
  await this.lifecycle.cancelAutomaticUpdate(scopedAction);
@@ -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;
@@ -133,6 +157,8 @@ export class SafeRestartNotifier {
133
157
  remainingMs: status.remainingMs,
134
158
  pendingFinalDeliveries: status.activity.pendingFinalDeliveries,
135
159
  activeTerminals: status.activity.activeTerminals,
160
+ terminalBlockers: status.activity.terminalBlockers,
161
+ contextKey,
136
162
  waitingTasks,
137
163
  });
138
164
  const cardHash = JSON.stringify(card);
@@ -141,12 +167,17 @@ export class SafeRestartNotifier {
141
167
  if (this.cardHashes.get(contextKey) === cardHash) return;
142
168
  try {
143
169
  await this.outbound.updateInteractiveCard(messageId, card);
144
- 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
+ }
145
174
  return;
146
175
  } catch (error) {
147
176
  this.logger.warn({ error, contextKey, messageId }, "Failed to update safe restart status card; sending a replacement.");
177
+ if (this.activeScheduleId !== status.scheduleId) return;
148
178
  this.messageIds.delete(contextKey);
149
179
  this.cardHashes.delete(contextKey);
180
+ this.cardTerminalIds.delete(contextKey);
150
181
  }
151
182
  }
152
183
  try {
@@ -170,6 +201,7 @@ export class SafeRestartNotifier {
170
201
  if (this.activeScheduleId === status.scheduleId) {
171
202
  this.messageIds.set(contextKey, created);
172
203
  this.cardHashes.set(contextKey, cardHash);
204
+ this.cardTerminalIds.set(contextKey, new Set((status.activity.terminalBlockers ?? []).map((terminal) => terminal.terminalId)));
173
205
  } else {
174
206
  await this.stopCard(created, status, reason ?? status.reason);
175
207
  }
@@ -1,5 +1,8 @@
1
+ import type { ActiveTerminal } from "../terminal/TerminalServer.js";
2
+
1
3
  export interface ServerActivityState {
2
4
  activeTerminals?: number;
5
+ terminalBlockers?: ActiveTerminal[];
3
6
  runningSessions: number;
4
7
  pendingFinalDeliveries: number;
5
8
  latestInboundAt?: string;
@@ -66,6 +69,10 @@ export class SafeRestartScheduler {
66
69
  return this.reason;
67
70
  }
68
71
 
72
+ isCurrentSchedule(scheduleId: number): boolean {
73
+ return this.scheduled && !this.restartingNow && this.scheduleId === scheduleId;
74
+ }
75
+
69
76
  schedule(
70
77
  reason: string,
71
78
  notificationTarget?: RestartNotificationTarget,
@@ -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
+ }
@@ -3,8 +3,11 @@ import { createRequire } from "node:module";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import type { IPty } from "node-pty";
6
+ import { powerShellTrackingScript, TerminalCommandTracker, type TerminalCommandState } from "./TerminalCommandTracker.js";
6
7
 
7
- export type TerminalProcess = Pick<IPty, "write" | "resize" | "kill" | "onData" | "onExit">;
8
+ export type TerminalProcess = Pick<IPty, "write" | "resize" | "kill" | "onData" | "onExit"> & {
9
+ commandState?(): TerminalCommandState;
10
+ };
8
11
  export type TerminalSpawner = (cwd: string, cols: number, rows: number) => Promise<TerminalProcess>;
9
12
 
10
13
  export function terminalShell(
@@ -63,9 +66,28 @@ export const spawnTerminal: TerminalSpawner = async (cwd, cols, rows) => {
63
66
  prepareTerminalHelper(path.dirname(require.resolve("node-pty/package.json")));
64
67
  }
65
68
  const shell = terminalShell();
66
- return pty.spawn(shell.file, shell.args, {
69
+ const tracker = new TerminalCommandTracker();
70
+ const powershell = /^(pwsh|powershell)(\.exe)?$/iu.test(path.basename(shell.file));
71
+ const args = powershell ? [...shell.args, "-NoExit", "-EncodedCommand",
72
+ Buffer.from(powerShellTrackingScript(tracker.nonce), "utf16le").toString("base64")] : shell.args;
73
+ const processPty = pty.spawn(shell.file, args, {
67
74
  cwd, cols, rows, name: "xterm-256color", env: terminalEnvironment(), useConpty: true,
68
75
  // The bundled ConPTY closes via its input pipe, avoiding node-pty's console-list helper on kill.
69
76
  useConptyDll: process.platform === "win32",
70
77
  });
78
+ return {
79
+ write: (data) => processPty.write(data), resize: (cols, rows) => processPty.resize(cols, rows), kill: () => processPty.kill(),
80
+ onExit: (listener) => processPty.onExit(listener),
81
+ onData: (listener) => processPty.onData((data) => { const visible = tracker.consume(data); if (visible) listener(visible); }),
82
+ commandState: () => {
83
+ const tracked = tracker.snapshot();
84
+ if (tracked.status !== "unknown" || process.platform === "win32") return tracked;
85
+ try {
86
+ const name = processPty.process;
87
+ // Unix PTYs can report a foreground process even without shell integration.
88
+ if (name && path.basename(name) !== path.basename(shell.file)) return { status: "running", command: name, source: "process" };
89
+ } catch { /* An exited PTY may no longer have a foreground process. */ }
90
+ return tracked;
91
+ },
92
+ };
71
93
  };
@@ -12,6 +12,7 @@ import type { CardAction } from "../feishu/types.js";
12
12
  import { spawnTerminal, type TerminalProcess, type TerminalSpawner } from "./TerminalProcess.js";
13
13
  import { terminalPage, TERMINAL_CSS, TERMINAL_SCRIPT } from "./TerminalPage.js";
14
14
  import { TerminalLinks } from "./TerminalLinks.js";
15
+ import type { TerminalCommandState } from "./TerminalCommandTracker.js";
15
16
 
16
17
  const require = createRequire(import.meta.url);
17
18
  const AUTH_TTL = 5 * 60_000;
@@ -36,6 +37,11 @@ export interface TerminalCardView extends TerminalAuthorization {
36
37
  terminalId: string; reopenToken: string; reopenUrl: string; recovery: boolean; state: "pending" | "approved" | "running" | "closed";
37
38
  connected: boolean; message?: string;
38
39
  }
40
+ export interface ActiveTerminal {
41
+ terminalId: string; token: string; cwd: string;
42
+ state: "starting" | "running" | "closing"; connected: boolean;
43
+ active: boolean; foreground: boolean; command: TerminalCommandState;
44
+ }
39
45
  export interface TerminalServerOptions {
40
46
  stateDirectory: string;
41
47
  ownerId(): string | undefined;
@@ -133,6 +139,37 @@ export class TerminalServer {
133
139
  return [...this.sessions.values()].filter((s) => s.pty || s.starting).length;
134
140
  }
135
141
 
142
+ listActiveTerminals(): ActiveTerminal[] {
143
+ return [...this.sessions.values()].filter((s) => s.pty || s.starting).map((s) => {
144
+ const foreground = s.socket?.readyState === WebSocket.OPEN && (s.foregroundUntil ?? 0) > this.now();
145
+ let command: TerminalCommandState = { status: "unknown" };
146
+ if (s.state !== "closed") {
147
+ try { command = s.pty?.commandState?.() ?? command; }
148
+ catch { /* Command inspection must not prevent safe-restart activity checks. */ }
149
+ }
150
+ return {
151
+ terminalId: s.requestId, token: s.reopenToken, cwd: s.cwd,
152
+ state: s.state === "closed" ? "closing" : s.pty ? "running" : "starting",
153
+ connected: s.socket?.readyState === WebSocket.OPEN,
154
+ active: s.state !== "closed" && (foreground || command.status === "running" || this.now() - s.lastActivity < 30_000),
155
+ foreground, command,
156
+ };
157
+ });
158
+ }
159
+
160
+ async stopActiveTerminal(terminalId: string, userId: string): Promise<boolean> {
161
+ const ownerId = this.options.ownerId();
162
+ if (!ownerId || userId !== ownerId) throw new Error("仅 AgentBot 所有者可结束终端。");
163
+ const session = this.sessions.get(terminalId);
164
+ if (!session || session.state === "closed" || (!session.pty && !session.starting)) return false;
165
+ if (session.ownerId !== userId) throw new Error("终端所有者已变更,请刷新状态。");
166
+ // Keep blocking restart if the OS refuses to terminate the Shell.
167
+ if (session.pty) session.pty.kill();
168
+ this.disposeSession(session, "所有者已从安全重启卡片结束终端", true);
169
+ await this.cardUpdates;
170
+ return true;
171
+ }
172
+
136
173
  async resolveApproval(action: CardAction): Promise<string> {
137
174
  this.sweep();
138
175
  const id = String(action.value.requestId ?? "");
@@ -425,11 +462,13 @@ export class TerminalServer {
425
462
  this.assertCanStart();
426
463
  const starting = (async () => {
427
464
  const pty = await (this.options.spawn ?? spawnTerminal)(session.cwd, 100, 30);
465
+ session.pty = pty;
466
+ pty.onExit(({ exitCode }) => { session.pty = undefined; this.disposeSession(session, `Shell 已退出(${exitCode})`); });
428
467
  if (this.closed || this.options.canStart?.() === false || session.state === "closed"
429
468
  || session.ownerId !== this.options.ownerId() || this.now() >= session.expiresAt) {
430
- pty.kill(); this.disposeSession(session, "服务正在重启或授权已失效"); return;
469
+ this.disposeSession(session, "服务正在重启或授权已失效", true); pty.kill(); return;
431
470
  }
432
- session.pty = pty; session.state = "running"; session.lastActivity = this.now();
471
+ session.state = "running"; session.lastActivity = this.now();
433
472
  this.markDisconnected(session);
434
473
  pty.onData((data) => {
435
474
  if (session.state !== "running") return;
@@ -437,7 +476,6 @@ export class TerminalServer {
437
476
  session.output = (session.output + data).slice(-256 * 1024);
438
477
  if (session.socket) this.send(session.socket, { type: "data", data });
439
478
  });
440
- pty.onExit(({ exitCode }) => { session.pty = undefined; this.disposeSession(session, `Shell 已退出(${exitCode})`); });
441
479
  this.options.logger.info({ requestId: session.requestId }, "Interactive terminal started.");
442
480
  })();
443
481
  session.starting = starting;
@@ -469,16 +507,16 @@ export class TerminalServer {
469
507
  if (s.state === "running" && now - Math.max(s.lastActivity, s.foregroundUntil ?? 0) >= IDLE_TTL) {
470
508
  this.disposeSession(s, "连续 30 分钟无输入或输出,Shell 已自动关闭");
471
509
  }
472
- if (s.state === "closed" && !s.starting && now - s.lastActivity >= AUTH_TTL) this.sessions.delete(s.requestId);
510
+ if (s.state === "closed" && !s.pty && !s.starting && now - s.lastActivity >= AUTH_TTL) this.sessions.delete(s.requestId);
473
511
  }
474
512
  }
475
- private disposeSession(s: TerminalSession, message: string): void {
513
+ private disposeSession(s: TerminalSession, message: string, waitForExit = false): void {
476
514
  if (s.state === "closed") return;
477
515
  this.clearDisconnectTimer(s);
478
516
  s.state = "closed"; s.message = message; s.lastActivity = this.now();
479
- const pty = s.pty; s.pty = undefined;
517
+ const pty = s.pty; if (!waitForExit) s.pty = undefined;
480
518
  if (s.socket) { this.send(s.socket, { type: "exit", message }); s.socket.close(); s.socket = undefined; }
481
- try { pty?.kill(); } catch (error) { this.options.logger.warn({ error, requestId: s.requestId }, "Failed to close terminal process."); }
519
+ try { if (!waitForExit) pty?.kill(); } catch (error) { this.options.logger.warn({ error, requestId: s.requestId }, "Failed to close terminal process."); }
482
520
  s.output = "";
483
521
  for (const recovery of this.recoveries.values()) {
484
522
  if (recovery.targetId === s.requestId) this.closeRecovery(recovery, message);