@keyou007/agent-bot 0.1.24-alpha.1 → 0.1.24-alpha.3

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 (99) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +33 -7
  3. package/README.zh.md +32 -7
  4. package/dist/acp/AcpJsonRpcConnection.js +3 -1
  5. package/dist/acp/AcpJsonRpcConnection.js.map +1 -1
  6. package/dist/cli/SelfUpdateRunner.js +2 -0
  7. package/dist/cli/SelfUpdateRunner.js.map +1 -1
  8. package/dist/cli/SelfUpdater.js +2 -0
  9. package/dist/cli/SelfUpdater.js.map +1 -1
  10. package/dist/codex/AppServerConnection.js +3 -1
  11. package/dist/codex/AppServerConnection.js.map +1 -1
  12. package/dist/codex/CodexEventMapper.js +41 -3
  13. package/dist/codex/CodexEventMapper.js.map +1 -1
  14. package/dist/codex/CodexRuntime.js +95 -3
  15. package/dist/codex/CodexRuntime.js.map +1 -1
  16. package/dist/commands/CommandRouter.js +5 -0
  17. package/dist/commands/CommandRouter.js.map +1 -1
  18. package/dist/feishu/CardRenderer.js +49 -23
  19. package/dist/feishu/CardRenderer.js.map +1 -1
  20. package/dist/feishu/FeishuConnector.js +2 -0
  21. package/dist/feishu/FeishuConnector.js.map +1 -1
  22. package/dist/feishu/FeishuMarkdown.js +70 -4
  23. package/dist/feishu/FeishuMarkdown.js.map +1 -1
  24. package/dist/feishu/FeishuMessageClient.js +44 -0
  25. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  26. package/dist/feishu/FeishuTurnPresenter.js +114 -37
  27. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  28. package/dist/feishu/RecentMessageContext.js +70 -0
  29. package/dist/feishu/RecentMessageContext.js.map +1 -0
  30. package/dist/index.js +62 -24
  31. package/dist/index.js.map +1 -1
  32. package/dist/local-files/DiagramPreview.js +171 -0
  33. package/dist/local-files/DiagramPreview.js.map +1 -0
  34. package/dist/local-files/LocalFileViewerServer.js +141 -3
  35. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  36. package/dist/local-files/PreviewSyntaxHighlight.js +57 -0
  37. package/dist/local-files/PreviewSyntaxHighlight.js.map +1 -0
  38. package/dist/local-files/TurnPreviewPage.js +499 -60
  39. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  40. package/dist/presentation/OutboundRouter.js +18 -9
  41. package/dist/presentation/OutboundRouter.js.map +1 -1
  42. package/dist/presentation/TurnStateReducer.js +157 -8
  43. package/dist/presentation/TurnStateReducer.js.map +1 -1
  44. package/dist/presentation/turnReasoning.js +23 -0
  45. package/dist/presentation/turnReasoning.js.map +1 -0
  46. package/dist/proxy/ProxySessionController.js +461 -49
  47. package/dist/proxy/ProxySessionController.js.map +1 -1
  48. package/dist/state/StateStore.js +43 -2
  49. package/dist/state/StateStore.js.map +1 -1
  50. package/dist/state/TurnPreviewJournal.js +469 -0
  51. package/dist/state/TurnPreviewJournal.js.map +1 -0
  52. package/dist/state/migrations.js +10 -0
  53. package/dist/state/migrations.js.map +1 -1
  54. package/dist/updates/AutomaticUpdatePreparer.js +16 -7
  55. package/dist/updates/AutomaticUpdatePreparer.js.map +1 -1
  56. package/dist/updates/ManualUpdateController.js +102 -0
  57. package/dist/updates/ManualUpdateController.js.map +1 -0
  58. package/dist/updates/PublishedRelease.js +42 -4
  59. package/dist/updates/PublishedRelease.js.map +1 -1
  60. package/dist/utils/ProtocolLineWarning.js +15 -0
  61. package/dist/utils/ProtocolLineWarning.js.map +1 -0
  62. package/docs/technical-reference.md +21 -1
  63. package/docs/technical-reference.zh.md +21 -1
  64. package/npm-shrinkwrap.json +1401 -10
  65. package/package.json +3 -1
  66. package/skills/agent-bot/SKILL.md +9 -2
  67. package/src/acp/AcpJsonRpcConnection.ts +3 -1
  68. package/src/cli/SelfUpdateRunner.ts +3 -0
  69. package/src/cli/SelfUpdater.ts +2 -0
  70. package/src/codex/AppServerConnection.ts +3 -1
  71. package/src/codex/CodexEventMapper.ts +40 -3
  72. package/src/codex/CodexRuntime.ts +91 -4
  73. package/src/commands/CommandRouter.ts +4 -0
  74. package/src/commands/commandTypes.ts +1 -0
  75. package/src/feishu/CardRenderer.ts +47 -21
  76. package/src/feishu/FeishuConnector.ts +2 -0
  77. package/src/feishu/FeishuMarkdown.ts +69 -3
  78. package/src/feishu/FeishuMessageClient.ts +44 -0
  79. package/src/feishu/FeishuTurnPresenter.ts +107 -31
  80. package/src/feishu/RecentMessageContext.ts +90 -0
  81. package/src/feishu/types.ts +5 -0
  82. package/src/index.ts +62 -22
  83. package/src/local-files/DiagramPreview.ts +173 -0
  84. package/src/local-files/LocalFileViewerServer.ts +129 -2
  85. package/src/local-files/PreviewSyntaxHighlight.ts +58 -0
  86. package/src/local-files/TurnPreviewPage.ts +539 -57
  87. package/src/presentation/OutboundRouter.ts +25 -7
  88. package/src/presentation/TurnStateReducer.ts +153 -9
  89. package/src/presentation/turnReasoning.ts +21 -0
  90. package/src/presentation/turnViewTypes.ts +19 -1
  91. package/src/proxy/ProxySessionController.ts +453 -40
  92. package/src/runtime/types.ts +21 -0
  93. package/src/state/StateStore.ts +45 -2
  94. package/src/state/TurnPreviewJournal.ts +398 -0
  95. package/src/state/migrations.ts +10 -0
  96. package/src/updates/AutomaticUpdatePreparer.ts +21 -7
  97. package/src/updates/ManualUpdateController.ts +116 -0
  98. package/src/updates/PublishedRelease.ts +49 -3
  99. package/src/utils/ProtocolLineWarning.ts +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyou007/agent-bot",
