lark-remote 0.5.20 → 0.5.21

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 (55) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/bridge/inbound-media.js +71 -45
  3. package/dist/bridge/index.d.ts +9 -0
  4. package/dist/bridge/index.js +230 -203
  5. package/dist/card/approval-render.js +32 -33
  6. package/dist/card/card-budget.js +123 -98
  7. package/dist/card/run-renderer.js +82 -68
  8. package/dist/card/run-state.d.ts +0 -14
  9. package/dist/card/run-state.js +335 -207
  10. package/dist/card/tool-render.js +20 -16
  11. package/dist/card/usage-format.d.ts +12 -9
  12. package/dist/card/usage-format.js +111 -97
  13. package/dist/common/text-width.js +36 -24
  14. package/dist/common/tool-summary.js +132 -90
  15. package/dist/index.js +226 -190
  16. package/dist/message-handlers.js +253 -236
  17. package/dist/router/commands/ls.d.ts +3 -0
  18. package/dist/router/commands/ls.js +256 -175
  19. package/dist/router/commands/order.js +184 -159
  20. package/dist/router/commands/resume.js +189 -133
  21. package/dist/router/commands/ws.js +219 -174
  22. package/dist/router/index.js +192 -201
  23. package/dist/runner/bash/runner.js +2 -4
  24. package/dist/runner/codex/app-server/translator.d.ts +2 -0
  25. package/dist/runner/codex/app-server/translator.js +287 -221
  26. package/dist/runner/common/acp/base-translator.js +19 -17
  27. package/dist/runner/common/jsonl-stream.js +154 -187
  28. package/dist/runner/common/jsonrpc/client.d.ts +29 -0
  29. package/dist/runner/common/jsonrpc/client.js +43 -12
  30. package/dist/runner/common/spawning-runner.d.ts +5 -0
  31. package/dist/runner/common/spawning-runner.js +8 -3
  32. package/dist/runner/common/turn-started.d.ts +8 -0
  33. package/dist/runner/common/turn-started.js +11 -0
  34. package/dist/runner/dsh/approval.js +84 -51
  35. package/dist/runner/dsh/client.d.ts +7 -9
  36. package/dist/runner/dsh/client.js +356 -287
  37. package/dist/runner/dsh/mux-stream.js +28 -8
  38. package/dist/runner/dsh/runner.d.ts +4 -3
  39. package/dist/runner/dsh/runner.js +88 -64
  40. package/dist/runner/dsh/translator.d.ts +1 -31
  41. package/dist/runner/dsh/translator.js +180 -178
  42. package/dist/runner/kimi/acp/runner.js +81 -44
  43. package/dist/runner/opencode/acp/error-monitor.js +5 -5
  44. package/dist/runner/pi/rpc/client.js +4 -13
  45. package/dist/runner/pi/rpc/translator.js +61 -60
  46. package/dist/session/claude/sessions.js +113 -101
  47. package/dist/session/codex/rollout-reader.js +179 -180
  48. package/dist/session/dsh/sessions.js +281 -231
  49. package/dist/session/kimi/sessions.d.ts +0 -7
  50. package/dist/session/kimi/sessions.js +129 -109
  51. package/dist/session/opencode/sessions.d.ts +0 -11
  52. package/dist/session/opencode/sessions.js +161 -137
  53. package/dist/session/session-store.d.ts +8 -0
  54. package/dist/session/session-store.js +98 -98
  55. package/package.json +1 -1
@@ -51,77 +51,98 @@ export function reduceRunState(state, event) {
51
51
  const blocks = capStaleTextBlocks(next.blocks);
52
52
  return blocks === next.blocks ? next : { ...next, blocks };
53
53
  }
