@keyou007/agent-bot 0.1.24-alpha.2 → 0.1.24-alpha.4

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 (100) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +33 -5
  3. package/README.zh.md +32 -5
  4. package/dist/acp/AcpJsonRpcConnection.js +3 -1
  5. package/dist/acp/AcpJsonRpcConnection.js.map +1 -1
  6. package/dist/acp/AcpSessionManager.js +0 -14
  7. package/dist/acp/AcpSessionManager.js.map +1 -1
  8. package/dist/acp/acpTypes.js.map +1 -1
  9. package/dist/cli/SelfUpdateRunner.js.map +1 -1
  10. package/dist/cli/SelfUpdater.js +2 -0
  11. package/dist/cli/SelfUpdater.js.map +1 -1
  12. package/dist/codex/AppServerConnection.js +3 -1
  13. package/dist/codex/AppServerConnection.js.map +1 -1
  14. package/dist/codex/CodexEventMapper.js +28 -1
  15. package/dist/codex/CodexEventMapper.js.map +1 -1
  16. package/dist/codex/CodexRuntime.js +92 -0
  17. package/dist/codex/CodexRuntime.js.map +1 -1
  18. package/dist/commands/CommandRouter.js +5 -3
  19. package/dist/commands/CommandRouter.js.map +1 -1
  20. package/dist/feishu/CardRenderer.js +45 -23
  21. package/dist/feishu/CardRenderer.js.map +1 -1
  22. package/dist/feishu/FeishuConnector.js +2 -0
  23. package/dist/feishu/FeishuConnector.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 +497 -62
  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 +155 -6
  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 +466 -38
  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 +12 -1
  67. package/src/acp/AcpJsonRpcConnection.ts +3 -1
  68. package/src/acp/AcpSessionManager.ts +0 -17
  69. package/src/acp/acpTypes.ts +0 -1
  70. package/src/cli/SelfUpdateRunner.ts +1 -0
  71. package/src/cli/SelfUpdater.ts +2 -0
  72. package/src/codex/AppServerConnection.ts +3 -1
  73. package/src/codex/CodexEventMapper.ts +29 -1
  74. package/src/codex/CodexRuntime.ts +87 -0
  75. package/src/commands/CommandRouter.ts +4 -3
  76. package/src/commands/commandTypes.ts +1 -0
  77. package/src/feishu/CardRenderer.ts +43 -21
  78. package/src/feishu/FeishuConnector.ts +2 -0
  79. package/src/feishu/FeishuMessageClient.ts +44 -0
  80. package/src/feishu/FeishuTurnPresenter.ts +107 -31
  81. package/src/feishu/RecentMessageContext.ts +90 -0
  82. package/src/feishu/types.ts +5 -0
  83. package/src/index.ts +62 -22
  84. package/src/local-files/DiagramPreview.ts +173 -0
  85. package/src/local-files/LocalFileViewerServer.ts +129 -2
  86. package/src/local-files/PreviewSyntaxHighlight.ts +58 -0
  87. package/src/local-files/TurnPreviewPage.ts +537 -59
  88. package/src/presentation/OutboundRouter.ts +25 -7
  89. package/src/presentation/TurnStateReducer.ts +151 -7
  90. package/src/presentation/turnReasoning.ts +21 -0
  91. package/src/presentation/turnViewTypes.ts +19 -1
  92. package/src/proxy/ProxySessionController.ts +457 -27
  93. package/src/runtime/types.ts +21 -0
  94. package/src/state/StateStore.ts +45 -2
  95. package/src/state/TurnPreviewJournal.ts +398 -0
  96. package/src/state/migrations.ts +10 -0
  97. package/src/updates/AutomaticUpdatePreparer.ts +21 -7
  98. package/src/updates/ManualUpdateController.ts +116 -0
  99. package/src/updates/PublishedRelease.ts +49 -3
  100. 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.2",
3
+ "version": "0.1.24-alpha.4",
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.
@@ -109,12 +110,16 @@ These commands export only user Prompts and final answers through the latest com
109
110
 
110
111
  Feishu `/sessions [keyword]` lists and searches tasks across projects and all Providers, not just the App Server's current Provider. Switching Provider does not hide existing tasks; listing still uses paged metadata and the latest Turn summary only.
111
112
 
113
+ Feishu group renames update only the group's currently bound task title, not topic or historical tasks. Users may remove generated prefixes or use another format: recognized configured names or matching Agent prefixes yield the task title, otherwise the normalized complete group name is used. A renamed prefix does not change the task's Agent. New-group naming rules are unchanged.
114
+
112
115
  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.
113
116
 
114
117
  If a TraeX task stops producing output, check its progress card for a pending plan-mode confirmation. `Approve Plan` / `Enter Plan Mode`, `Reject`, and `Cancel Request` require the user's explicit choice, even with automatic permissions. Preview displays the plan but cannot approve it. Cancelling a request does not itself stop the task; waiting turns still block safe restart. Never approve, stop, or force-restart merely to clear that wait. Notifications dropped by an older running version are not automatically replayed after upgrading.
115
118
 
116
119
  ## Change Settings