3
- "version": "0.1.24-alpha.1",
3
+ "version": "0.1.24-alpha.3",
4
4
  "type": "module",
5
5
  "description": "Use Codex, TraeX, and compatible coding agents from Lark/Feishu.",
6
6
  "license": "GPL-3.0-only",
@@ -75,6 +75,7 @@
75
75
  "dotenv": "17.4.2",
76
76
  "highlight.js": "11.12.0",
77
77
  "markdown-it": "15.0.1",
78
+ "mermaid": "11.17.2",
78
79
  "pino": "10.3.1",
79
80
  "yaml": "2.9.0",
80
81
  "zod": "4.4.3"
@@ -83,6 +84,7 @@
83
84
  "@types/better-sqlite3": "7.6.13",
84
85
  "@types/node": "22.18.11",
85
86
  "eslint": "10.6.0",
87
+ "linkedom": "0.18.13",
86
88
  "prettier": "3.9.5",
87
89
  "tsx": "4.23.0",
88
90
  "typescript": "7.0.2",
@@ -73,6 +73,7 @@ agentbot task title "<title>"
73
73
 
74
74
  - `prompt` posts the Prompt to the task's Feishu conversation before submitting it.
75
75
  - `queue` creates a later turn instead of steering the active turn. `nosteer` is an alias.
76
+ - The Feishu `/status` card includes `Preview` for the active or latest Turn when a saved preview and the file viewer are available; opening it does not switch or control the task.
76
77
  - `stop` requests an Agent interrupt; it does not kill the Agent process.
77
78
  - `release` sends a Lark confirmation card for releasing the Agent's shared App Server. It never releases automatically; the user must click **Release** or **Release Now** on the card.
78
79
  - `dismiss --yes` irreversibly dissolves the task's current Feishu group and archives its current task. Use it only after the user explicitly confirms.
@@ -125,7 +126,7 @@ agentbot task permissions [auto|confirm]
125
126
 
126
127
  Omit the value to inspect the current setting and available choices. `agent` changes the default Agent for future tasks in that conversation. The other settings affect the specified task from its next request and become the saved defaults for that Agent.
127
128
 
128
- A successful Provider switch confirms settings, not inference connectivity. Model-list fallback warnings appear on settings cards; inspect the Provider service when availability is unconfirmed. App Server errors and native retry notices appear on the current progress card without terminating the turn. Do not stop or restart a retrying task unless the user requests it.
129
+ A successful Provider switch confirms settings, not inference connectivity. Model-list fallback warnings appear on settings cards; inspect the Provider service when availability is unconfirmed. App Server errors and native retry notices show the message and supplied `additionalDetails` as literal text on the current progress card and Preview without terminating the turn. Use Preview for long reasons; `Reconnecting` alone does not identify the cause when the Agent supplies no details, and old snapshots are not backfilled. Do not stop or restart a retrying task unless the user requests it.
129
130
 
130
131
  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.
131
132
 
@@ -144,6 +145,10 @@ agentbot task reset <turn-id>
144
145
 
145
146
  In Feishu, `/turn` (also `/turns`) opens history. `/turn <Turn ID>` or `/turn <index>` shows saved runtime details for the current task without resetting, resuming, or interrupting it. Indices match the history card: one-based, newest first, including a running Turn at the top. Missing records use only necessary summary pages; Fork queries stay within the branch anchor. Summary-only external Turns do not contain unrecorded tool output. This does not change the CLI's `task turns [task]` task-reference argument.
146
147
 
148
+ The Feishu `/sessions` card also has `SwitchGroup`: create a new group bound to the selected existing task, without forking or creating a new Agent task. Source bindings remain unchanged, and active Agent Bot Turns keep their original delivery route; messages from the new group queue instead of steering them. External active tasks are not taken over.
149
+
150
+ The Feishu `/sessions` card provides `Turns` for each task, including unbound external tasks. Browsing and pagination never switch, resume, or bind a task; Reset is available only for the current task.
151
+
147
152
  Use `turns` to obtain a real Turn ID before `reset`. Reset changes conversation context only; it does not revert local files. Agent Bot announces an interactive Reset when it starts and queues every new message after its Reaction until the replacement thread is ready.
148
153
 
149
154
  ## Files And Local Commands
@@ -156,7 +161,7 @@ agentbot task shell "<command>"
156
161
 