54
- /** 单事件 → 单次状态迁移。窗口后置裁剪统一由 `reduceRunState` 做,这里不管。 */
55
- function reduceRunStateInner(state, event) {
56
- // 只有终态才阻止事件处理。finalizing 是非终态,
57
- // 仍需累积 assistant 内容(thinking、text、tool_use、后台任务输出)。
58
- const terminal = state.terminal;
59
- if (isRunTerminal(terminal)) {
60
- return state;
54
+ // ── 内联分支归约器(模块顶层命名函数)──────────────────────────────────────
55
+ // 原来 goal_state / approval_* 五个分支直接写在 reduceRunStateInner 里,连同
56
+ // 内部 .map/.filter 箭头闭包一起把 CC 顶到 29。抽成命名函数后外层只留查表。
57
+ function reduceGoalStateEvent(state, event) {
58
+ const e = event;
59
+ // 覆盖写:每次观测都是宿主的整份快照(phase/轮次/目标全文),不做增量。
60
+ return { ...state, goal: e.goal ?? undefined, goalKnown: e.known };
61
+ }
62
+ function reduceApprovalRequestedEvent(state, event) {
63
+ const e = event;
64
+ const approvals = state.approvals ?? [];
65
+ const next = { view: e.view, expired: false };
66
+ let idx = -1;
67
+ for (let i = 0; i < approvals.length; i++) {
68
+ if (approvals[i].view.requestId === e.requestId) {
69
+ idx = i;
70
+ break;
71
+ }
61
72
  }
62
- if (event.type === 'system')
63
- return reduceSystemEvent(state, event);
64
- if (event.type === 'run_status')
65
- return reduceRunStatusEvent(state, event);
66
- if (event.type === 'goal_state') {
67
- // 覆盖写:每次观测都是宿主的整份快照(phase/轮次/目标全文),不做增量。
68
- return { ...state, goal: event.goal ?? undefined, goalKnown: event.known };
73
+ if (idx >= 0) {
74
+ // 同一 requestId 重复到达:原地替换,保持槽位顺序。
75
+ const replaced = approvals.slice();
76
+ replaced[idx] = next;
77
+ return { ...state, approvals: replaced };
69
78
  }
70
- if (event.type === 'background_jobs')
71
- return reduceBackgroundJobsEvent(state, event);
72
- if (event.type === 'result')
73
- return reduceResultEvent(state, event);
74
- if (event.type === 'final_answer')
75
- return reduceFinalAnswerEvent(state, event);
76
- if (event.type === 'assistant')
77
- return reduceAssistantEvent(state, event);
78
- if (event.type === 'user')
79
- return reduceToolResultEvent(state, event);
80
- if (event.type === 'plan')
81
- return reducePlanEvent(state, event);
82
- if (event.type === 'file_change')
83
- return reduceFileChangeEvent(state, event);
84
- if (event.type === 'notice')
85
- return reduceNoticeEvent(state, event);
86
- if (event.type === 'session_info')
87
- return reduceSessionInfoEvent(state, event);
88
- if (event.type === 'turn_started')
89
- return reduceTurnStartedEvent(state, event);
90
- if (event.type === 'turn_diff')
91
- return reduceTurnDiffEvent(state, event);
92
- if (event.type === 'approval_requested') {
93
- const approvals = state.approvals ?? [];
94
- const next = { view: event.view, expired: false };
95
- const idx = approvals.findIndex((a) => a.view.requestId === event.requestId);
96
- if (idx >= 0) {
97
- // 同一 requestId 重复到达:原地替换,保持槽位顺序。
98
- return {
99
- ...state,
100
- approvals: approvals.map((a, i) => (i === idx ? next : a)),
101
- };
102
- }
103
- return { ...state, approvals: [...approvals, next] };
79
+ return { ...state, approvals: [...approvals, next] };
80
+ }
81
+ function reduceApprovalResolvedEvent(state, event) {
82
+ const e = event;
83
+ const current = state.approvals ?? [];
84
+ const approvals = [];
85
+ for (const a of current) {
86
+ if (a.view.requestId !== e.requestId)
87
+ approvals.push(a);
104
88
  }
105
- if (event.type === 'approval_resolved') {
106
- return {
107
- ...state,
108
- approvals: (state.approvals ?? []).filter((a) => a.view.requestId !== event.requestId),
109
- };
89
+ return { ...state, approvals };
90
+ }
91
+ function reduceApprovalViewUpdatedEvent(state, event) {
92
+ const e = event;
93
+ const current = state.approvals ?? [];
94
+ const approvals = [];
95
+ for (const a of current) {
96
+ approvals.push(a.view.requestId === e.requestId ? { ...a, view: e.view } : a);
110
97
  }
111
- if (event.type === 'approval_view_updated') {
112
- return {
113
- ...state,
114
- approvals: (state.approvals ?? []).map((a) => a.view.requestId === event.requestId ? { ...a, view: event.view } : a),
115
- };
98
+ return { ...state, approvals };
99
+ }
100
+ function reduceApprovalExpiredEvent(state, event) {
101
+ const e = event;
102
+ const current = state.approvals ?? [];
103
+ const approvals = [];
104
+ for (const a of current) {
105
+ approvals.push(a.view.requestId === e.requestId ? { ...a, expired: true } : a);
116
106
  }
117
- if (event.type === 'approval_expired') {
118
- return {
119
- ...state,
120
- approvalExpired: true,
121
- approvals: (state.approvals ?? []).map((a) => a.view.requestId === event.requestId ? { ...a, expired: true } : a),
122
- };
107
+ return { ...state, approvalExpired: true, approvals };
108
+ }
109
+ /**
110
+ * event.type → 归约器。用 Map 而非对象字面量:`constructor`/`toString` 这类
111
+ * 事件名会命中 Object.prototype。
112
+ *
113
+ * 表里的归约器各自持有窄事件参数(如 `ResultEvent`),而查表调用处按
114
+ * `event.type` 精确路由后传入的运行时值一定是该窄类型;这里用一次整体类型
115
+ * 断言把「窄参数函数」装进统一签名的表,运行时零影响(不改变任何行为)。
116
+ */
117
+ const EVENT_REDUCERS = new Map([
118
+ ['system', reduceSystemEvent],
119
+ ['run_status', reduceRunStatusEvent],
120
+ ['goal_state', reduceGoalStateEvent],
121
+ ['background_jobs', reduceBackgroundJobsEvent],
122
+ ['result', reduceResultEvent],
123
+ ['final_answer', reduceFinalAnswerEvent],
124
+ ['assistant', reduceAssistantEvent],
125
+ ['user', reduceToolResultEvent],
126
+ ['plan', reducePlanEvent],
127
+ ['file_change', reduceFileChangeEvent],
128
+ ['notice', reduceNoticeEvent],
129
+ ['session_info', reduceSessionInfoEvent],
130
+ ['turn_started', reduceTurnStartedEvent],
131
+ ['turn_diff', reduceTurnDiffEvent],
132
+ ['approval_requested', reduceApprovalRequestedEvent],
133
+ ['approval_resolved', reduceApprovalResolvedEvent],
134
+ ['approval_view_updated', reduceApprovalViewUpdatedEvent],
135
+ ['approval_expired', reduceApprovalExpiredEvent],
136
+ ]);
137
+ /** 单事件 → 单次状态迁移。窗口后置裁剪统一由 `reduceRunState` 做,这里不管。 */
138
+ function reduceRunStateInner(state, event) {
139
+ // 只有终态才阻止事件处理。finalizing 是非终态,
140
+ // 仍需累积 assistant 内容(thinking、text、tool_use、后台任务输出)。
141
+ if (isRunTerminal(state.terminal)) {
142
+ return state;
123
143
  }
124
- return state;
144
+ const reduce = EVENT_REDUCERS.get(event.type);
145
+ return reduce ? reduce(state, event) : state;
125
146
  }
126
147
  /** Handle app-server turn_started — records the operation kind (turn vs compact). */
127
148
  function reduceTurnStartedEvent(state, event) {
@@ -131,130 +152,211 @@ function reduceTurnStartedEvent(state, event) {
131
152
  footer: state.footer ?? 'thinking',
132
153
  };
133
154
  }
155
+ /** 恒等返回:file_change 的 fallback 与 update 都返回同一个已构造块。 */
156
+ function constantBlock(block) {
157
+ return block;
158
+ }
159
+ // -- text --
160
+ function textDiffMatches(event, b) {
161
+ return b.kind === 'text' && b.itemId === event.itemId;
162
+ }
163
+ function makeTextDiffBlock(event, ts, complete) {
164
+ return {
165
+ kind: 'text',
166
+ itemId: event.itemId,
167
+ // 最新那条正文不设窗口(它是「输出」),capStaleTextBlocks 负责在它被
168
+ // 更新的正文顶替后补上进度窗口。
169
+ content: event.text,
170
+ timestamp: ts,
171
+ ...(complete ? { completedAt: ts } : {}),
172
+ };
173
+ }
174
+ function updateTextDiffBlock(event, ts, complete, existing) {
175
+ const textBlock = existing;
176
+ return {
177
+ ...textBlock,
178
+ content: event.text,
179
+ // kimi:块锚点时间改为本次 diff 时间(整轮累积流不断续写,
180
+ // 标题应反映“最后写入时刻”而非首见时刻;move-to-end 后位置
181
+ // 与标题才一致)。codex app-server 不置 refreshTimestamp,
182
+ // 保持“首见时间戳不刷新”的既有契约。
183
+ ...(event.refreshTimestamp ? { timestamp: ts } : {}),
184
+ ...(complete ? { completedAt: ts } : {}),
185
+ };
186
+ }
187
+ function applyTextDiff(next, event) {
188
+ if (event.text === undefined)
189
+ return next;
190
+ const ts = event.timestamp;
191
+ const complete = event.complete === true;
192
+ const blocks = markThinkingInactive(next.blocks);
193
+ const capped = upsertSnapshotBlock(blocks, textDiffMatches.bind(null, event), makeTextDiffBlock.bind(null, event, ts, complete), updateTextDiffBlock.bind(null, event, ts, complete), !complete);
194
+ return withCappedBlocks(next, capped, 'streaming');
195
+ }
196
+ // -- reasoning --
197
+ function reasoningDiffMatches(event, b) {
198
+ return b.kind === 'thinking' && b.itemId === event.itemId;
199
+ }
200
+ function makeReasoningDiffBlock(event, ts, complete) {
201
+ return {
202
+ kind: 'thinking',
203
+ itemId: event.itemId,
204
+ content: keepTailMarked(event.reasoning, MAX_REASONING_CHARS),
205
+ active: !complete,
206
+ timestamp: ts,
207
+ ...(complete ? { completedAt: ts } : {}),
208
+ };
209
+ }
210
+ function updateReasoningDiffBlock(event, ts, complete, existing) {
211
+ const thinkingBlock = existing;
212
+ return {
213
+ ...thinkingBlock,
214
+ content: keepTailMarked(event.reasoning, MAX_REASONING_CHARS),
215
+ active: !complete,
216
+ ...(event.refreshTimestamp ? { timestamp: ts } : {}),
217
+ ...(complete ? { completedAt: ts } : {}),
218
+ };
219
+ }
220
+ // reasoning 的 applier 由下方 applySnapshotDiff + REASONING_DIFF_SPEC 提供:
221
+ // 原本与 applyToolOutputDiff 逐字重复(仅 guard 字段 / 三个 handler / footer
222
+ // 不同),现合并为单一实现,差异提到数据表。字段顺序、事件顺序逐项保持。
223
+ // -- toolOutput --
224
+ function toolDiffMatches(event, b) {
225
+ return b.kind === 'tool' && b.tool.id === event.itemId;
226
+ }
227
+ function makeToolDiffBlock(event, ts, complete) {
228
+ // 身份只在首见时写入(existing 分支不动):toolName/toolInput/toolHint。
229
+ const inputStr = truncateDetail(stringifyUnknown(event.toolInput ?? {}));
230
+ return {
231
+ kind: 'tool',
232
+ tool: {
233
+ id: event.itemId,
234
+ name: event.toolName ?? 'command',
235
+ input: inputStr,
236
+ parsedInput: asRecord(inputStr),
237
+ ...(event.toolHint !== undefined ? { toolHint: event.toolHint } : {}),
238
+ output: keepTail(event.toolOutput, MAX_TOOL_DETAIL_CHARS),
239
+ status: complete ? (event.toolStatus ?? 'ok') : 'running',
240
+ startedAt: ts,
241
+ ...(complete ? { completedAt: ts } : {}),
242
+ },
243
+ };
244
+ }
245
+ function updateToolDiffBlock(event, ts, complete, existing) {
246
+ const tool = existing.tool;
247
+ return {
248
+ kind: 'tool',
249
+ tool: {
250
+ ...tool,
251
+ output: keepTail(event.toolOutput, MAX_TOOL_DETAIL_CHARS),
252
+ status: complete ? (event.toolStatus ?? 'ok') : tool.status,
253
+ ...(complete ? { completedAt: ts } : {}),
254
+ },
255
+ };
256
+ }
257
+ // toolOutput 的 applier 同样由 applySnapshotDiff + TOOL_DIFF_SPEC 驱动(见下)。
258
+ // -- plan --
259
+ function planDiffMatches(event, b) {
260
+ return b.kind === 'plan' && b.itemId === event.itemId;
261
+ }
262
+ function makePlanDiffBlock(newPlan, event, ts, complete) {
263
+ return {
264
+ kind: 'plan',
265
+ itemId: event.itemId,
266
+ content: newPlan,
267
+ active: !complete,
268
+ timestamp: ts,
269
+ ...(complete ? { completedAt: ts } : {}),
270
+ };
271
+ }
272
+ function updatePlanDiffBlock(newPlan, complete, ts, existing) {
273
+ const planBlock = existing;
274
+ return {
275
+ ...planBlock,
276
+ content: newPlan,
277
+ active: !complete,
278
+ ...(complete ? { completedAt: ts } : {}),
279
+ };
280
+ }
281
+ function applyPlanDiff(next, event) {
282
+ if (event.plan === undefined)
283
+ return next;
284
+ const ts = event.timestamp;
285
+ const complete = event.complete === true;
286
+ const newPlan = keepTailMarked(event.plan, MAX_REASONING_CHARS * 2);
287
+ const capped = upsertSnapshotBlock(next.blocks, planDiffMatches.bind(null, event), makePlanDiffBlock.bind(null, newPlan, event, ts, complete), updatePlanDiffBlock.bind(null, newPlan, complete, ts), !complete);
288
+ return withCappedBlocks({ ...next, plan: newPlan }, capped);
289
+ }
290
+ // -- fileChanges --
291
+ function makeFileChangeBlock(fc, event, ts, complete) {
292
+ return {
293
+ kind: 'file_change',
294
+ itemId: event.itemId,
295
+ path: fc.path,
296
+ operation: fc.kind === 'add' ? 'create' : fc.kind === 'delete' ? 'delete' : 'edit',
297
+ diff: fc.diff,
298
+ timestamp: ts,
299
+ ...(complete ? { completedAt: ts } : {}),
300
+ };
301
+ }
302
+ function fileChangeMatches(fc, event, b) {
303
+ return b.kind === 'file_change' && b.itemId === event.itemId && b.path === fc.path;
304
+ }
305
+ function applyFileChangesDiff(next, event) {
306
+ if (event.fileChanges === undefined)
307
+ return next;
308
+ const ts = event.timestamp;
309
+ const complete = event.complete === true;
310
+ let blocks = next.blocks;
311
+ for (const fc of event.fileChanges) {
312
+ const fileBlock = makeFileChangeBlock(fc, event, ts, complete);
313
+ // 同一 item 的同一路径原地替换(权威 diff 校正),新路径/新 item 追加。
314
+ blocks = upsertSnapshotBlock(blocks, fileChangeMatches.bind(null, fc, event), constantBlock.bind(null, fileBlock), constantBlock.bind(null, fileBlock), !complete);
315
+ }
316
+ return withCappedBlocks(next, blocks);
317
+ }
318
+ const REASONING_DIFF_SPEC = {
319
+ hasPayload: (e) => e.reasoning !== undefined,
320
+ matches: reasoningDiffMatches,
321
+ make: makeReasoningDiffBlock,
322
+ update: updateReasoningDiffBlock,
323
+ footer: 'thinking',
324
+ };
325
+ const TOOL_DIFF_SPEC = {
326
+ hasPayload: (e) => e.toolOutput !== undefined,
327
+ matches: toolDiffMatches,
328
+ make: makeToolDiffBlock,
329
+ update: updateToolDiffBlock,
330
+ footer: 'tool_running',
331
+ };
332
+ function applySnapshotDiff(spec, next, event) {
333
+ if (!spec.hasPayload(event))
334
+ return next;
335
+ const ts = event.timestamp;
336
+ const complete = event.complete === true;
337
+ const capped = upsertSnapshotBlock(next.blocks, spec.matches.bind(null, event), spec.make.bind(null, event, ts, complete), spec.update.bind(null, event, ts, complete), !complete);
338
+ return withCappedBlocks(next, capped, spec.footer);
339
+ }
340
+ /** 五个字段按固定顺序依次应用(与原实现的 5 个 if 分支顺序一致)。 */
341
+ const TURN_DIFF_APPLIERS = [
342
+ applyTextDiff,
343
+ applySnapshotDiff.bind(null, REASONING_DIFF_SPEC),
344
+ applySnapshotDiff.bind(null, TOOL_DIFF_SPEC),
345
+ applyPlanDiff,
346
+ applyFileChangesDiff,
347
+ ];
134
348
  /**
135
349
  * Handle app-server turn_diff events. The translator emits FULL per-item
136
350
  * snapshots (accumulated text/reasoning/tool output/plan, current file-change
137
351
  * set). Each snapshot is scoped to a thread item (event.itemId); the block is
138
352
  * replaced in place to avoid duplication, and a NEW block is appended on the
139
- * item's first sight — so interleaved items keep their real chronology and
140
- * the active streaming content sits at the bottom, like the exec path.
353
+ * item's first sight — so interleaved items keep their real chronology and the
354
+ * active streaming content sits at the bottom, like the exec path.
141
355
  */
142
356
  function reduceTurnDiffEvent(state, event) {
143
357
  let next = state;
144
- const ts = event.timestamp;
145
- const complete = event.complete === true;
146
- if (event.text !== undefined) {
147
- const blocks = markThinkingInactive(next.blocks);
148
- const capped = upsertSnapshotBlock(blocks, (b) => b.kind === 'text' && b.itemId === event.itemId, () => ({
149
- kind: 'text',
150
- itemId: event.itemId,
151
- // 最新那条正文不设窗口(它是「输出」),capStaleTextBlocks 负责在它被
152
- // 更新的正文顶替后补上进度窗口。
153
- content: event.text,
154
- timestamp: ts,
155
- ...(complete ? { completedAt: ts } : {}),
156
- }), (existing) => {
157
- const textBlock = existing;
158
- return {
159
- ...textBlock,
160
- content: event.text,
161
- // kimi:块锚点时间改为本次 diff 时间(整轮累积流不断续写,
162
- // 标题应反映“最后写入时刻”而非首见时刻;move-to-end 后位置
163
- // 与标题才一致)。codex app-server 不置 refreshTimestamp,
164
- // 保持“首见时间戳不刷新”的既有契约。
165
- ...(event.refreshTimestamp ? { timestamp: ts } : {}),
166
- ...(complete ? { completedAt: ts } : {}),
167
- };
168
- }, !complete);
169
- next = withCappedBlocks(next, capped, 'streaming');
170
- }
171
- if (event.reasoning !== undefined) {
172
- const capped = upsertSnapshotBlock(next.blocks, (b) => b.kind === 'thinking' && b.itemId === event.itemId, () => ({
173
- kind: 'thinking',
174
- itemId: event.itemId,
175
- content: keepTailMarked(event.reasoning, MAX_REASONING_CHARS),
176
- active: !complete,
177
- timestamp: ts,
178
- ...(complete ? { completedAt: ts } : {}),
179
- }), (existing) => {
180
- const thinkingBlock = existing;
181
- return {
182
- ...thinkingBlock,
183
- content: keepTailMarked(event.reasoning, MAX_REASONING_CHARS),
184
- active: !complete,
185
- ...(event.refreshTimestamp ? { timestamp: ts } : {}),
186
- ...(complete ? { completedAt: ts } : {}),
187
- };
188
- }, !complete);
189
- next = withCappedBlocks(next, capped, 'thinking');
190
- }
191
- if (event.toolOutput !== undefined) {
192
- const capped = upsertSnapshotBlock(next.blocks, (b) => b.kind === 'tool' && b.tool.id === event.itemId, () => {
193
- // 身份只在首见时写入(existing 分支不动):toolName/toolInput/toolHint。
194
- const inputStr = truncateDetail(stringifyUnknown(event.toolInput ?? {}));
195
- return {
196
- kind: 'tool',
197
- tool: {
198
- id: event.itemId,
199
- name: event.toolName ?? 'command',
200
- input: inputStr,
201
- parsedInput: asRecord(inputStr),
202
- ...(event.toolHint !== undefined ? { toolHint: event.toolHint } : {}),
203
- output: keepTail(event.toolOutput, MAX_TOOL_DETAIL_CHARS),
204
- status: complete ? (event.toolStatus ?? 'ok') : 'running',
205
- startedAt: ts,
206
- ...(complete ? { completedAt: ts } : {}),
207
- },
208
- };
209
- }, (existing) => ({
210
- kind: 'tool',
211
- tool: {
212
- ...existing.tool,
213
- output: keepTail(event.toolOutput, MAX_TOOL_DETAIL_CHARS),
214
- status: complete
215
- ? (event.toolStatus ?? 'ok')
216
- : existing.tool.status,
217
- ...(complete ? { completedAt: ts } : {}),
218
- },
219
- }), !complete);
220
- next = withCappedBlocks(next, capped, 'tool_running');
221
- }
222
- if (event.plan !== undefined) {
223
- const newPlan = keepTailMarked(event.plan, MAX_REASONING_CHARS * 2);
224
- const capped = upsertSnapshotBlock(next.blocks, (b) => b.kind === 'plan' && b.itemId === event.itemId, () => ({
225
- kind: 'plan',
226
- itemId: event.itemId,
227
- content: newPlan,
228
- active: !complete,
229
- timestamp: ts,
230
- ...(complete ? { completedAt: ts } : {}),
231
- }), (existing) => {
232
- const planBlock = existing;
233
- return {
234
- ...planBlock,
235
- content: newPlan,
236
- active: !complete,
237
- ...(complete ? { completedAt: ts } : {}),
238
- };
239
- }, !complete);
240
- next = withCappedBlocks({ ...next, plan: newPlan }, capped);
241
- }
242
- if (event.fileChanges !== undefined) {
243
- let blocks = next.blocks;
244
- for (const fc of event.fileChanges) {
245
- const fileBlock = {
246
- kind: 'file_change',
247
- itemId: event.itemId,
248
- path: fc.path,
249
- operation: fc.kind === 'add' ? 'create' : fc.kind === 'delete' ? 'delete' : 'edit',
250
- diff: fc.diff,
251
- timestamp: ts,
252
- ...(complete ? { completedAt: ts } : {}),
253
- };
254
- // 同一 item 的同一路径原地替换(权威 diff 校正),新路径/新 item 追加。
255
- blocks = upsertSnapshotBlock(blocks, (b) => b.kind === 'file_change' && b.itemId === event.itemId && b.path === fc.path, () => fileBlock, () => fileBlock, !complete);
256
- }
257
- next = withCappedBlocks(next, blocks);
358
+ for (const applier of TURN_DIFF_APPLIERS) {
359
+ next = applier(next, event);
258
360
  }
259
361
  return next;
260
362
  }
@@ -452,43 +554,69 @@ function bridgeJobStatus(status) {
452
554
  *
453
555
  * 无变化时返回**同一个 state 引用**:bridge 靠它跳过无谓的卡片 patch。
454
556
  */
455
- export function syncBackgroundJobs(state, roster) {
456
- const tracked = state.backgroundJobs ?? [];
457
- const byId = new Map(roster.map((job) => [job.id, job]));
458
- const next = tracked.map((job) => {
459
- const row = byId.get(job.id);
460
- if (!row)
461
- return job.status === 'ended' ? job : { ...job, status: 'ended' };
462
- if (job.status === 'ended')
463
- return job;
464
- const status = row.status === undefined ? job.status : bridgeJobStatus(row.status);
465
- const label = row.label.trim() === '' ? job.label : row.label;
466
- const progress = row.progress ?? job.progress;
467
- if (status === job.status && label === job.label && progress === job.progress)
468
- return job;
469
- return {
470
- ...job,
471
- label,
472
- ...(status === undefined ? {} : { status }),
473
- ...(progress === undefined ? {} : { progress }),
474
- };
475
- });
476
- // 新出现的任务只收仍在跑的:与收尾采集同一口径(列出已结束的任务只会被
477
- // 读成"还在跑")。
478
- const seen = new Set(tracked.map((job) => job.id));
557
+ /** 单个已跟踪 job 与 roster 行的对账(原 tracked.map 回调)。 */
558
+ function syncTrackedJob(job, row) {
559
+ if (!row)
560
+ return job.status === 'ended' ? job : { ...job, status: 'ended' };
561
+ if (job.status === 'ended')
562
+ return job;
563
+ const status = row.status === undefined ? job.status : bridgeJobStatus(row.status);
564
+ const label = row.label.trim() === '' ? job.label : row.label;
565
+ const progress = row.progress ?? job.progress;
566
+ if (status === job.status && label === job.label && progress === job.progress)
567
+ return job;
568
+ return {
569
+ ...job,
570
+ label,
571
+ ...(status === undefined ? {} : { status }),
572
+ ...(progress === undefined ? {} : { progress }),
573
+ };
574
+ }
575
+ /** roster 里新出现且仍在跑的 job(原新增循环)。 */
576
+ function collectNewJobs(tracked, roster) {
577
+ const seen = new Set();
578
+ for (const job of tracked)
579
+ seen.add(job.id);
580
+ const added = [];
479
581
  for (const row of roster) {
480
582
  if (seen.has(row.id))
481
583
  continue;
482
584
  if (row.status !== undefined && !RUNNING_JOB_STATUSES.has(row.status))
483
585
  continue;
484
- next.push({
586
+ added.push({
485
587
  id: row.id,
486
588
  label: row.label,
487
589
  ...(row.status === undefined ? {} : { status: row.status }),
488
590
  ...(row.progress === undefined ? {} : { progress: row.progress }),
489
591
  });
490
592
  }
491
- if (next.length === tracked.length && next.every((job, index) => job === tracked[index])) {
593
+ return added;
594
+ }
595
+ /** next 与 tracked 是否逐项同一引用(原 length + every 判定)。 */
596
+ function jobsUnchanged(next, tracked) {
597
+ if (next.length !== tracked.length)
598
+ return false;
599
+ for (let i = 0; i < next.length; i++) {
600
+ if (next[i] !== tracked[i])
601
+ return false;
602
+ }
603
+ return true;
604
+ }
605
+ export function syncBackgroundJobs(state, roster) {
606
+ const tracked = state.backgroundJobs ?? [];
607
+ const byId = new Map();
608
+ for (const job of roster)
609
+ byId.set(job.id, job);
610
+ const next = [];
611
+ for (const job of tracked) {
612
+ next.push(syncTrackedJob(job, byId.get(job.id)));
613
+ }
614
+ // 新出现的任务只收仍在跑的:与收尾采集同一口径(列出已结束的任务只会被
615
+ // 读成"还在跑")。
616
+ for (const added of collectNewJobs(tracked, roster)) {
617
+ next.push(added);
618
+ }
619
+ if (jobsUnchanged(next, tracked)) {
492
620
  return state;
493
621
  }
494
622
  return { ...state, backgroundJobs: next };
@@ -98,11 +98,23 @@ export function toolBodyMd(tool, opts = {}) {
98
98
  // 同上:走 truncate 保证切点不落在代理对中间(末尾 '…' 由 truncate 附加以对齐预算)。
99
99
  return `${truncate(body, BODY_TOTAL_MAX)}\n\n_(body 已截断,完整内容查日志)_`;
100
100
  }
101
- // --- Named render bodies (shared by tool-name cases and toolHint branches) ---
102
- function renderBashInput(str) {
103
- const cmd = str('command');
104
- return cmd ? `**Command**\n\`\`\`bash\n${truncate(cmd, BODY_FIELD_MAX)}\n\`\`\`` : '';
101
+ function renderFieldInput(str, spec) {
102
+ const value = str(spec.key);
103
+ if (!value)
104
+ return '';
105
+ const shown = spec.truncate ? truncate(value, BODY_FIELD_MAX) : value;
106
+ return spec.fence
107
+ ? `**${spec.label}**\n\`\`\`${spec.fence}\n${shown}\n\`\`\``
108
+ : `**${spec.label}** \`${shown}\``;
105
109
  }
110
+ const BASH_FIELD = {
111
+ key: 'command',
112
+ label: 'Command',
113
+ truncate: true,
114
+ fence: 'bash',
115
+ };
116
+ const GLOB_FIELD = { key: 'pattern', label: 'Pattern', truncate: true };
117
+ const LS_FIELD = { key: 'path', label: 'Path', truncate: false };
106
118
  function renderFileInput(str) {
107
119
  const fp = str('file_path') || str('path');
108
120
  return fp ? `**File** \`${fp}\`` : '';
@@ -115,14 +127,6 @@ function renderGrepInput(str) {
115
127
  lines.push(`**Path** \`${str('path')}\``);
116
128
  return lines.join('\n');
117
129
  }
118
- function renderGlobInput(str) {
119
- const p = str('pattern');
120
- return p ? `**Pattern** \`${truncate(p, BODY_FIELD_MAX)}\`` : '';
121
- }
122
- function renderLsInput(str) {
123
- const p = str('path');
124
- return p ? `**Path** \`${p}\`` : '';
125
- }
126
130
  function renderWebFetchInput(str) {
127
131
  return str('url') ? `**URL** ${str('url')}` : '';
128
132
  }
@@ -149,7 +153,7 @@ function renderInput(tool) {
149
153
  // 完全一致。hint 分支与 name case 共用同一渲染函数,无复制粘贴。
150
154
  switch (tool.toolHint) {
151
155
  case 'shell':
152
- return renderBashInput(str);
156
+ return renderFieldInput(str, BASH_FIELD);
153
157
  case 'file':
154
158
  case 'patch':
155
159
  return renderFileInput(str);
@@ -162,15 +166,15 @@ function renderInput(tool) {
162
166
  }
163
167
  switch (toolKindOf(tool.name)) {
164
168
  case 'shell':
165
- return renderBashInput(str);
169
+ return renderFieldInput(str, BASH_FIELD);
166
170
  case 'file':
167
171
  return renderFileInput(str);
168
172
  case 'grep':
169
173
  return renderGrepInput(str);
170
174
  case 'glob':
171
- return renderGlobInput(str);
175
+ return renderFieldInput(str, GLOB_FIELD);
172
176
  case 'ls':
173
- return renderLsInput(str);
177
+ return renderFieldInput(str, LS_FIELD);
174
178
  case 'webfetch':
175
179
  return renderWebFetchInput(str);
176
180
  case 'websearch':