117
120
 
121
+ Feishu slash commands accept unique prefixes: `/mo`, `/mod`, and `/mode` open the `/model` settings card and do not accept arguments. This is model selection, not an Agent mode switch. CLI `task model [model]` keeps its documented argument behavior.
122
+
118
123
  ```powershell
119
124
  agentbot task agent [name]
120
125
  agentbot task provider [provider]
@@ -144,6 +149,10 @@ agentbot task reset <turn-id>
144
149
 
145
150
  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
151
 
152
+ 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.
153
+
154
+ 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.
155
+
147
156
  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
157
 
149
158
  ## Files And Local Commands
@@ -156,7 +165,7 @@ agentbot task shell "<command>"
156
165
 
157
166
  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
167
 
159
- For group mention-only mode:
168
+ 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
169
 
161
170
  ```powershell
162
171
  agentbot task mute on|off
@@ -173,6 +182,8 @@ agentbot server restart --task <task> --reason "<reason>"
173
182
  agentbot server stop
174
183
  ```
175
184
 
185
+ 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.
186
+
176
187
  `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
188
 
178
189
  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
 
@@ -18,7 +18,6 @@ export interface RuntimeSession {
18
18
  acpSessionId: string;
19
19
  managed: ManagedAcpProcess;
20
20
  running: boolean;
21
- modes?: JsonValue;
22
21
  configOptions?: JsonValue;
23
22
  availableCommands?: JsonValue;
24
23
  onUpdate: (session: RuntimeSession, update: Record<string, JsonValue>) => void;
@@ -81,7 +80,6 @@ export class AcpSessionManager {
81
80
  acpSessionId: newSessionResult.sessionId,
82
81
  managed,
83
82
  running: false,
84
- modes: newSessionResult.modes,
85
83
  configOptions: newSessionResult.configOptions,
86
84
  onUpdate: input.onUpdate,
87
85
  onPermissionRequest: input.onPermissionRequest,
@@ -147,14 +145,6 @@ export class AcpSessionManager {
147
145
  this.processManager.stopAll();
148
146
  }
149
147
 
150
- async setMode(localSessionId: string, modeId: string): Promise<void> {
151
- const session = this.requireSession(localSessionId);
152
- await session.managed.connection.request("session/set_mode", {
153
- sessionId: session.acpSessionId,
154
- modeId,
155
- });
156
- }
157
-
158
148
  async setConfigOption(localSessionId: string, configId: string, value: string): Promise<JsonValue> {
159
149
  const session = this.requireSession(localSessionId);
160
150
  const result = await session.managed.connection.request<JsonValue>("session/set_config_option", {
@@ -242,13 +232,6 @@ export class AcpSessionManager {
242
232
  session.configOptions = update.configOptions;
243
233
  }
244
234
 
245
- if (updateType === "current_mode_update" && session.modes && isObject(session.modes)) {
246
- session.modes = {
247
- ...session.modes,
248
- currentModeId: update.modeId,
249
- };
250
- }
251
-
252
235
  if (updateType === "available_commands_update" && "availableCommands" in update) {
253
236
  session.availableCommands = update.availableCommands;
254
237
  }
@@ -63,7 +63,6 @@ export interface AcpInitializeResult {
63
63
 
64
64
  export interface AcpSessionNewResult {
65
65
  sessionId: string;
66
- modes?: JsonValue;
67
66
  configOptions?: JsonValue;
68
67
  }
69
68
 
@@ -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
  }
@@ -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[] }
@@ -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);
@@ -213,6 +232,15 @@ 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
+
216
244
  export function formatCodexError(error: unknown): string | undefined {
217
245
  if (!isRecord(error)) return undefined;
218
246
  const message = stringValue(error.message)?.trim();
@@ -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,
@@ -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
  });
@@ -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;
@@ -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
  }
@@ -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];
@@ -41,8 +42,6 @@ const COMMAND_INITIALISMS: Partial<Record<CommandName, string>> = {
41
42
  nosteer: "ns",
42
43
  };
43
44
 
