linkshell-cli 0.3.11 → 0.3.13

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.
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { spawn } from "node:child_process";
2
3
  import { homedir } from "node:os";
3
4
  import { basename, join, relative } from "node:path";
4
5
  import {
@@ -207,6 +208,8 @@ interface PendingStructuredInputWaiter {
207
208
 
208
209
  const PERMISSION_TIMEOUT_MS = 5 * 60_000;
209
210
  const MAX_TIMELINE_ITEMS = 200;
211
+ const MAX_SNAPSHOT_ITEMS = 80;
212
+ const MAX_SNAPSHOT_TEXT_BYTES = 128 * 1024;
210
213
 
211
214
  function id(prefix: string): string {
212
215
  return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -221,6 +224,71 @@ function stringify(value: unknown): string {
221
224
  }
222
225
  }
223
226
 
227
+ function truncateUtf8(value: string | undefined, maxBytes = MAX_SNAPSHOT_TEXT_BYTES): string | undefined {
228
+ if (!value) return value;
229
+ if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
230
+ let end = Math.min(value.length, maxBytes);
231
+ while (end > 0 && Buffer.byteLength(value.slice(0, end), "utf8") > maxBytes) {
232
+ end = Math.floor(end * 0.9);
233
+ }
234
+ return `${value.slice(0, end)}\n\n[truncated by LinkShell: original ${Buffer.byteLength(value, "utf8")} bytes]`;
235
+ }
236
+
237
+ function snapshotContentBlocks(
238
+ blocks: AgentContentBlock[] | undefined,
239
+ options: { stripImages?: boolean } = {},
240
+ ): AgentContentBlock[] | undefined {
241
+ if (!blocks) return undefined;
242
+ return blocks.map((block) =>
243
+ block.type === "image" && options.stripImages !== false
244
+ ? { ...block, data: undefined, text: block.text || "图片附件" }
245
+ : { ...block, text: truncateUtf8(block.text) },
246
+ );
247
+ }
248
+
249
+ function snapshotTimelineItem(
250
+ item: AgentTimelineItem,
251
+ options: { stripImages?: boolean } = {},
252
+ ): AgentTimelineItem {
253
+ return {
254
+ ...item,
255
+ content: snapshotContentBlocks(item.content, options),
256
+ text: truncateUtf8(item.text),
257
+ toolCall: item.toolCall
258
+ ? {
259
+ ...item.toolCall,
260
+ input: truncateUtf8(item.toolCall.input),
261
+ output: truncateUtf8(item.toolCall.output),
262
+ }
263
+ : undefined,
264
+ commandExecution: item.commandExecution
265
+ ? {
266
+ ...item.commandExecution,
267
+ command: truncateUtf8(item.commandExecution.command, 16 * 1024),
268
+ output: truncateUtf8(item.commandExecution.output),
269
+ }
270
+ : undefined,
271
+ fileChange: item.fileChange
272
+ ? {
273
+ ...item.fileChange,
274
+ diff: truncateUtf8(item.fileChange.diff),
275
+ summary: truncateUtf8(item.fileChange.summary),
276
+ }
277
+ : undefined,
278
+ permission: item.permission
279
+ ? {
280
+ ...item.permission,
281
+ toolInput: truncateUtf8(item.permission.toolInput),
282
+ context: truncateUtf8(item.permission.context),
283
+ }
284
+ : undefined,
285
+ };
286
+ }
287
+
288
+ function snapshotTimelineItems(items: AgentTimelineItem[]): AgentTimelineItem[] {
289
+ return items.slice(-MAX_SNAPSHOT_ITEMS).map((item) => snapshotTimelineItem(item));
290
+ }
291
+
224
292
  function asRecord(value: unknown): Record<string, unknown> | undefined {
225
293
  return typeof value === "object" && value ? value as Record<string, unknown> : undefined;
226
294
  }
@@ -768,6 +836,30 @@ interface ProviderRuntimeCapabilities {
768
836
  const ALL_REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const;
769
837
  const CLAUDE_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"] as const;
770
838
  const AGENT_PERMISSION_MODES: AgentPermissionMode[] = ["read_only", "workspace_write", "full_access"];
839
+ const COMMAND_OUTPUT_MAX_BYTES = 96 * 1024;
840
+ const LINKSHELL_NATIVE_COMMANDS: Array<{
841
+ name: string;
842
+ description: string;
843
+ category: string;
844
+ argsMode?: AgentCommandDescriptor["argsMode"];
845
+ destructive?: boolean;
846
+ providers?: AgentProvider[];
847
+ }> = [
848
+ { name: "status", description: "Show current Agent and workspace status", category: "LinkShell", argsMode: "none" },
849
+ { name: "plan", description: "Enter Plan mode for the next turn", category: "Agent", argsMode: "none" },
850
+ { name: "exit-plan", description: "Exit Plan mode", category: "Agent", argsMode: "none" },
851
+ { name: "review", description: "Ask the Agent to review current local changes", category: "Agent", argsMode: "optional" },
852
+ { name: "subagents", description: "Ask the Agent to split work across subagents when useful", category: "Agent", argsMode: "optional" },
853
+ { name: "compact", description: "Compact the active Codex context", category: "Codex", argsMode: "none", providers: ["codex"] },
854
+ { name: "clear", description: "Start a fresh Agent context for this conversation", category: "Agent", argsMode: "none", destructive: true },
855
+ { name: "git-status", description: "Show branch and working tree status", category: "Git", argsMode: "none" },
856
+ { name: "git-diff", description: "Show a compact diffstat for current changes", category: "Git", argsMode: "none" },
857
+ { name: "git-commit", description: "Commit staged changes with the given message", category: "Git", argsMode: "required" },
858
+ { name: "git-pull", description: "Pull with fast-forward only", category: "Git", argsMode: "none" },
859
+ { name: "git-push", description: "Push the current branch", category: "Git", argsMode: "none" },
860
+ { name: "git-stash", description: "Stash current working tree changes", category: "Git", argsMode: "optional" },
861
+ { name: "git-stash-pop", description: "Pop the latest stash", category: "Git", argsMode: "none" },
862
+ ];
771
863
  const CLAUDE_REMOTE_HIDDEN_COMMANDS = new Set([
772
864
  "add-dir",
773
865
  "agents",
@@ -1040,17 +1132,30 @@ function customClaudeCommands(cwd: string): AgentCommandDescriptor[] {
1040
1132
 
1041
1133
  function defaultProviderCommands(provider: AgentProvider, cwd: string, enabled: boolean): AgentCommandDescriptor[] {
1042
1134
  const disabledReason = enabled ? undefined : `${providerLabel(provider)} 未安装或启动失败`;
1135
+ const linkshellCommands = LINKSHELL_NATIVE_COMMANDS
1136
+ .filter((command) => !command.providers || command.providers.includes(provider))
1137
+ .map((command) => makeCommand({
1138
+ provider,
1139
+ name: command.name,
1140
+ description: command.description,
1141
+ source: "linkshell",
1142
+ category: command.category,
1143
+ argsMode: command.argsMode ?? "optional",
1144
+ destructive: command.destructive,
1145
+ disabledReason,
1146
+ executionKind: "native",
1147
+ }));
1043
1148
  if (provider === "codex") {
1044
- return [];
1149
+ return linkshellCommands;
1045
1150
  }
1046
1151
  if (provider === "claude") {
1047
1152
  const custom = customClaudeCommands(cwd).map((command) => ({
1048
1153
  ...command,
1049
1154
  disabledReason: command.disabledReason ?? disabledReason,
1050
1155
  }));
1051
- return custom;
1156
+ return [...linkshellCommands, ...custom];
1052
1157
  }
1053
- return [];
1158
+ return linkshellCommands;
1054
1159
  }
1055
1160
 
1056
1161
  function mergeCommands(...groups: Array<AgentCommandDescriptor[] | undefined>): AgentCommandDescriptor[] {
@@ -1069,6 +1174,81 @@ function mergeCommands(...groups: Array<AgentCommandDescriptor[] | undefined>):
1069
1174
  return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
1070
1175
  }
1071
1176
 
1177
+ function isGitNativeCommand(name: string): boolean {
1178
+ return name === "git-status" ||
1179
+ name === "git-diff" ||
1180
+ name === "git-commit" ||
1181
+ name === "git-pull" ||
1182
+ name === "git-push" ||
1183
+ name === "git-stash" ||
1184
+ name === "git-stash-pop";
1185
+ }
1186
+
1187
+ function gitCommandArgs(name: string, args?: string): { display: string; argv: string[] } {
1188
+ const message = args?.trim();
1189
+ switch (name) {
1190
+ case "git-status":
1191
+ return { display: "git status --short --branch", argv: ["status", "--short", "--branch"] };
1192
+ case "git-diff":
1193
+ return { display: "git diff --stat", argv: ["diff", "--stat"] };
1194
+ case "git-commit":
1195
+ if (!message) throw new Error("请先输入提交信息,例如 /git-commit fix mobile agent timeline");
1196
+ return { display: `git commit -m ${JSON.stringify(message)}`, argv: ["commit", "-m", message] };
1197
+ case "git-pull":
1198
+ return { display: "git pull --ff-only", argv: ["pull", "--ff-only"] };
1199
+ case "git-push":
1200
+ return { display: "git push", argv: ["push"] };
1201
+ case "git-stash":
1202
+ return {
1203
+ display: "git stash push -u",
1204
+ argv: ["stash", "push", "-u", "-m", message || "LinkShell mobile stash"],
1205
+ };
1206
+ case "git-stash-pop":
1207
+ return { display: "git stash pop", argv: ["stash", "pop"] };
1208
+ default:
1209
+ throw new Error(`未知 Git 命令:/${name}`);
1210
+ }
1211
+ }
1212
+
1213
+ function runProcess(
1214
+ command: string,
1215
+ args: string[],
1216
+ options: { cwd: string; maxBytes?: number },
1217
+ ): Promise<{ output: string; exitCode: number | null; signal: NodeJS.Signals | null }> {
1218
+ return new Promise((resolve, reject) => {
1219
+ const child = spawn(command, args, {
1220
+ cwd: options.cwd,
1221
+ stdio: ["ignore", "pipe", "pipe"],
1222
+ windowsHide: true,
1223
+ });
1224
+ const maxBytes = options.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES;
1225
+ let output = "";
1226
+ let bytes = 0;
1227
+ let truncated = false;
1228
+ const append = (chunk: Buffer) => {
1229
+ if (bytes >= maxBytes) {
1230
+ truncated = true;
1231
+ return;
1232
+ }
1233
+ const remaining = maxBytes - bytes;
1234
+ const slice = chunk.byteLength > remaining ? chunk.subarray(0, remaining) : chunk;
1235
+ output += slice.toString("utf8");
1236
+ bytes += slice.byteLength;
1237
+ if (slice.byteLength < chunk.byteLength) truncated = true;
1238
+ };
1239
+ child.stdout.on("data", append);
1240
+ child.stderr.on("data", append);
1241
+ child.once("error", reject);
1242
+ child.once("close", (exitCode, signal) => {
1243
+ resolve({
1244
+ output: `${output.trimEnd()}${truncated ? "\n\n[truncated by LinkShell]" : ""}`,
1245
+ exitCode,
1246
+ signal,
1247
+ });
1248
+ });
1249
+ });
1250
+ }
1251
+
1072
1252
  function runtimeCommands(provider: AgentProvider, value: unknown): AgentCommandDescriptor[] {
1073
1253
  const raw = asRecord(value);
1074
1254
  const commandsValue =
@@ -1583,7 +1763,7 @@ export class AgentWorkspaceProxy {
1583
1763
  hostDeviceId: this.input.hostDeviceId,
1584
1764
  payload: {
1585
1765
  conversation: existingConversation,
1586
- snapshot: this.timelines.get(existingConversation.id) ?? [],
1766
+ snapshot: snapshotTimelineItems(this.timelines.get(existingConversation.id) ?? []),
1587
1767
  },
1588
1768
  }));
1589
1769
  return existingConversation;
@@ -1639,7 +1819,7 @@ export class AgentWorkspaceProxy {
1639
1819
  this.input.send(createEnvelope({
1640
1820
  type: "agent.v2.conversation.opened",
1641
1821
  hostDeviceId: this.input.hostDeviceId,
1642
- payload: { conversation, snapshot: this.timelines.get(conversation.id) ?? [] },
1822
+ payload: { conversation, snapshot: snapshotTimelineItems(this.timelines.get(conversation.id) ?? []) },
1643
1823
  }));
1644
1824
  return conversation;
1645
1825
  } catch (error) {
@@ -1691,7 +1871,7 @@ export class AgentWorkspaceProxy {
1691
1871
  this.input.send(createEnvelope({
1692
1872
  type: "agent.v2.conversation.opened",
1693
1873
  hostDeviceId: this.input.hostDeviceId,
1694
- payload: { conversation, snapshot: this.timelines.get(conversation.id) ?? [] },
1874
+ payload: { conversation, snapshot: snapshotTimelineItems(this.timelines.get(conversation.id) ?? []) },
1695
1875
  }));
1696
1876
  return conversation;
1697
1877
  }
@@ -1918,14 +2098,8 @@ export class AgentWorkspaceProxy {
1918
2098
  return;
1919
2099
  }
1920
2100
 
1921
- if (conversation.provider !== "codex") {
1922
- this.addItem(conversation.id, {
1923
- id: id("error"),
1924
- conversationId: conversation.id,
1925
- type: "error",
1926
- error: `${command.title} 暂无 ${providerLabel(conversation.provider)} 原生实现。`,
1927
- createdAt: now,
1928
- });
2101
+ if (isGitNativeCommand(command.name)) {
2102
+ await this.executeGitNativeCommand(conversation, command.name, args);
1929
2103
  return;
1930
2104
  }
1931
2105
 
@@ -1942,6 +2116,22 @@ export class AgentWorkspaceProxy {
1942
2116
  return;
1943
2117
  }
1944
2118
 
2119
+ if (command.name === "review" || command.name === "subagents") {
2120
+ const prompt = command.name === "review"
2121
+ ? args || "Review the current local changes."
2122
+ : args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results.";
2123
+ await this.sendPrompt({
2124
+ conversationId: conversation.id,
2125
+ clientMessageId: id(command.name),
2126
+ contentBlocks: [{ type: "text", text: prompt }],
2127
+ model: conversation.model,
2128
+ reasoningEffort: conversation.reasoningEffort,
2129
+ permissionMode: conversation.permissionMode,
2130
+ collaborationMode: conversation.collaborationMode,
2131
+ });
2132
+ return;
2133
+ }
2134
+
1945
2135
  if (command.name === "compact") {
1946
2136
  if (!(client instanceof AcpClient)) throw new Error("当前 Codex runtime 不支持原生 compact。");
1947
2137
  conversation.status = "running";
@@ -1979,22 +2169,6 @@ export class AgentWorkspaceProxy {
1979
2169
  return;
1980
2170
  }
1981
2171
 
1982
- if (command.name === "review" || command.name === "subagents") {
1983
- const prompt = command.name === "review"
1984
- ? args || "Review the current local changes."
1985
- : args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results.";
1986
- await this.sendPrompt({
1987
- conversationId: conversation.id,
1988
- clientMessageId: id(command.name),
1989
- contentBlocks: [{ type: "text", text: prompt }],
1990
- model: conversation.model,
1991
- reasoningEffort: conversation.reasoningEffort,
1992
- permissionMode: conversation.permissionMode,
1993
- collaborationMode: conversation.collaborationMode,
1994
- });
1995
- return;
1996
- }
1997
-
1998
2172
  throw new Error(`命令暂未实现:/${command.name}`);
1999
2173
  } catch (error) {
2000
2174
  const message = error instanceof Error ? error.message : String(error);
@@ -2009,6 +2183,46 @@ export class AgentWorkspaceProxy {
2009
2183
  }
2010
2184
  }
2011
2185
 
2186
+ private async executeGitNativeCommand(
2187
+ conversation: AgentConversation,
2188
+ commandName: string,
2189
+ args?: string,
2190
+ ): Promise<void> {
2191
+ const git = gitCommandArgs(commandName, args);
2192
+ const toolId = id(commandName);
2193
+ const now = Date.now();
2194
+ conversation.status = "running";
2195
+ conversation.lastMessagePreview = git.display;
2196
+ conversation.lastActivityAt = now;
2197
+ this.emitConversation(conversation);
2198
+ this.upsertTool(conversation.id, {
2199
+ id: toolId,
2200
+ name: "命令",
2201
+ input: `${git.display}\n\ncwd: ${conversation.cwd}`,
2202
+ createdAt: now,
2203
+ status: "running",
2204
+ });
2205
+ const result = await runProcess("git", git.argv, {
2206
+ cwd: conversation.cwd,
2207
+ maxBytes: COMMAND_OUTPUT_MAX_BYTES,
2208
+ });
2209
+ const ok = result.exitCode === 0;
2210
+ this.upsertTool(conversation.id, {
2211
+ id: toolId,
2212
+ name: "命令",
2213
+ input: `${git.display}\n\ncwd: ${conversation.cwd}`,
2214
+ output: result.output || (ok ? "完成" : `退出码 ${result.exitCode ?? "unknown"}`),
2215
+ createdAt: now,
2216
+ status: ok ? "completed" : "failed",
2217
+ });
2218
+ conversation.status = ok ? "idle" : "error";
2219
+ conversation.lastMessagePreview = ok
2220
+ ? `${git.display} 完成`
2221
+ : `${git.display} 失败`;
2222
+ conversation.lastActivityAt = Date.now();
2223
+ this.emitConversation(conversation);
2224
+ }
2225
+
2012
2226
  private handleRequest(method: string, params: unknown): Promise<unknown> | unknown {
2013
2227
  if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") {
2014
2228
  return this.handleStructuredInput(params, true);
@@ -2935,7 +3149,7 @@ export class AgentWorkspaceProxy {
2935
3149
  this.input.send(createEnvelope({
2936
3150
  type: "agent.v2.event",
2937
3151
  hostDeviceId: this.input.hostDeviceId,
2938
- payload: { conversationId, conversation, item },
3152
+ payload: { conversationId, conversation, item: snapshotTimelineItem(item, { stripImages: false }) },
2939
3153
  }));
2940
3154
  }
2941
3155
 
@@ -2994,8 +3208,8 @@ export class AgentWorkspaceProxy {
2994
3208
  }
2995
3209
  const conversations = [...this.conversations.values()];
2996
3210
  const items = conversationId
2997
- ? this.timelines.get(conversationId) ?? []
2998
- : [...this.timelines.values()].flat();
3211
+ ? snapshotTimelineItems(this.timelines.get(conversationId) ?? [])
3212
+ : [];
2999
3213
  this.input.send(createEnvelope({
3000
3214
  type: "agent.v2.snapshot",
3001
3215
  hostDeviceId: this.input.hostDeviceId,
@@ -62,6 +62,8 @@ interface CodexIndexEntry {
62
62
  updatedAt?: number;
63
63
  }
64
64
 
65
+ type StoredToolStatus = NonNullable<StoredAgentTimelineItem["toolCall"]>["status"];
66
+
65
67
  function asRecord(value: unknown): Record<string, unknown> | undefined {
66
68
  return value && typeof value === "object" && !Array.isArray(value)
67
69
  ? value as Record<string, unknown>
@@ -366,7 +368,8 @@ function historyToolName(name: string | undefined): string {
366
368
  return name;
367
369
  }
368
370
 
369
- function historyToolKind(name: string | undefined): "tool_activity" | "command_execution" {
371
+ function historyToolKind(name: string | undefined): "tool_activity" | "command_execution" | "file_change" {
372
+ if (name === "apply_patch") return "file_change";
370
373
  return name?.endsWith("exec_command") || name?.endsWith("write_stdin") ? "command_execution" : "tool_activity";
371
374
  }
372
375
 
@@ -390,6 +393,75 @@ function commandFromCodexTool(name: string | undefined, rawInput: unknown, outpu
390
393
  return { command, cwd, output, status: output === undefined ? "running" : "completed" };
391
394
  }
392
395
 
396
+ function patchTextFromCodexTool(name: string | undefined, rawInput: unknown, input: string | undefined): string | undefined {
397
+ if (name !== "apply_patch") return undefined;
398
+ if (typeof rawInput === "string") return rawInput;
399
+ const record = asRecord(rawInput);
400
+ for (const key of ["patch", "input", "text", "content"]) {
401
+ const value = record?.[key];
402
+ if (typeof value === "string" && value.trim()) return value;
403
+ }
404
+ return input;
405
+ }
406
+
407
+ function fileChangeFromApplyPatch(
408
+ patchText: string | undefined,
409
+ status: StoredToolStatus,
410
+ ): StoredAgentTimelineItem["fileChange"] | undefined {
411
+ if (!patchText?.trim()) return undefined;
412
+ const entries: NonNullable<StoredAgentTimelineItem["fileChange"]>["entries"] = [];
413
+ let current: NonNullable<StoredAgentTimelineItem["fileChange"]>["entries"][number] | undefined;
414
+
415
+ const flush = () => {
416
+ if (!current?.path) return;
417
+ const existing = entries.find((entry) => entry.path === current!.path);
418
+ if (existing) {
419
+ existing.added = (existing.added ?? 0) + (current.added ?? 0);
420
+ existing.removed = (existing.removed ?? 0) + (current.removed ?? 0);
421
+ existing.kind ??= current.kind;
422
+ } else {
423
+ entries.push(current);
424
+ }
425
+ };
426
+
427
+ for (const rawLine of patchText.split(/\r?\n/)) {
428
+ const add = rawLine.match(/^\*\*\* Add File:\s+(.+)$/);
429
+ const update = rawLine.match(/^\*\*\* Update File:\s+(.+)$/);
430
+ const del = rawLine.match(/^\*\*\* Delete File:\s+(.+)$/);
431
+ const move = rawLine.match(/^\*\*\* Move to:\s+(.+)$/);
432
+ if (add || update || del) {
433
+ flush();
434
+ current = {
435
+ path: (add?.[1] ?? update?.[1] ?? del?.[1] ?? "").trim(),
436
+ kind: add ? "create" : del ? "delete" : "update",
437
+ added: 0,
438
+ removed: 0,
439
+ };
440
+ continue;
441
+ }
442
+ if (move?.[1] && current) {
443
+ current.path = move[1].trim();
444
+ current.kind = "move";
445
+ continue;
446
+ }
447
+ if (!current) continue;
448
+ if (rawLine.startsWith("+") && !rawLine.startsWith("+++")) {
449
+ current.added = (current.added ?? 0) + 1;
450
+ } else if (rawLine.startsWith("-") && !rawLine.startsWith("---")) {
451
+ current.removed = (current.removed ?? 0) + 1;
452
+ }
453
+ }
454
+ flush();
455
+
456
+ if (entries.length === 0) return undefined;
457
+ return {
458
+ entries,
459
+ diff: patchText,
460
+ summary: entries.map((entry) => [entry.kind, entry.path].filter(Boolean).join(" ")).join("\n"),
461
+ status,
462
+ };
463
+ }
464
+
393
465
  function upsertHistoryTool(
394
466
  itemsById: Map<string, StoredAgentTimelineItem>,
395
467
  conversationId: string,
@@ -402,21 +474,27 @@ function upsertHistoryTool(
402
474
  const id = `history-tool:${callId}`;
403
475
  const existing = itemsById.get(id);
404
476
  const commandExecution = commandFromCodexTool(name, rawInput, existing?.commandExecution?.output);
477
+ const status: StoredToolStatus = existing?.toolCall?.status ?? "running";
478
+ const fileChange = fileChangeFromApplyPatch(patchTextFromCodexTool(name, rawInput, input), status);
405
479
  itemsById.set(id, {
406
480
  id,
407
481
  conversationId,
408
482
  type: "tool_call",
409
- kind: historyToolKind(name),
483
+ kind: fileChange ? "file_change" : historyToolKind(name),
410
484
  itemId: callId,
411
485
  toolCall: {
412
486
  id: callId,
413
- name: historyToolName(name),
414
- input: input ?? existing?.toolCall?.input,
487
+ name: fileChange ? "文件修改" : historyToolName(name),
488
+ input: fileChange?.summary ?? input ?? existing?.toolCall?.input,
415
489
  output: existing?.toolCall?.output,
416
490
  createdAt: existing?.toolCall?.createdAt ?? createdAt,
417
- status: existing?.toolCall?.status ?? "running",
491
+ status,
418
492
  },
419
493
  commandExecution: commandExecution ?? existing?.commandExecution,
494
+ fileChange: fileChange ?? existing?.fileChange,
495
+ text: fileChange
496
+ ? `已编辑 ${fileChange.entries.length} 个文件`
497
+ : existing?.text,
420
498
  createdAt: existing?.createdAt ?? createdAt,
421
499
  updatedAt: createdAt,
422
500
  metadata: { source: "device-history", provider: "codex" },
@@ -435,6 +513,9 @@ function completeHistoryTool(
435
513
  const commandExecution = existing?.commandExecution
436
514
  ? { ...existing.commandExecution, output, status: "completed" as const }
437
515
  : undefined;
516
+ const fileChange = existing?.fileChange
517
+ ? { ...existing.fileChange, summary: existing.fileChange.summary ?? output, status: "completed" as const }
518
+ : undefined;
438
519
  itemsById.set(id, {
439
520
  id,
440
521
  conversationId,
@@ -450,6 +531,8 @@ function completeHistoryTool(
450
531
  status: "completed",
451
532
  },
452
533
  commandExecution,
534
+ fileChange,
535
+ text: fileChange ? existing?.text ?? `已编辑 ${fileChange.entries.length} 个文件` : existing?.text,
453
536
  createdAt: existing?.createdAt ?? createdAt,
454
537
  updatedAt: createdAt,
455
538
  metadata: { source: "device-history", provider: "codex" },