157
162
  Paths are resolved from the selected task's working directory; `~` means the operating-system user's home directory. `file` sends the file to the task's Feishu conversation. `shell` runs in the task directory.
158
163
 
159
- For group mention-only mode:
164
+ For group mention-only mode (ordinary @ Prompts also receive bounded recent text/images from the current group or topic; scan newest to oldest and stop at the first message already submitted to the current task/branch, without reading older pages; requires Feishu history/group-message access; `/mute off` disables enrichment):
160
165
 
161
166
  ```powershell
162
167
  agentbot task mute on|off
@@ -173,6 +178,8 @@ agentbot server restart --task <task> --reason "<reason>"
173
178
  agentbot server stop
174
179
  ```
175
180
 
181
+ In Feishu, `/update` checks npm’s latest stable and Alpha releases and displays exact-version update buttons and release notes. It does not install on its own. Only the configured owner can select an update; choices expire after 30 minutes and never permit downgrade. It is available even when automatic checks are disabled. Let the user choose a version; do not click an update button merely to test this command. Source and npm-link installations can inspect releases but cannot use self-update.
182
+
176
183
  `update` is only for npm-installed Agent Bot packages. It verifies the new package, sends a safe-restart card, waits for active tasks, and restarts immediately when the service is already idle. It automatically restores the prior version if activation fails. It refuses source checkouts and `npm link` installations.
177
184
 
178
185
  The Feishu service also checks stable npm `latest` once daily at a persisted random time between 10:00 and 17:00 (server local time). A new version sends the owner a private release-notes card with 60 seconds to cancel. If not cancelled, preparation runs in the background and activation waits for tasks, final deliveries, and the quiet inbound window before restarting. Each version is notified once per Profile; cancellation survives restarts. An interrupted countdown resumes with a fresh minute on the same card. No private recipient, unconfirmed notification delivery, or missing release notes means no automatic installation. Source/npm-link protections still apply. Set `updates.enabled: false` in the Profile config and safely restart to disable checks. Do not bypass the countdown or idle gate.
@@ -1,3 +1,4 @@
1
+ import { ProtocolLineWarning } from "../utils/ProtocolLineWarning.js";
1
2
  import { EventEmitter } from "node:events";
2
3
  import readline from "node:readline";
3
4
  import type { ChildProcessWithoutNullStreams } from "node:child_process";
@@ -26,6 +27,7 @@ export interface AcpJsonRpcConnectionEvents {
26
27
  }
27
28
 
