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

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 (50) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +6 -4
  3. package/README.zh.md +6 -4
  4. package/dist/codex/CodexEventMapper.js +1 -0
  5. package/dist/codex/CodexEventMapper.js.map +1 -1
  6. package/dist/codex/CodexProcessManager.js +3 -1
  7. package/dist/codex/CodexProcessManager.js.map +1 -1
  8. package/dist/codex/CodexRuntime.js +70 -8
  9. package/dist/codex/CodexRuntime.js.map +1 -1
  10. package/dist/codex/CodexVersion.js +2 -2
  11. package/dist/codex/CodexVersion.js.map +1 -1
  12. package/dist/codex/ProviderSwitch.js +1 -1
  13. package/dist/codex/ProviderSwitch.js.map +1 -1
  14. package/dist/codex/TraePlanEntry.js +70 -0
  15. package/dist/codex/TraePlanEntry.js.map +1 -0
  16. package/dist/feishu/CardRenderer.js +27 -1
  17. package/dist/feishu/CardRenderer.js.map +1 -1
  18. package/dist/index.js +16 -3
  19. package/dist/index.js.map +1 -1
  20. package/dist/proxy/ProxySessionController.js +5 -0
  21. package/dist/proxy/ProxySessionController.js.map +1 -1
  22. package/dist/supervision/SafeRestartNotifier.js +32 -1
  23. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  24. package/dist/supervision/SafeRestartScheduler.js +3 -0
  25. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  26. package/dist/terminal/TerminalCommandTracker.js +74 -0
  27. package/dist/terminal/TerminalCommandTracker.js.map +1 -0
  28. package/dist/terminal/TerminalProcess.js +25 -1
  29. package/dist/terminal/TerminalProcess.js.map +1 -1
  30. package/dist/terminal/TerminalServer.js +44 -7
  31. package/dist/terminal/TerminalServer.js.map +1 -1
  32. package/docs/technical-reference.md +2 -0
  33. package/docs/technical-reference.zh.md +2 -0
  34. package/npm-shrinkwrap.json +2 -2
  35. package/package.json +1 -1
  36. package/skills/agent-bot/SKILL.md +6 -4
  37. package/src/codex/CodexEventMapper.ts +1 -0
  38. package/src/codex/CodexProcessManager.ts +3 -1
  39. package/src/codex/CodexRuntime.ts +64 -8
  40. package/src/codex/CodexVersion.ts +2 -2
  41. package/src/codex/ProviderSwitch.ts +1 -1
  42. package/src/codex/TraePlanEntry.ts +63 -0
  43. package/src/feishu/CardRenderer.ts +30 -2
  44. package/src/index.ts +14 -3
  45. package/src/proxy/ProxySessionController.ts +4 -0
  46. package/src/supervision/SafeRestartNotifier.ts +34 -2
  47. package/src/supervision/SafeRestartScheduler.ts +7 -0
  48. package/src/terminal/TerminalCommandTracker.ts +74 -0
  49. package/src/terminal/TerminalProcess.ts +24 -2
  50. package/src/terminal/TerminalServer.ts +45 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyou007/agent-bot",
3
- "version": "0.1.24-alpha.6",
3
+ "version": "0.1.24-alpha.8",
4
4
  "type": "module",
5
5
  "description": "Use Codex, TraeX, and compatible coding agents from Lark/Feishu.",
6
6
  "license": "GPL-3.0-only",
@@ -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
 
@@ -137,9 +137,9 @@ A successful Provider switch confirms settings, not inference connectivity. Mode
137
137
 
138
138
  Providers such as a Responses bridge may omit assistant message phases. Unphased text stays in the progress timeline; successful completion promotes only the last message not followed by a tool start. Do not diagnose progress text in the answer area as a CSS issue without checking message phases. Old snapshots are not automatically backfilled.
139
139
 