44
- const DISABLED_COMMAND_NAMES = new Set(["mode", "modes"]);
45
-
46
45
  export class CommandRouter {
47
46
  parse(text: string): Command {
48
47
  const trimmed = text.trim();
@@ -121,6 +120,9 @@ export class CommandRouter {
121
120
  if (args.length === 0) return { type: "restart" };
122
121
  if (args.length === 1 && args[0] === "--force") return { type: "restart", force: true };
123
122
  throw new Error("/restart 只接受一个可选的 --force 参数。");
123
+ case "update":
124
+ if (args.length > 0) throw new Error("/update 不接受参数,请在卡片中选择版本。");
125
+ return { type: "update" };
124
126
  case "release":
125
127
  if (args.length > 0) throw new Error("/release 不接受参数。");
126
128
  return { type: "release" };
@@ -158,7 +160,6 @@ export class CommandRouter {
158
160
  function resolveCommandName(rawCommand: string): CommandName {
159
161
  const input = rawCommand.slice(1).toLowerCase();
160
162
  if (!input) throw unknownCommand(rawCommand);
161
- if (DISABLED_COMMAND_NAMES.has(input)) throw unknownCommand(rawCommand);
162
163
 
163
164
  const exact = COMMAND_NAMES.find((command) => command === input);
164
165
  if (exact) return exact;
@@ -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) {
@@ -1913,17 +1925,24 @@ function renderGroupedActivityGroups(
1913
1925
  fullActivityText?: boolean;
1914
1926
  } = {},
1915
1927
  ): Record<string, unknown>[] {
1916
- return groups.flatMap((group) => {
1917
- if (group.kind === "gap") return [markdown("…")];
1918
- if (group.kind === "activity") {
1919
- return renderActivity(group.activity, projectCwd, options.fullActivityText === true);
1920
- }
1921
- if (group.tools.length === 0) {
1922
- const reasonings = group.latestReasoning ? [group.latestReasoning] : [];
1923
- 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;
1924
1935
  }
1925
- return [executionActivityPanel(group, projectCwd)];
1926
- });
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;
1927
1946
  }
1928
1947
 
1929
1948
  function groupTurnActivities(activities: TurnActivity[]): GroupedTurnActivity[] {
@@ -2144,7 +2163,10 @@ function groupedLiveActivityPage(
2144
2163
  }
2145
2164
 
2146
2165
  function isCommentaryGroup(group: GroupedTurnActivity): boolean {
2147
- 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;
2148
2170
  }
2149
2171
 
2150
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!)
@@ -15,7 +15,10 @@ import {
15
15
  type MergedForwardMessageItem,
16
16
  type ReferencedMessageContent,
17
17
  } from "./MergedForwardMessage.js";
18
+ import { RECENT_CONTEXT_LIMIT, RECENT_CONTEXT_MAX_PAGES, RECENT_CONTEXT_PAGE_SIZE, recentMessagePageSchema, scanRecentContextMessages, selectRecentContextMessages } from "./RecentMessageContext.js";
18
19
  import type {
20
+ RecentMessageRequest,
21
+ RecentContextMessage,
19
22
  CreatedGroup,
20
23
  CreateGroupInput,
21
24
  FeishuOutbound,
@@ -251,6 +254,47 @@ export class FeishuMessageClient implements FeishuOutbound {
251
254
  return renderMergedForwardPrompt(messageId, items);
252
255
  }
253
256
 
257
+ async readRecentMessages(request: RecentMessageRequest): Promise<RecentContextMessage[]> {
258
+ const operation = "read recent group context";
259
+ try {
260
+ const token = await this.getTenantAccessToken();
261
+ const items: NonNullable<NonNullable<ReturnType<typeof recentMessagePageSchema.parse>["data"]>["items"]> = [];
262
+ let pageToken: string | undefined;
263
+ let selected: RecentContextMessage[] = [];
264
+ let reachedKnownMessage = false;
265
+ for (let page = 0; page < RECENT_CONTEXT_MAX_PAGES; page++) {
266
+ const url = new URL("https://open.feishu.cn/open-apis/im/v1/messages");
267
+ url.searchParams.set("container_id_type", request.threadId ? "thread" : "chat");
268
+ url.searchParams.set("container_id", request.threadId ?? request.chatId);
269
+ url.searchParams.set("sort_type", "ByCreateTimeDesc");
270
+ url.searchParams.set("page_size", String(RECENT_CONTEXT_PAGE_SIZE));
271
+ if (pageToken) url.searchParams.set("page_token", pageToken);
272
+ if (!request.threadId && request.beforeTimestamp) {
273
+ url.searchParams.set("end_time", String(Math.ceil(request.beforeTimestamp / 1000)));
274
+ }
275
+ const response = await fetch(url, {
276
+ headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(10_000),
277
+ });
278
+ const payload = recentMessagePageSchema.parse(await response.json());
279
+ if (!response.ok || payload.code !== 0) {
280
+ throw new FeishuApiError(payload.msg || "无法读取近期群消息;请检查飞书历史消息及群消息读取权限。", payload.code, { code: payload.code, msg: payload.msg ?? "" }, operation, response.status);
281
+ }
282
+ items.push(...(payload.data?.items ?? []));
283
+ if (request.beforeTimestamp || items.some((item) => item.message_id === request.beforeMessageId)) {
284
+ const scanned = scanRecentContextMessages(items, request);
285
+ selected = scanned.messages;
286
+ reachedKnownMessage = scanned.reachedKnownMessage;
287
+ }
288
+ const nextToken = payload.data?.page_token;
289
+ if (reachedKnownMessage || selected.length >= RECENT_CONTEXT_LIMIT || !payload.data?.has_more || !nextToken || nextToken === pageToken) break;
290
+ pageToken = nextToken;
291
+ }
292
+ return selectRecentContextMessages(items, request);
293
+ } catch (error) {
294
+ throw normalizeTransportError(error, operation);
295
+ }
296
+ }
297
+
254
298
  async readReferencedMessage(messageId: string): Promise<ReferencedMessageContent> {
255
299
  const items = await this.readMessageItems(messageId, "read referenced message");
256
300
  return renderReferencedMessage(messageId, items);