28
29
  export class AcpJsonRpcConnection extends EventEmitter {
30
+ private readonly invalidLineWarning = new ProtocolLineWarning();
29
31
  private nextId = 1;
30
32
  private readonly pending = new Map<string | number, PendingRequest>();
31
33
  private readonly handlers = new Map<string, ClientMethodHandler>();
@@ -129,7 +131,7 @@ export class AcpJsonRpcConnection extends EventEmitter {
129
131
  try {
130
132
  message = JSON.parse(line) as JsonRpcMessage;
131
133
  } catch (error) {
132
- this.logger.warn({ line, error }, "Ignoring non-JSON ACP stdout line.");
134
+ this.invalidLineWarning.warn(this.logger, line, "Ignoring non-JSON ACP stdout line.");
133
135
  return;
134
136
  }
135
137
 
@@ -24,6 +24,7 @@ export interface SelfUpdatePlan {
24
24
  restartService: boolean;
25
25
  reason?: string;
26
26
  notificationSessionId?: string;
27
+ notificationTarget?: { contextKey: string; replyMessageId?: string };
27
28
  resultPath: string;
28
29
  logPath: string;
29
30
  }
@@ -571,6 +572,7 @@ export function runNpmCommand(args: string[], cwd: string): CommandResult {
571
572
  {
572
573
  cwd,
573
574
  env: process.env,
575
+ windowsHide: true,
574
576
  encoding: "utf8",
575
577
  maxBuffer: 32 * 1024 * 1024,
576
578
  shell: process.platform === "win32",
@@ -610,6 +612,7 @@ function runNode(args: string[], cwd: string): CommandResult {
610
612
  spawnSync(process.execPath, args, {
611
613
  cwd,
612
614
  env: process.env,
615
+ windowsHide: true,
613
616
  encoding: "utf8",
614
617
  maxBuffer: 32 * 1024 * 1024,
615
618
  }),
@@ -316,6 +316,7 @@ export function finalizeSelfUpdatePlan(
316
316
  workingDirectory?: string;
317
317
  reason?: string;
318
318
  notificationSessionId?: string;
319
+ notificationTarget?: SelfUpdatePlan["notificationTarget"];
319
320
  },
320
321
  ): void {
321
322
  const plan = readSelfUpdatePlan(planPath);
@@ -324,6 +325,7 @@ export function finalizeSelfUpdatePlan(
324
325
  plan.restartService = values.restartService;
325
326
  if (values.workingDirectory) plan.workingDirectory = values.workingDirectory;
326
327
  if (values.reason) plan.reason = values.reason;
328
+ if (values.notificationTarget) plan.notificationTarget = values.notificationTarget;
327
329
  if (values.notificationSessionId)
328
330
  plan.notificationSessionId = values.notificationSessionId;
329
331
  fs.writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`);
@@ -1,3 +1,4 @@
1
+ import { ProtocolLineWarning } from "../utils/ProtocolLineWarning.js";
1
2
  import type { ChildProcessWithoutNullStreams } from "node:child_process";
2
3
  import readline from "node:readline";
3
4
  import type { Logger } from "pino";
@@ -42,6 +43,7 @@ export class AppServerRequestError extends Error {
42
43
  }
43
44
 
44
45
  export class AppServerConnection {
46
+ private readonly invalidLineWarning = new ProtocolLineWarning();
45
47
  private nextId = 1;
46
48
  private closed = false;
47
49
  private readonly pending = new Map<AppServerRequestId, PendingRequest>();
@@ -108,7 +110,7 @@ export class AppServerConnection {
108
110
  try {
109
111
  message = JSON.parse(line) as AppServerMessage;
110
112
  } catch (error) {
111
- this.logger.warn({ line, error }, "Ignoring non-JSON App Server stdout line.");
113
+ this.invalidLineWarning.warn(this.logger, line, "Ignoring non-JSON App Server stdout line.");
112
114
  return;
113
115
  }
114
116
  this.handleMessage(message);
@@ -51,7 +51,10 @@ export type MappedCodexNotification =
51
51
  activityId: string;
52
52
  text: string;
53
53
  append: boolean;
54
+ reasoning?: { itemId: string; summaryIndex: number };
54
55
  }
56
+ | { kind: "reasoning_delta"; threadId: string; turnId: string; itemId: string; contentIndex: number; text: string }
57
+ | { kind: "reasoning_completed"; threadId: string; turnId: string; itemId: string; summary: string[]; content: string[] }
55
58
  | ({ kind: "mode_change_requested"; threadId: string; turnId: string } & z.infer<typeof modeChangeRequest>)
56
59
  | ({ kind: "mode_change_resolved"; threadId: string; turnId: string } & z.infer<typeof modeChangeResolution>)
57
60
  | { kind: "plan"; threadId: string; turnId: string; steps: PlanStep[] }
@@ -73,7 +76,7 @@ export function mapCodexNotification(method: string, params: unknown): MappedCod
73
76
  if (!threadId || !turnId) return undefined;
74
77
 
75
78
  if (method === "error" && isRecord(params.error)) {
76
- const message = stringValue(params.error.message)?.trim();
79
+ const message = formatCodexError(params.error);
77
80
  if (!message || typeof params.willRetry !== "boolean") return undefined;
78
81
  return { kind: "runtime_error", threadId, turnId, message, willRetry: params.willRetry };
79
82
  }
@@ -146,16 +149,24 @@ export function mapCodexNotification(method: string, params: unknown): MappedCod
146
149
  const text = stringValue(params.delta);
147
150
  const itemId = stringValue(params.itemId);
148
151
  const summaryIndex = numberValue(params.summaryIndex);
149
- if (text === undefined || !itemId || summaryIndex === undefined) return undefined;
152
+ if (text === undefined || !itemId || !isReasoningIndex(summaryIndex)) return undefined;
150
153
  return {
151
154
  kind: "progress",
152
155
  threadId,
153
156
  turnId,
154
157
  activityId: `reasoning:${itemId}:${summaryIndex}`,
158
+ reasoning: { itemId, summaryIndex },
155
159
  text,
156
160
  append: true,
157
161
  };
158
162
  }
163
+ if (method === "item/reasoning/textDelta") {
164
+ const text = stringValue(params.delta);
165
+ const itemId = stringValue(params.itemId);
166
+ const contentIndex = numberValue(params.contentIndex);
167
+ if (text === undefined || !itemId || !isReasoningIndex(contentIndex)) return undefined;
168
+ return { kind: "reasoning_delta", threadId, turnId, itemId, contentIndex, text };
169
+ }
159
170
  if (method === "turn/plan/updated" && Array.isArray(params.plan)) {
160
171
  const steps = params.plan.flatMap((value): PlanStep[] => {
161
172
  if (!isRecord(value) || typeof value.step !== "string") return [];
@@ -164,6 +175,14 @@ export function mapCodexNotification(method: string, params: unknown): MappedCod
164
175
  return { kind: "plan", threadId, turnId, steps };
165
176
  }
166
177
  if ((method === "item/started" || method === "item/completed") && isRecord(params.item)) {
178
+ if (params.item.type === "reasoning") {
179
+ if (method !== "item/completed") return undefined;
180
+ const itemId = stringValue(params.item.id);
181
+ const summary = reasoningSections(params.item.summary);
182
+ const content = reasoningSections(params.item.content);
183
+ if (!itemId || !summary || !content) return undefined;
184
+ return { kind: "reasoning_completed", threadId, turnId, itemId, summary, content };
185
+ }
167
186
  if (params.item.type === "plan") {
168
187
  const itemId = stringValue(params.item.id);
169
188
  const text = stringValue(params.item.text);
@@ -200,7 +219,7 @@ export function mapCodexNotification(method: string, params: unknown): MappedCod
200
219
  if (method === "turn/completed" && isRecord(params.turn)) {
201
220
  const status = params.turn.status;
202
221
  const mappedStatus = status === "interrupted" ? "cancelled" : status === "failed" ? "failed" : "completed";
203
- const error = isRecord(params.turn.error) ? stringValue(params.turn.error.message) : undefined;
222
+ const error = formatCodexError(params.turn.error);
204
223
  return {
205
224
  kind: "terminal",
206
225
  threadId,
@@ -213,6 +232,24 @@ export function mapCodexNotification(method: string, params: unknown): MappedCod
213
232
  return undefined;
214
233
  }
215
234
 
235
+ function isReasoningIndex(value: number | undefined): value is number {
236
+ return value !== undefined && Number.isSafeInteger(value) && value >= 0 && value < 10_000;
237
+ }
238
+
239
+ function reasoningSections(value: unknown): string[] | undefined {
240
+ if (value === undefined) return [];
241
+ return Array.isArray(value) && value.every((section) => typeof section === "string") ? value : undefined;
242
+ }
243
+
244
+ export function formatCodexError(error: unknown): string | undefined {
245
+ if (!isRecord(error)) return undefined;
246
+ const message = stringValue(error.message)?.trim();
247
+ const details = stringValue(error.additionalDetails)?.trim();
248
+ if (!message) return details || undefined;
249
+ if (!details || message.includes(details)) return message;
250
+ return details.includes(message) ? details : `${message}\n\n${details}`;
251
+ }
252
+
216
253
  function mapTool(
217
254
  item: Record<string, unknown>,
218
255
  phase: "started" | "updated",
@@ -16,6 +16,7 @@ import type {
16
16
  RemoteSessionPage,
17
17
  RemoteSessionSummary,
18
18
  RemoteTurnPage,
19
+ RemoteTurnDetails,
19
20
  ResumeRuntimeSessionInput,
20
21
  RuntimeGoal,
21
22
  RuntimeGoalUpdate,
@@ -29,7 +30,7 @@ import type {
29
30
  import { appendGeneratedImageMarkdown } from "../utils/generatedImageMarkdown.js";
30
31
  import { normalizeTaskTitle } from "../utils/taskTitle.js";
31
32
  import { AppServerRequestError } from "./AppServerConnection.js";
32
- import { mapCodexNotification } from "./CodexEventMapper.js";
33
+ import { formatCodexError, mapCodexNotification } from "./CodexEventMapper.js";
33
34
  import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
34
35
  import { detectProjectlessWorkspace } from "./ProjectlessWorkspace.js";
35
36
  import { threadWriterLockPath } from "./ThreadWriterProcess.js";
@@ -398,6 +399,48 @@ export class CodexRuntime implements AgentRuntime {
398
399
  };
399
400
  }
400
401
 
402
+ async readRemoteTurn(remoteSessionId: string, turnId: string): Promise<RemoteTurnDetails> {
403
+ const client = await this.client();
404
+ const seenCursors = new Set<string>();
405
+ let cursor: string | undefined;
406
+ do {
407
+ const page = await this.readTurnPage(client, remoteSessionId, {
408
+ cursor, limit: 50, itemsView: "summary",
409
+ }, CONTROL_REQUEST_TIMEOUT_MS);
410
+ const index = page.data.findIndex((turn) => turn.id === turnId);
411
+ if (index >= 0) {
412
+ // Cursors are opaque; ask for the prefix to obtain the target's boundary.
413
+ if (index > 0) {
414
+ const prefix = await this.readTurnPage(client, remoteSessionId, {
415
+ cursor, limit: index, itemsView: "summary",
416
+ }, CONTROL_REQUEST_TIMEOUT_MS);
417
+ if (!prefix.nextCursor || prefix.data.at(-1)?.id !== page.data[index - 1]?.id) {
418
+ throw new Error("轮次历史发生变化,请重新打开重试。");
419
+ }
420
+ cursor = prefix.nextCursor;
421
+ }
422
+ const full = await this.readTurnPage(client, remoteSessionId, {
423
+ cursor, limit: 1, itemsView: "full",
424
+ }, CONTROL_REQUEST_TIMEOUT_MS);
425
+ const turn = full.data[0];
426
+ if (!turn || turn.id !== turnId) throw new Error("轮次历史发生变化,请重新打开重试。");
427
+ if (turn.itemsView === "summary" || !Array.isArray(turn.items)) {
428
+ throw new Error("Agent 未返回完整的轮次执行记录。");
429
+ }
430
+ if (turn.status === "inProgress") throw new Error("该历史轮次仍在执行,请稍后重试。");
431
+ return remoteTurnDetails(remoteSessionId, turn);
432
+ }
433
+ const nextCursor = page.nextCursor ?? undefined;
434
+ if (!nextCursor) break;
435
+ if (nextCursor === cursor || seenCursors.has(nextCursor)) {
436
+ throw new Error("App Server repeated a Turn history cursor.");
437
+ }
438
+ seenCursors.add(nextCursor);
439
+ cursor = nextCursor;
440
+ } while (cursor);
441
+ throw new Error(`Agent 历史中未找到 Turn:${turnId}`);
442
+ }
443
+
401
444
  async *readConversation(remoteSessionId: string, throughTurnId?: string): AsyncIterable<ConversationTurn> {
402
445
  const client = await this.client();
403
446
  const seenCursors = new Set<string>();
@@ -1211,12 +1254,19 @@ export class CodexRuntime implements AgentRuntime {
1211
1254
  append: false,
1212
1255
  severity: "warning",
1213
1256
  });
1257
+ } else if (mapped.kind === "reasoning_delta") {
1258
+ this.emit({ type: "reasoning_delta", sessionId, turnId: mapped.turnId,
1259
+ itemId: mapped.itemId, contentIndex: mapped.contentIndex, text: mapped.text });
1260
+ } else if (mapped.kind === "reasoning_completed") {
1261
+ this.emit({ type: "reasoning_completed", sessionId, turnId: mapped.turnId,
1262
+ itemId: mapped.itemId, summary: mapped.summary, content: mapped.content });
1214
1263
  } else if (mapped.kind === "progress") {
1215
1264
  this.emit({
1216
1265
  type: "progress",
1217
1266
  sessionId,
1218
1267
  turnId: mapped.turnId,
1219
1268
  activityId: mapped.activityId,
1269
+ ...(mapped.reasoning ? { reasoning: mapped.reasoning } : {}),
1220
1270
  text: mapped.text,
1221
1271
  append: mapped.append,
1222
1272
  });
@@ -1460,7 +1510,7 @@ export class CodexRuntime implements AgentRuntime {
1460
1510
  type: "turn_failed",
1461
1511
  sessionId: session.localSessionId,
1462
1512
  turnId: turn.id,
1463
- message: turn.error?.message ?? "App Server turn failed.",
1513
+ message: formatCodexError(turn.error) ?? "App Server turn failed.",
1464
1514
  });
1465
1515
  return;
1466
1516
  }
@@ -1626,6 +1676,8 @@ interface CodexThreadSnapshot {
1626
1676
 
1627
1677
  interface CodexTurnSnapshot {
1628
1678
  id: string;
1679
+ itemsView?: "summary" | "full";
1680
+ completedAt?: number | null;
1629
1681
  status: "completed" | "interrupted" | "failed" | "inProgress";
1630
1682
  items?: Array<{
1631
1683
  id?: string;
@@ -1639,7 +1691,7 @@ interface CodexTurnSnapshot {
1639
1691
  status?: string;
1640
1692
  savedPath?: string;
1641
1693
  }>;
1642
- error?: { message?: string } | null;
1694
+ error?: { message?: string; additionalDetails?: string | null } | null;
1643
1695
  startedAt?: number | null;
1644
1696
  durationMs?: number | null;
1645
1697
  }
@@ -1722,6 +1774,41 @@ function turnStartedAt(turn: CodexTurnSnapshot): number {
1722
1774
  return typeof turn.startedAt === "number" ? turn.startedAt * 1_000 : Date.now();
1723
1775
  }
1724
1776
 
1777
+ function remoteTurnDetails(remoteSessionId: string, turn: CodexTurnSnapshot): RemoteTurnDetails {
1778
+ const items: RemoteTurnDetails["items"] = [];
1779
+ const finalMessages = new Set(finalResponseMessages(turn));
1780
+ for (const [index, item] of (turn.items ?? []).entries()) {
1781
+ const id = item.id ?? `history:${index}`;
1782
+ if (item.type === "userMessage") {
1783
+ const content: unknown[] = item.content ?? [];
1784
+ const text = content.flatMap((block) => isRecord(block) && block.type === "text" && typeof block.text === "string" ? [block.text] : []).join("\n");
1785
+ const localImagePaths = content.flatMap((block) => isRecord(block) && block.type === "localImage" && typeof block.path === "string" ? [block.path] : []);
1786
+ items.push({ kind: "message", id, role: "user", text, ...(localImagePaths.length ? { localImagePaths } : {}) });
1787
+ continue;
1788
+ }
1789
+ if (item.type === "agentMessage") {
1790
+ if (!finalMessages.has(item) && item.text) items.push({ kind: "message", id, role: "assistant", text: item.text });
1791
+ continue;
1792
+ }
1793
+ const mapped = mapCodexNotification("item/completed", { threadId: remoteSessionId, turnId: turn.id, item });
1794
+ if (mapped?.kind === "tool") items.push({ kind: "tool", tool: mapped.tool });
1795
+ else if (mapped?.kind === "reasoning_completed") {
1796
+ items.push({ kind: "reasoning", id: mapped.itemId, summary: mapped.summary, content: mapped.content });
1797
+ } else if (mapped?.kind === "progress") items.push({ kind: "message", id, role: "assistant", text: mapped.text });
1798
+ else if (mapped?.kind === "plan") items.push({ kind: "plan", steps: mapped.steps });
1799
+ }
1800
+ return {
1801
+ turnId: turn.id,
1802
+ status: turn.status === "failed" ? "failed" : turn.status === "interrupted" ? "cancelled" : "completed",
1803
+ startedAt: typeof turn.startedAt === "number" ? turn.startedAt * 1_000 : undefined,
1804
+ completedAt: typeof turn.completedAt === "number" ? turn.completedAt * 1_000 : undefined,
1805
+ durationMs: turn.durationMs ?? undefined,
1806
+ finalResponse: appendGeneratedImageMarkdown(extractFinalResponse(turn), extractGeneratedImagePaths(turn)),
1807
+ error: formatCodexError(turn.error),
1808
+ items,
1809
+ };
1810
+ }
1811
+
1725
1812
  function extractFinalResponse(turn: CodexTurnSnapshot): string {
1726
1813
  return finalResponseMessages(turn).map((item) => item.text!.trim()).join("\n\n");
1727
1814
  }
@@ -1798,7 +1885,7 @@ function remoteSessionSummary(thread: CodexThreadSnapshot): RemoteSessionSummary
1798
1885
  finalResponse: lastTurn && lastTurn.status !== "inProgress"
1799
1886
  ? appendGeneratedImageMarkdown(extractFinalResponse(lastTurn), extractGeneratedImagePaths(lastTurn)) || undefined
1800
1887
  : undefined,
1801
- lastError: lastTurn?.error?.message,
1888
+ lastError: formatCodexError(lastTurn?.error),
1802
1889
  lastTurnToolCount: toolCounts?.total,
1803
1890
  lastTurnCompletedToolCount: toolCounts?.completed,
1804
1891
  lastTurnFailedToolCount: toolCounts?.failed,
@@ -29,6 +29,7 @@ export const COMMAND_NAMES = [
29
29
  "thinking",
30
30
  "title",
31
31
  "turns",
32
+ "update",
32
33
  ] as const;
33
34
 
34
35
  type CommandName = (typeof COMMAND_NAMES)[number];
@@ -121,6 +122,9 @@ export class CommandRouter {
121
122
  if (args.length === 0) return { type: "restart" };
122
123
  if (args.length === 1 && args[0] === "--force") return { type: "restart", force: true };
123
124
  throw new Error("/restart 只接受一个可选的 --force 参数。");
125
+ case "update":
126
+ if (args.length > 0) throw new Error("/update 不接受参数,请在卡片中选择版本。");
127
+ return { type: "update" };
124
128
  case "release":
125
129
  if (args.length > 0) throw new Error("/release 不接受参数。");
126
130
  return { type: "release" };
@@ -21,6 +21,7 @@ export type Command =
21
21
  | { type: "goal"; action: "pause" | "resume" | "clear" }
22
22
  | { type: "restart"; force?: boolean }
23
23
  | { type: "release" }
24
+ | { type: "update" }
24
25
  | { type: "mute"; enabled: boolean }
25
26
  | { type: "turns"; turnReference?: string }
26
27
  | { type: "model" }
@@ -246,6 +246,8 @@ export interface ResetHistoryCardEntry extends TaskListCardEntry {
246
246
  }
247
247
 
248
248
  export interface ResetHistoryCardView {
249
+ taskTitle?: string;
250
+ readOnly?: boolean;
249
251
  entries: ResetHistoryCardEntry[];
250
252
  footerLines: string[];
251
253
  pageActions: TaskListCardAction[];
@@ -1267,6 +1269,11 @@ export class CardRenderer {
1267
1269
  : "任务执行详情";
1268
1270
  const elements = [markdown(`Turn: \`${state.turnId}\``)];
1269
1271
  if (state.prompt) elements.push(markdown(`**Prompt**\n${truncateText(state.prompt, 1_000)}`));
1272
+ if (state.historyDetailError) {
1273
+ elements.push(markdown(`**历史执行详情读取失败**\n${truncateText(state.historyDetailError, 1_000)}\n请重新打开详情或 Preview 重试;本轮执行状态未改变。`));
1274
+ } else if (state.historyDetail === "summary") {
1275
+ elements.push(markdown("当前仅保存轮次摘要,尚未加载历史执行详情。"));
1276
+ }
1270
1277
  elements.push(...renderTurnElements(state, "always"));
1271
1278
  if (previewUrl) elements.push(taskActionRow([turnPreviewAction(previewUrl)]));
1272
1279
  return turnCard(title, "blue", elements, renderTurnSubtitle(state));
@@ -1604,13 +1611,16 @@ export class CardRenderer {
1604
1611
  renderResetHistoryCard(view: ResetHistoryCardView): Record<string, unknown> {
1605
1612
  const elements: Record<string, unknown>[] = [
1606
1613
  {
1607
- ...markdown("<font color='grey'>Reset 会将当前任务的对话上下文恢复到所选轮次完成时;不会回退本地文件。</font>"),
1614
+ ...markdown(view.readOnly
1615
+ ? "<font color='grey'>仅查看此任务的轮次;如需 Reset,请先切换到此任务。</font>"
1616
+ : "<font color='grey'>Reset 会将当前任务的对话上下文恢复到所选轮次完成时;不会回退本地文件。</font>"),
1608
1617
  text_size: "notation",
1609
1618
  },
1610
1619
  { tag: "hr" },
1611
1620
  ];
1621
+ if (view.taskTitle) elements.unshift(markdown(`**任务**:${escapeCardActionText(view.taskTitle)}`));
1612
1622
  if (view.entries.length === 0) {
1613
- elements.push(markdown("当前任务还没有成功完成的 turn。"));
1623
+ elements.push(markdown(view.readOnly ? "此任务还没有可显示的 turn。" : "当前任务还没有成功完成的 turn。"));
1614
1624
  } else {
1615
1625
  const sequenceWidth = `${Math.max(16, ...view.entries.map((entry) => String(entry.sequence).length * 10))}px`;
1616
1626
  view.entries.forEach((entry) => {
@@ -1674,13 +1684,13 @@ function renderTurnElements(
1674
1684
  const pages = activityPages(allActivities);
1675
1685
  const visibleActivities = pages.at(-1) ?? [];
1676
1686
  if (state.plan.length > 0) elements.push(planPanel(state.plan));
1677
- if (pages.length > 1) {
1687
+ if (pages.length > 1 || (state.previewJournal && state.activitiesTruncated)) {
1678
1688
  elements.push(taskActionRow([{
1679
- text: `查看历史思考(共 ${pages.length} 页)`,
1689
+ text: state.activitiesTruncated ? "查看历史思考" : `查看历史思考(共 ${pages.length} 页)`,
1680
1690
  value: {
1681
1691
  action: "activity_history",
1682
1692
  turnId: state.turnId,
1683
- page: String(pages.length - 2),
1693
+ page: String(state.activitiesTruncated ? Number.MAX_SAFE_INTEGER : pages.length - 2),
1684
1694
  },
1685
1695
  }]));
1686
1696
  }
@@ -1743,18 +1753,20 @@ function renderGroupedTurnElements(
1743
1753
  fullActivityText: true,
1744
1754
  });
1745
1755
  if (state.plan.length > 0) elements.push(planPanel(state.plan));
1746
- if (historyPages.length > 0) {
1756
+ if (historyPages.length > 0 || (state.previewJournal && state.activitiesTruncated)) {
1747
1757
  elements.push(taskActionRow([{
1748
- text: `查看历史思考(共 ${historyPages.length + 1} 页)`,
1758
+ text: state.activitiesTruncated ? "查看历史思考" : `查看历史思考(共 ${historyPages.length + 1} 页)`,
1749
1759
  value: {
1750
1760
  action: "activity_history",
1751
1761
  turnId: state.turnId,
1752
- page: String(historyPages.length - 1),
1762
+ page: String(state.activitiesTruncated ? Number.MAX_SAFE_INTEGER : historyPages.length - 1),
1753
1763
  },
1754
1764
  }]));
1755
1765
  }
1756
- if (state.activitiesTruncated && visible.groups[0]?.kind !== "gap") elements.push(markdown("…"));
1757
- elements.push(...renderGroupedActivityGroups(visible.groups, state.projectCwd));
1766
+ const visibleGroups: GroupedTurnActivity[] = state.activitiesTruncated
1767
+ ? [{ kind: "gap", id: "gap:truncated" }, ...visible.groups]
1768
+ : visible.groups;
1769
+ elements.push(...renderGroupedActivityGroups(visibleGroups, state.projectCwd));
1758
1770
  if (state.fileSummary.length > 0) elements.push(fileSummaryPanel(state));
1759
1771
 
1760
1772
  if (state.approval) {
@@ -1847,6 +1859,10 @@ function renderActivity(
1847
1859
  if (activity.kind === "assistant" || (activity.kind === "reasoning" && activity.id.startsWith("commentary:"))) {
1848
1860
  const text = activity.text.trim();
1849
1861
  if (!text) return [];
1862
+ if (activity.id.startsWith("commentary:runtime-error:")) {
1863
+ const chunks = fullAssistantText ? splitText(text, ACTIVITY_TEXT_CHUNK) : [text];
1864
+ return chunks.map((chunk) => markdown(codeBlock(chunk, fullAssistantText ? ACTIVITY_TEXT_CHUNK : MAX_LIVE_ASSISTANT_TEXT)));
1865
+ }
1850
1866
  return fullAssistantText
1851
1867
  ? splitText(text, ACTIVITY_TEXT_CHUNK).map(markdown)
1852
1868
  : [markdown(truncateText(text, MAX_LIVE_ASSISTANT_TEXT))];
@@ -1909,17 +1925,24 @@ function renderGroupedActivityGroups(
1909
1925
  fullActivityText?: boolean;
1910
1926
  } = {},
1911
1927
  ): Record<string, unknown>[] {
1912
- return groups.flatMap((group) => {
1913
- if (group.kind === "gap") return [markdown("…")];
1914
- if (group.kind === "activity") {
1915
- return renderActivity(group.activity, projectCwd, options.fullActivityText === true);
1916
- }
1917
- if (group.tools.length === 0) {
1918
- const reasonings = group.latestReasoning ? [group.latestReasoning] : [];
1919
- return renderReasoningGroup(reasonings);
1928
+ const elements: Record<string, unknown>[] = [];
1929
+ let previousWasGap = false;
1930
+ for (const group of groups) {
1931
+ if (group.kind === "gap") {
1932
+ if (!previousWasGap) elements.push(markdown("…"));
1933
+ previousWasGap = true;
1934
+ continue;
1920
1935
  }
1921
- return [executionActivityPanel(group, projectCwd)];
1922
- });
1936
+ const rendered = group.kind === "activity"
1937
+ ? renderActivity(group.activity, projectCwd, options.fullActivityText === true)
1938
+ : group.tools.length === 0
1939
+ ? renderReasoningGroup(group.latestReasoning ? [group.latestReasoning] : [])
1940
+ : [executionActivityPanel(group, projectCwd)];
1941
+ if (rendered.length === 0) continue;
1942
+ elements.push(...rendered);
1943
+ previousWasGap = false;
1944
+ }
1945
+ return elements;
1923
1946
  }
1924
1947
 
1925
1948
  function groupTurnActivities(activities: TurnActivity[]): GroupedTurnActivity[] {
@@ -2140,7 +2163,10 @@ function groupedLiveActivityPage(
2140
2163
  }
2141
2164
 
2142
2165
  function isCommentaryGroup(group: GroupedTurnActivity): boolean {
2143
- return group.kind === "activity" && isCommentaryActivity(group.activity);
2166
+ return group.kind === "activity"
2167
+ && group.activity.kind !== "tool"
2168
+ && isCommentaryActivity(group.activity)
2169
+ && group.activity.text.trim().length > 0;
2144
2170
  }
2145
2171
 
2146
2172
  function groupsWithGaps(
@@ -262,6 +262,8 @@ function toIncomingMessage(
262
262
  const messageId = message.message_id ?? `${Date.now()}`;
263
263
  return {
264
264
  messageId,
265
+ ...(Number.isFinite(Number(message.create_time)) && Number(message.create_time) > 0
266
+ ? { createdAt: Number(message.create_time) } : {}),
265
267
  contextKey: chatId
266
268
  ? threadContext
267
269
  ? threadContextKey(chatId, threadId!)