140
- Provider changes during an Agent Bot turn are staged until it ends; do not stop the turn. The settings card and CLI show the pending Provider/model, and model/thinking/permission selections edit that pending configuration. The latest selection is applied before the next turn, including queued/retried turns. Pending settings survive restart, remain separate from actual task settings, and are verified before saving Agent defaults. Deferred failures roll back to the original Provider/model. After the runtime confirms rollback, the card returns to the original settings, the pending selection is cleared, and queued prompts continue with the original settings. Failed or unverified rollback still pauses queued prompts and requires explicit recovery; never claim rollback success based only on unchanged local fields. Custom Providers discover models through their OpenAI-compatible `/models` endpoint when available. Switching keeps the previous model when supported, otherwise uses the Provider default or its first model. A missing model-list endpoint falls back to the current or configured model and does not by itself block switching. Wait for active work to finish; do not stop it without the user's request. Switching unloads only the selected thread, not the shared App Server. On failure, previous settings are retained and recovery is attempted. If recovery fails, resolve the reported configuration or ownership issue and retry the Provider switch before sending another prompt. Never replace a forked task with an empty task to work around a switch failure.
140
+ Provider changes during an Agent Bot turn are staged until it ends; do not stop the turn. The settings card and CLI show the pending Provider/model, and model/thinking/permission selections edit that pending configuration. The latest selection is applied before the next turn, including queued/retried turns. Pending settings survive restart, remain separate from actual task settings, and are verified before saving Agent defaults. Deferred failures roll back to the original Provider/model. After the runtime confirms rollback, the card returns to the original settings, the pending selection is cleared, and queued prompts continue with the original settings. Failed or unverified rollback still pauses queued prompts and requires explicit recovery; never claim rollback success based only on unchanged local fields. Custom Providers discover models through their OpenAI-compatible `/models` endpoint when available. Switching keeps the previous model when supported, otherwise uses the Provider default or its first model. A missing model-list endpoint falls back to the current or configured model and does not by itself block switching. Wait for active work to finish; do not stop it without the user's request. Switching detaches only the selected thread and waits for its actual unload before resuming. On failure, previous settings are retained and recovery is attempted. If recovery fails, resolve the reported configuration or ownership issue and retry the Provider switch before sending another prompt. Never replace a forked task with an empty task to work around a switch failure.
141
141
 
142
- Provider changes must keep the same remote task ID and complete history. Never use Fork, thread replacement, synthetic Turns, or history repair to change settings. A rejected switch must report the reason and verify rollback; if App Server retains a stale Provider, suggest an explicit safe restart and retry rather than restarting the shared process implicitly. Settings inspection and selection must remain available when the old Provider cannot resume.
142
+ Provider changes must keep the same remote task ID and complete history. Never use Fork, thread replacement, synthetic Turns, or history repair to change settings. A rejected switch must report the reason and verify rollback; Codex is started with zero thread-unload delay, and switching waits for the selected thread to report notLoaded before resuming it. Other tasks may remain active, and empty sibling tasks stay untouched. An unload timeout does not prove external ownership; inspect the selected task and retry after releasing any remaining subscription. Do not restart or force-release the shared process to switch Provider. Settings inspection and selection must remain available when the old Provider cannot resume.
143
143
 
144
144
  ## Goals And Turns
145
145
 
@@ -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.
@@ -221,7 +223,7 @@ agentbot --profile ~/.agent-bot-rescue server status
221
223
 
222
224
  Initialization prepares configuration, checks supported Agents, configures Feishu, and starts the server. On first initialization and `init --reset`, let the user choose whether all group messages are accepted or an explicit @ mention is required; only the first choice requests the additional all-group-message permission. Relay authorization links and wait for the user; never choose a response mode, skip authorization, or choose maintenance actions without permission.
223
225
 
224
- Codex requires version 0.153.4 or later. Init checks this locally, and runtime startup validates the actual App Server version. For an unsupported version, show the upgrade instruction (`codex update` or `npm install -g @openai/codex@latest`); do not work around it by reading full history through legacy APIs. Safely restart Agent Bot after an authorized upgrade. This version requirement does not apply to TraeX or ACP Agents.
226
+ Codex requires version 0.162.1 or later. Init checks this locally, and runtime startup validates the actual App Server version. For an unsupported version, show the upgrade instruction (`codex update` or `npm install -g @openai/codex@latest`); do not work around it by reading full history through legacy APIs. Safely restart Agent Bot after an authorized upgrade. This version requirement does not apply to TraeX or ACP Agents.
225
227
 
226
228
  Use `init --reset` only for an explicitly requested full reset. Without `--profile` it resets the default Profile; pass `--profile <directory>` to reset another Profile. It preserves backups under `.reset-backups`.
227
229
 
@@ -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
  });
@@ -60,9 +60,11 @@ export class CodexProcessManager implements AppServerClientProvider {
60
60
  const environmentContext = this.environmentContext();
61
61
  const agentEnvironment = { ...this.env };
62
62
  if (this.getAgentFamily() === "codex") agentEnvironment.CODEX_HOME = this.getCodexHome();
63
+ const args = this.getAgentFamily() === "codex"
64
+ ? [...this.args, "-c", "thread_unload_delay_secs=0"] : this.args;
63
65
  const child = spawnStdioCommand(
64
66
  this.command,
65
- this.args,
67
+ args,
66
68
  agentBotEnvironment({ ...this.codexEnvironment, ...process.env }, agentEnvironment, environmentContext),
67
69
  environmentContext.profilePath ?? os.homedir(),
68
70
  );
@@ -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;
@@ -829,7 +832,7 @@ export class CodexRuntime implements AgentRuntime {
829
832
  ): Promise<RuntimeSession> {
830
833
  const session = this.requireSession(sessionId);
831
834
  if (session.activeTurnId) throw new Error(PROVIDER_SWITCH_BUSY);
832
- const client = await this.client();
835
+ let client = await this.client();
833
836
  const metadata = await client.request<ThreadReadResponse>("thread/read", {
834
837
  threadId: session.remoteSessionId, includeTurns: false,
835
838
  }, SYNC_REQUEST_TIMEOUT_MS);
@@ -851,12 +854,14 @@ export class CodexRuntime implements AgentRuntime {
851
854
  const restoreProvider = previous.modelProvider?.trim()
852
855
  || (metadata.thread.id === previous.remoteSessionId ? metadata.thread.modelProvider?.trim() : undefined);
853
856
  let detached = false;
857
+ let resumeRequested = false;
854
858
  try {
855
859
  const params = {
856
860
  cwd: session.cwd, modelProvider: settings.modelProvider, model: settings.model,
857
861
  ...threadLifecycleParams(session.cwd), ...permissionParams(settings.permissionMode),
858
862
  };
859
863
  await this.detachThreadForSettings(client, session, () => { detached = true; });
864
+ resumeRequested = true;
860
865
  const response = await client.request<ThreadResponse>("thread/resume", {
861
866
  ...params, threadId: session.remoteSessionId, excludeTurns: true,
862
867
  }, SESSION_REQUEST_TIMEOUT_MS);
@@ -877,7 +882,8 @@ export class CodexRuntime implements AgentRuntime {
877
882
  if (detached) {
878
883
  try {
879
884
  if (!restoreProvider) throw new Error("无法确认切换前的 Provider,不能验证原设置已恢复。");
880
- await this.detachThreadForSettings(client, previous);
885
+ client = await this.client();
886
+ if (resumeRequested) await this.detachThreadForSettings(client, previous);
881
887
  const restored = await client.request<ThreadResponse>("thread/resume", {
882
888
  threadId: previous.remoteSessionId, excludeTurns: true, cwd: previous.cwd,
883
889
  modelProvider: restoreProvider, model: previous.model,
@@ -918,6 +924,20 @@ export class CodexRuntime implements AgentRuntime {
918
924
  if (!["unsubscribed", "notLoaded", "notSubscribed"].includes(result.status)) {
919
925
  throw new Error(`App Server 返回了未知的取消订阅状态:${result.status}`);
920
926
  }
927
+ if (this.provider.getAgentFamily?.() === "codex") {
928
+ const deadline = Date.now() + CONTROL_REQUEST_TIMEOUT_MS;
929
+ while (Date.now() < deadline) {
930
+ if (this.attachedClient !== client) throw new Error("App Server 连接已变化,请重试 Provider 切换。");
931
+ const { thread } = await client.request<ThreadReadResponse>("thread/read", {
932
+ threadId: session.remoteSessionId, includeTurns: false,
933
+ }, Math.min(SYNC_REQUEST_TIMEOUT_MS, Math.max(1, deadline - Date.now())));
934
+ if (thread.id !== session.remoteSessionId) throw new Error("App Server 返回了不同的任务 ID,未切换 Provider。");
935
+ if (thread.status?.type === "notLoaded") return;
936
+ if (thread.status?.type === "active" || session.activeTurnId) throw new Error(PROVIDER_SWITCH_BUSY);
937
+ await delay(100);
938
+ }
939
+ throw new Error("等待任务卸载超时;任务可能仍有订阅或正在关闭,请稍后重试 Provider 切换。");
940
+ }
921
941
  }
922
942
 
923
943
  async respondToApproval(
@@ -950,11 +970,15 @@ export class CodexRuntime implements AgentRuntime {
950
970
  try {
951
971
  const client = await this.client();
952
972
  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);
973
+ try {
974
+ await client.request("mode/change/respond", {
975
+ threadId: this.requireSession(sessionId).remoteSessionId,
976
+ requestId: modeChange.requestId,
977
+ decision: decision === "accept" ? "approved" : decision === "decline" ? "rejected" : "cancelled",
978
+ }, CONTROL_REQUEST_TIMEOUT_MS);
979
+ } catch (error) {
980
+ if (!await this.reconcileCompletedPlanEntry(requestId, modeChange, error)) throw error;
981
+ }
958
982
  if (this.modeChanges.get(requestId) === modeChange) {
959
983
  this.confirmPlanContinuation(modeChange, decision);
960
984
  this.modeChanges.delete(requestId);
@@ -974,6 +998,36 @@ export class CodexRuntime implements AgentRuntime {
974
998
  this.emit({ type: "approval_resolved", sessionId, turnId: pending.turnId, requestId, decision });
975
999
  }
976
1000
 
1001
+ private async reconcileCompletedPlanEntry(
1002
+ requestId: string, pending: PendingModeChange, error: unknown,
1003
+ ): Promise<boolean> {
1004
+ const session = this.sessions.get(pending.sessionId);
1005
+ if (!pending.automatic || pending.decision !== "accept" || pending.targetMode !== "plan"
1006
+ || this.provider.getAgentFamily?.() !== "traex" || !session?.rolloutPath
1007
+ || !(error instanceof AppServerRequestError) || error.method !== "mode/change/respond" || error.code !== -32600
1008
+ || error.serverMessage !== `failed to respond to mode change request: No pending mode change found for request_id: ${pending.requestId}`) {
1009
+ return false;
1010
+ }
1011
+ const toolCallId = pending.sourceToolCallId ?? pending.requestId;
1012
+ for (const waitMs of [0, 100, 200]) {
1013
+ if (waitMs) await delay(waitMs);
1014
+ if (this.modeChanges.get(requestId) !== pending || this.sessions.get(pending.sessionId) !== session
1015
+ || session.activeTurnId !== pending.turnId) return false;
1016
+ try {
1017
+ if (!await hasCompletedTraePlanEntry(session.rolloutPath, pending.turnId, toolCallId)) continue;
1018
+ if (this.modeChanges.get(requestId) !== pending || this.sessions.get(pending.sessionId) !== session
1019
+ || session.activeTurnId !== pending.turnId) return false;
1020
+ this.logger.info({ sessionId: pending.sessionId, turnId: pending.turnId, requestId, toolCallId },
1021
+ "Verified that TraeX already completed the automatic plan-mode entry.");
1022
+ return true;
1023
+ } catch (verificationError) {
1024
+ this.logger.debug({ error: verificationError, sessionId: pending.sessionId, requestId },
1025
+ "Could not verify TraeX plan-mode entry from the rollout tail.");
1026
+ }
1027
+ }
1028
+ return false;
1029
+ }
1030
+
977
1031
  async listModels(modelProvider?: string, fallbackModel?: string): Promise<ModelOption[]> {
978
1032
  return (await this.listModelsWithStatus(modelProvider, fallbackModel)).models;
979
1033
  }
@@ -1217,6 +1271,7 @@ export class CodexRuntime implements AgentRuntime {
1217
1271
  const pending: PendingModeChange = {
1218
1272
  sessionId, turnId: mapped.turnId, requestId: mapped.requestId,
1219
1273
  targetMode: mapped.targetMode, responding: false,
1274
+ sourceToolCallId: mapped.sourceToolCallId ?? undefined,
1220
1275
  };
1221
1276
  this.modeChanges.set(requestId, pending);
1222
1277
  const automatic = session.permissionMode === "auto" && this.provider.getAgentFamily?.() === "traex";
@@ -1236,8 +1291,9 @@ export class CodexRuntime implements AgentRuntime {
1236
1291
  this.emit({ type: "approval_requested", sessionId, turnId: mapped.turnId, request });
1237
1292
  if (automatic) {
1238
1293
  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
1294
  if (this.modeChanges.get(requestId) !== pending || session.activeTurnId !== mapped.turnId) return;
1295
+ this.logger.warn({ error, sessionId, requestId, turnId: mapped.turnId, targetMode: mapped.targetMode,
1296
+ sourceToolCallId: pending.sourceToolCallId }, "Failed to automatically approve the TraeX mode change.");
1241
1297
  this.emit({ type: "approval_requested", sessionId, turnId: mapped.turnId,
1242
1298
  request: { ...request, automatic: false, reason: ["自动同意失败,请手动确认。", request.reason].filter(Boolean).join("\n\n") } });
1243
1299
  });
@@ -1,7 +1,7 @@
1
1
  import { compareSemanticVersions } from "../utils/semanticVersion.js";
2
2
 
3
- // Verified protocol baseline: paginated Turn summaries and excludeTurns.
4
- export const MINIMUM_CODEX_VERSION = "0.153.4";
3
+ // Verified protocol baseline: paginated history and configurable thread unloading.
4
+ export const MINIMUM_CODEX_VERSION = "0.162.1";
5
5
 
6
6
  export class CodexVersionError extends Error {}
7
7
 
@@ -15,7 +15,7 @@ export function assertProviderSettingsApplied(
15
15
  requested: Partial<Pick<RuntimeExecutionSettings, "modelProvider" | "model">>,
16
16
  ): void {
17
17
  if (requested.modelProvider !== undefined && actual.modelProvider !== requested.modelProvider) {
18
- throw new Error(`Provider 未生效:请求 ${requested.modelProvider},实际返回 ${actual.modelProvider ?? "未知"}。App Server 未应用设置;可在任务空闲后安全重启再重试,不会自动 Fork 或更改历史。`);
18
+ throw new Error(`Provider 未生效:请求 ${requested.modelProvider},实际返回 ${actual.modelProvider ?? "未知"}。App Server 未应用设置,请稍后重试;不会自动 Fork 或更改历史。`);
19
19
  }
20
20
  if (requested.model !== undefined && actual.model !== requested.model) {
21
21
  throw new Error(`模型未生效:请求 ${requested.model},实际返回 ${actual.model ?? "未知"}。请确认目标 Provider 支持该模型。`);
@@ -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
+ }