dsh-loop-engine 0.1.7-rc1 → 0.1.7-rc3

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.
package/README.md CHANGED
@@ -22,7 +22,7 @@ A **published** dsh needs no extra setup. Booting the harness from its **source
22
22
 
23
23
  ## Version compatibility
24
24
 
25
- dsh-loop-engine is versioned **in lockstep with the harness it targets**: `<harness version>-rcN`. From `0.1.7-rc1` on, **one release serves two harness generations**: the 0.1.5 line (`>=0.1.5-rc.1 <0.1.6-0`, all sharing the old settings API) and `0.1.7-rc.1` (`>=0.1.7-rc.1 <0.1.8-0`). It detects the running generation at load and takes the matching code path, so a single published package installs onto either. Every harness package it consumes declares that union range in `peerDependencies`; a harness outside it fails loudly at boot or session resume. `0.1.5-rc3` … `0.1.5-rc5` target harness `0.1.5-rc.2`; `0.1.5-rc1`/`0.1.5-rc2` target `0.1.5-rc.1`; `1.0.0-rc8` … `1.0.0-rc15` target `0.1.2-rc.1`; `1.0.0-rc7` and earlier target `0.1.1-rc.2`.
25
+ dsh-loop-engine is versioned **in lockstep with the harness it targets**: `<harness version>-rcN`. From `0.1.7-rc1` on, **one release serves two harness generations**: the 0.1.5 line (`>=0.1.5-rc.1 <0.1.6-0`, all sharing the old settings API) and the 0.1.7 line (`>=0.1.7-rc.1 <0.1.8-0`, whose `rc.1` and `rc.2` are both verified). It detects the running generation at load and takes the matching code path, so a single published package installs onto either. Every harness package it consumes declares that union range in `peerDependencies`; a harness outside it fails loudly at boot or session resume. Within a covered line a new `rc` needs no plugin release unless the API surfaces it depends on moved — `docs/compatibility.md` §1 lists the surfaces and the diff command that decides it. `0.1.5-rc3` … `0.1.5-rc5` target harness `0.1.5-rc.2`; `0.1.5-rc1`/`0.1.5-rc2` target `0.1.5-rc.1`; `1.0.0-rc8` … `1.0.0-rc15` target `0.1.2-rc.1`; `1.0.0-rc7` and earlier target `0.1.1-rc.2`.
26
26
 
27
27
  ### Requirements
28
28
 
@@ -65,6 +65,7 @@ dsh-loop-engine is versioned **in lockstep with the harness it targets**: `<harn
65
65
  - [docs/per-session-engine.md](docs/per-session-engine.md) — the full user-visible behavior of per-session engines.
66
66
  - [docs/source-checkout.md](docs/source-checkout.md) — the `file:` shims a source-launched harness needs.
67
67
  - [docs/architecture.md](docs/architecture.md) — plugin core: the single factory slot, the managed block, routing, per-session engine facts, the provider route.
68
+ - [docs/compatibility.md](docs/compatibility.md) — **start here when the harness upgrades**: every generation-dependent code site, the four techniques for spanning them, and the checklist for adding the next generation.
68
69
  - [docs/driver-core.md](docs/driver-core.md) — the shared driver infrastructure.
69
70
  - [docs/engine-claude.md](docs/engine-claude.md), [docs/engine-codex.md](docs/engine-codex.md), [docs/engine-kimi.md](docs/engine-kimi.md), [docs/engine-pi.md](docs/engine-pi.md) — per-engine internals.
70
71
  - [docs/optimization-backlog.md](docs/optimization-backlog.md) — known issues and the optimization list.
package/README.zh.md CHANGED
@@ -22,7 +22,7 @@ dsh plugin --profile web add dsh-loop-engine
22
22
 
23
23
  ## 版本兼容
24
24
 
25
- `dsh-loop-engine` 与它针对的 harness **同版本对齐**:`<harness version>-rcN`。自 `0.1.7-rc1` 起,**一个发布版本同时服务两代 harness**:0.1.5 线(`>=0.1.5-rc.1 <0.1.6-0`,三段 `rc` 共用同一套旧 settings API)与 `0.1.7-rc.1`(`>=0.1.7-rc.1 <0.1.8-0`)。它在加载期探测当前是哪一代、走对应分支,所以同一份已发布产物在两代上都能安装运行。它消费的每个 harness 包都在 `peerDependencies` 里声明这个并集范围;超出范围会在启动或会话恢复时响亮地失败。`0.1.5-rc3` … `0.1.5-rc5` 针对 harness `0.1.5-rc.2`;`0.1.5-rc1`/`0.1.5-rc2` 针对 `0.1.5-rc.1`;`1.0.0-rc8` … `1.0.0-rc15` 针对 `0.1.2-rc.1`;`1.0.0-rc7` 及更早针对 `0.1.1-rc.2`。
25
+ `dsh-loop-engine` 与它针对的 harness **同版本对齐**:`<harness version>-rcN`。自 `0.1.7-rc1` 起,**一个发布版本同时服务两代 harness**:0.1.5 线(`>=0.1.5-rc.1 <0.1.6-0`,三段 `rc` 共用同一套旧 settings API)与 0.1.7 线(`>=0.1.7-rc.1 <0.1.8-0`,其中 `rc.1` 与 `rc.2` 都已核对)。它在加载期探测当前是哪一代、走对应分支,所以同一份已发布产物在两代上都能安装运行。它消费的每个 harness 包都在 `peerDependencies` 里声明这个并集范围;超出范围会在启动或会话恢复时响亮地失败。在被覆盖的那条线里,新的 `rc` **不需要**插件跟版——除非它依赖的 API 面动了;`docs/compatibility.md` §1 列出了这些面与"要不要改代码"的那条判定命令。`0.1.5-rc3` … `0.1.5-rc5` 针对 harness `0.1.5-rc.2`;`0.1.5-rc1`/`0.1.5-rc2` 针对 `0.1.5-rc.1`;`1.0.0-rc8` … `1.0.0-rc15` 针对 `0.1.2-rc.1`;`1.0.0-rc7` 及更早针对 `0.1.1-rc.2`。
26
26
 
27
27
  ### 环境要求
28
28
 
@@ -65,6 +65,7 @@ dsh plugin --profile web add dsh-loop-engine
65
65
  - [docs/per-session-engine.md](docs/per-session-engine.md)——按会话选引擎的用户可见行为全解。
66
66
  - [docs/source-checkout.md](docs/source-checkout.md)——源码启动 harness 时需要的那套 `file:` shim。
67
67
  - [docs/architecture.md](docs/architecture.md)——插件核心:唯一 factory 槽位、受管理块、路由、逐会话引擎事实、provider 路由。
68
+ - [docs/compatibility.md](docs/compatibility.md)——**harness 升级时先看这篇**:全部随代际分叉的代码位置、跨代的四种手法,以及新增一代的清单。
68
69
  - [docs/driver-core.md](docs/driver-core.md)——共享驱动基础设施。
69
70
  - [docs/engine-claude.md](docs/engine-claude.md)、[docs/engine-codex.md](docs/engine-codex.md)、[docs/engine-kimi.md](docs/engine-kimi.md)、[docs/engine-pi.md](docs/engine-pi.md)——逐引擎内部实现。
70
71
  - [docs/optimization-backlog.md](docs/optimization-backlog.md)——已知问题与优化清单。
package/lib/client.js CHANGED
@@ -270,8 +270,12 @@ var PLUGIN_ID = "dsh-loop-engine";
270
270
  var ROW_SELECTORS = [
271
271
  /** 0.1.5 line: the turn-status row, whose class name ends `_turnStatus`. */
272
272
  '[class$="_turnStatus"]',
273
- /** 0.1.7 line: the turn-process button (`data-turn-process`); its status text is the label span. */
274
- 'button[data-turn-process] [class$="_label"]'
273
+ /**
274
+ * 0.1.7 line: the LIVE turn-process button — `disabled` is ui-chat's "this
275
+ * turn cannot be collapsed", which is true only while the turn is open — and
276
+ * its status text is the label span.
277
+ */
278
+ 'button[data-turn-process]:disabled [class$="_label"]'
275
279
  ];
276
280
  var sheetFor = (ROW) => {
277
281
  const GATE = `html[${ENGINE_ATTR}][${RUNNING_ATTR}]`;
package/lib/index.js CHANGED
@@ -1289,6 +1289,19 @@ var ClaudeCodeAgent = class {
1289
1289
  * opens the next step (see {@link beginSegment}).
1290
1290
  */
1291
1291
  stepSettledTools = 0;
1292
+ /**
1293
+ * Calls already given a `tool/result` in the current step. The Claude Agent
1294
+ * SDK delivers the same tool result in two `user` messages ~57 ms apart, and
1295
+ * Session V4 keys its pending-tool map by call id alone — it deletes the call
1296
+ * on the first result, so a second result for the same call is refused
1297
+ * (`tool/result <id> has no advertised tool lifecycle`) and the whole session
1298
+ * log fails to load. The durable contract is exactly one result per call, so
1299
+ * a repeat is dropped rather than appended. Reset per step (see {@link step}),
1300
+ * never on an internal segment rotation: call ids are unique for the life of
1301
+ * the session, so retaining the marks only widens duplicate suppression and
1302
+ * can never hide a legitimate result.
1303
+ */
1304
+ settledCalls = /* @__PURE__ */ new Set();
1292
1305
  /**
1293
1306
  * Whether the current query has rotated into a second step. The query-total
1294
1307
  * usage record is only meaningful while one step holds the whole query.
@@ -1616,6 +1629,7 @@ var ClaudeCodeAgent = class {
1616
1629
  const { abort: { signal } } = phase;
1617
1630
  signal.throwIfAborted();
1618
1631
  this.stepSettledTools = 0;
1632
+ this.settledCalls.clear();
1619
1633
  this.rotated = false;
1620
1634
  const cwd = this.session.header.cwd;
1621
1635
  if (cwd === void 0 || cwd.length === 0) {
@@ -1711,6 +1725,15 @@ var ClaudeCodeAgent = class {
1711
1725
  const synthesized = [...reasoningByIndex.entries()].sort((a, b) => a[0] - b[0]).map(([, text]) => ({ type: "reasoning", text }));
1712
1726
  content = [...synthesized, ...content];
1713
1727
  }
1728
+ const normalizedCalls = new Map(mapped.toolCalls.map((call) => [
1729
+ call.callId,
1730
+ normalizeHostedToolCall("claude-code", call.name, call.arguments)
1731
+ ]));
1732
+ content = content.map((block) => {
1733
+ if (block.type !== "tool-call") return block;
1734
+ const normalized = normalizedCalls.get(block.id);
1735
+ return { ...block, name: normalized.name, arguments: normalized.arguments };
1736
+ });
1714
1737
  if (content.length > 0) {
1715
1738
  this.beginSegment(phase);
1716
1739
  reasoningByIndex.clear();
@@ -1736,7 +1759,7 @@ var ClaudeCodeAgent = class {
1736
1759
  }
1737
1760
  }
1738
1761
  for (const call of mapped.toolCalls) {
1739
- const normalized = normalizeHostedToolCall("claude-code", call.name, call.arguments);
1762
+ const normalized = normalizedCalls.get(call.callId);
1740
1763
  this.session.append("tool/call", {
1741
1764
  turn: phase.turn,
1742
1765
  step: phase.step,
@@ -1751,6 +1774,9 @@ var ClaudeCodeAgent = class {
1751
1774
  }
1752
1775
  case "user": {
1753
1776
  for (const result of mapToolResults(message.message)) {
1777
+ const callId = result.source.callId;
1778
+ if (this.settledCalls.has(callId)) continue;
1779
+ this.settledCalls.add(callId);
1754
1780
  this.session.append("tool/result", { turn: phase.turn, step: phase.step, message: result }, { surfaceOp: "append" });
1755
1781
  this.stepSettledTools += 1;
1756
1782
  }
@@ -3493,9 +3519,9 @@ var CodexAgent = class {
3493
3519
  } else if (item.type === "commandExecution") {
3494
3520
  this.beginSegment(phase);
3495
3521
  const activity = mapCommandExecution(item);
3496
- foldToolCall(activity.call);
3497
- flushHeld();
3498
3522
  const normalizedCall = normalizeHostedToolCall("codex", activity.call.name, activity.call.arguments);
3523
+ foldToolCall({ ...activity.call, name: normalizedCall.name, arguments: normalizedCall.arguments });
3524
+ flushHeld();
3499
3525
  this.session.append("tool/call", {
3500
3526
  turn: phase.turn,
3501
3527
  step: phase.step,
@@ -3508,9 +3534,9 @@ var CodexAgent = class {
3508
3534
  } else if (item.type === "fileChange") {
3509
3535
  this.beginSegment(phase);
3510
3536
  const activity = mapFileChange(item);
3511
- foldToolCall(activity.call);
3512
- flushHeld();
3513
3537
  const normalizedCall = normalizeHostedToolCall("codex", activity.call.name, activity.call.arguments);
3538
+ foldToolCall({ ...activity.call, name: normalizedCall.name, arguments: normalizedCall.arguments });
3539
+ flushHeld();
3514
3540
  this.session.append("tool/call", {
3515
3541
  turn: phase.turn,
3516
3542
  step: phase.step,
@@ -3523,9 +3549,9 @@ var CodexAgent = class {
3523
3549
  } else if (item.type === "mcpToolCall") {
3524
3550
  this.beginSegment(phase);
3525
3551
  const activity = mapMcpToolCall(item);
3526
- foldToolCall(activity.call);
3527
- flushHeld();
3528
3552
  const normalizedCall = normalizeHostedToolCall("codex", activity.call.name, activity.call.arguments);
3553
+ foldToolCall({ ...activity.call, name: normalizedCall.name, arguments: normalizedCall.arguments });
3554
+ flushHeld();
3529
3555
  this.session.append("tool/call", {
3530
3556
  turn: phase.turn,
3531
3557
  step: phase.step,
@@ -4401,6 +4427,7 @@ var PiAgent = class {
4401
4427
  const startedReasoning = /* @__PURE__ */ new Set();
4402
4428
  const thinkingByIndex = /* @__PURE__ */ new Map();
4403
4429
  const emittedToolCalls = /* @__PURE__ */ new Set();
4430
+ const settledToolCalls = /* @__PURE__ */ new Set();
4404
4431
  let pendingToolCalls = [];
4405
4432
  let pendingCallLog = [];
4406
4433
  let callsToLog = [];
@@ -4428,13 +4455,12 @@ var PiAgent = class {
4428
4455
  }
4429
4456
  held = void 0;
4430
4457
  for (const call of callsToLog) {
4431
- const normalized = normalizeHostedToolCall("pi", call.name, call.arguments);
4432
4458
  this.session.append("tool/call", {
4433
4459
  turn: phase.turn,
4434
4460
  step: phase.step,
4435
4461
  callId: ToolCallId5(call.callId),
4436
- name: normalized.name,
4437
- arguments: normalized.arguments
4462
+ name: call.name,
4463
+ arguments: call.arguments
4438
4464
  });
4439
4465
  }
4440
4466
  callsToLog = [];
@@ -4453,8 +4479,9 @@ var PiAgent = class {
4453
4479
  if (emittedToolCalls.has(callId)) return;
4454
4480
  emittedToolCalls.add(callId);
4455
4481
  const argumentsValue = typeof rawArguments === "string" ? rawArguments : JSON.stringify(rawArguments ?? {});
4456
- pendingToolCalls.push({ type: "tool-call", id: ToolCallId5(callId), name: name2, arguments: argumentsValue });
4457
- pendingCallLog.push({ callId, name: name2, arguments: argumentsValue });
4482
+ const normalized = normalizeHostedToolCall("pi", name2, argumentsValue);
4483
+ pendingToolCalls.push({ type: "tool-call", id: ToolCallId5(callId), name: normalized.name, arguments: normalized.arguments });
4484
+ pendingCallLog.push({ callId, name: normalized.name, arguments: normalized.arguments });
4458
4485
  };
4459
4486
  const ensureToolCallOwner = () => {
4460
4487
  if (pendingToolCalls.length === 0) return;
@@ -4555,12 +4582,15 @@ var PiAgent = class {
4555
4582
  case "tool_execution_end":
4556
4583
  emitToolCall(event.toolCallId, event.toolName, void 0);
4557
4584
  ensureToolCallOwner();
4558
- this.session.append("tool/result", {
4559
- turn: phase.turn,
4560
- step: phase.step,
4561
- message: mapToolResult({ toolCallId: event.toolCallId, result: event.result, isError: event.isError })
4562
- }, { surfaceOp: "append" });
4563
- this.stepSettledTools += 1;
4585
+ if (!settledToolCalls.has(event.toolCallId)) {
4586
+ settledToolCalls.add(event.toolCallId);
4587
+ this.session.append("tool/result", {
4588
+ turn: phase.turn,
4589
+ step: phase.step,
4590
+ message: mapToolResult({ toolCallId: event.toolCallId, result: event.result, isError: event.isError })
4591
+ }, { surfaceOp: "append" });
4592
+ this.stepSettledTools += 1;
4593
+ }
4564
4594
  break;
4565
4595
  case "turn_end": {
4566
4596
  if (!assistantFlushed && event.message !== void 0) {
@@ -4570,6 +4600,8 @@ var PiAgent = class {
4570
4600
  flushHeld(lastUsage);
4571
4601
  assistantFlushed = true;
4572
4602
  for (const toolResult2 of event.toolResults ?? []) {
4603
+ if (settledToolCalls.has(toolResult2.toolCallId)) continue;
4604
+ settledToolCalls.add(toolResult2.toolCallId);
4573
4605
  ensureToolCallOwner();
4574
4606
  this.appendToolResult(phase.turn, phase.step, toolResult2);
4575
4607
  this.stepSettledTools += 1;
@@ -5638,10 +5670,13 @@ var KimiAgent = class {
5638
5670
  flushSegment(phase) {
5639
5671
  const calls = this.segmentCalls;
5640
5672
  this.segmentCalls = [];
5641
- this.flushAssistant(phase, calls);
5642
- for (const call of calls) {
5643
- const normalized = normalizeHostedToolCall("kimi", call.name, call.arguments);
5644
- this.session.append("tool/call", { turn: phase.turn, step: phase.step, callId: ToolCallId7(call.callId), name: normalized.name, arguments: normalized.arguments });
5673
+ const normalized = calls.map((call) => {
5674
+ const projection = normalizeHostedToolCall("kimi", call.name, call.arguments);
5675
+ return { callId: call.callId, name: projection.name, arguments: projection.arguments };
5676
+ });
5677
+ this.flushAssistant(phase, normalized);
5678
+ for (const call of normalized) {
5679
+ this.session.append("tool/call", { turn: phase.turn, step: phase.step, callId: ToolCallId7(call.callId), name: call.name, arguments: call.arguments });
5645
5680
  }
5646
5681
  }
5647
5682
  /**
@@ -7495,6 +7530,8 @@ function apply(ctx, config) {
7495
7530
  const AGENT_PRESETS_NS = "agent-presets";
7496
7531
  const PRESET_DEFAULT_ATTEMPTS = 30;
7497
7532
  const PRESET_DEFAULT_RETRY_MS = 100;
7533
+ const PRESET_SOURCE_ATTEMPTS = 20;
7534
+ const PRESET_SOURCE_RETRY_MS = 100;
7498
7535
  const ROUTE_ATTEMPTS = 30;
7499
7536
  const ROUTE_RETRY_MS = 100;
7500
7537
  const ROUTER_ATTEMPTS = 40;
@@ -7570,11 +7607,24 @@ function apply(ctx, config) {
7570
7607
  }
7571
7608
  return void 0;
7572
7609
  }
7573
- const settled = authoring ??= ensureEnginePresets(resolveDshHome2(), presets).catch((error) => {
7574
- ctx.logger.error(`loop-engine: engine preset authoring failed: ${String(error)}`);
7610
+ const settled = authoring ??= (async () => {
7611
+ let lastError;
7612
+ for (let attempt2 = 0; attempt2 <= PRESET_SOURCE_ATTEMPTS; attempt2 += 1) {
7613
+ try {
7614
+ return await ensureEnginePresets(resolveDshHome2(), presets);
7615
+ } catch (error) {
7616
+ lastError = error;
7617
+ if (attempt2 < PRESET_SOURCE_ATTEMPTS) {
7618
+ await new Promise((resolve5) => {
7619
+ setTimeout(resolve5, PRESET_SOURCE_RETRY_MS);
7620
+ });
7621
+ }
7622
+ }
7623
+ }
7624
+ ctx.logger.error(`loop-engine: engine preset authoring failed: ${String(lastError)}`);
7575
7625
  authoring = void 0;
7576
7626
  return false;
7577
- });
7627
+ })();
7578
7628
  return { presets, settled };
7579
7629
  };
7580
7630
  let authoring;
@@ -47,16 +47,26 @@
47
47
  * paints by it, and a leftover attribute is inert on a page whose turn-status row
48
48
  * is not rendered.
49
49
  *
50
- * The paint is also gated on the session being MID-TURN, through a second
51
- * document-level attribute ({@link RUNNING_ATTR}, written from the same
52
- * reflection). On the 0.1.5 line the row only exists while a turn is in flight,
53
- * so the engine attribute alone was enough; on the 0.1.7 line that same button
54
- * stays on screen after the turn ends, as the collapsed summary that reads
55
- * "用时 4秒" — an engine-only gate leaves the glyph and the sweep animating on a
56
- * finished turn, which is not what the live line means. The gate is a surface's
57
- * own answer (`session.running`, which every session-scoped seat receives), so a
58
- * surface that cannot answer passes nothing and leaves it alone rather than
59
- * guessing.
50
+ * The paint is gated TWICE, and both gates are needed on the 0.1.7 line.
51
+ *
52
+ * First, on the session being MID-TURN, through a second document-level
53
+ * attribute ({@link RUNNING_ATTR}, written from the same reflection). On the
54
+ * 0.1.5 line the row only exists while a turn is in flight, so the engine
55
+ * attribute alone was enough; on the 0.1.7 line that same button stays on screen
56
+ * after the turn ends, as the collapsed summary that reads "用时 4秒" — an
57
+ * engine-only gate leaves the glyph and the sweep animating on a finished turn,
58
+ * which is not what the live line means. The gate is a surface's own answer
59
+ * (`session.running`, which every session-scoped seat receives), so a surface
60
+ * that cannot answer passes nothing and leaves it alone rather than guessing.
61
+ *
62
+ * Second, and this is the one a session-level gate cannot express: on the
63
+ * 0.1.7 line EVERY turn's row stays on screen, so while turn N runs, turns
64
+ * 1..N-1 are still there. A session-wide gate turns the whole sheet on for all
65
+ * of them — the finished rows would wear the glyph again for as long as any
66
+ * later turn ran. The row selector therefore carries `:disabled` (see
67
+ * {@link ROW_SELECTORS}), which is how ui-chat marks the live row. The two
68
+ * gates answer different questions — "is a turn in flight for this session" and
69
+ * "is this row the one it belongs to" — and neither alone is enough.
60
70
  *
61
71
  * Three facts about the harness markup make that safe and specific:
62
72
  * - the row has one stable handle per harness generation — an
@@ -9,10 +9,14 @@
9
9
  * dsh's own tool names and argument shapes, while a hosted engine names its
10
10
  * tools differently: Claude's `Write`/`Edit`/`Read`/`Bash`, Codex's
11
11
  * `apply_patch`/`command_execution`, Kimi's title-cased `Bash`, Pi's
12
- * `path`-keyed arguments. This module normalizes the event a driver appends
13
- * while leaving the durable assistant message — and therefore the next step's
14
- * serialized prompt — in the engine's own vocabulary. Session pairing is by
15
- * callId, so the two spellings coexist.
12
+ * `path`-keyed arguments. This module projects a hosted call onto dsh's
13
+ * vocabulary, and each driver feeds the projected values to BOTH writers — the
14
+ * durable `tool/call` event and the matching `tool-call` block in the assistant
15
+ * message that advertised it. Session V4 pairs the two by `id` and rejects a
16
+ * log whose block and event disagree on `name` or `arguments`, so the two sides
17
+ * must be byte-identical and both carry dsh's spelling. That spelling also lets
18
+ * an assistant message replayed into an in-process turn name a tool dsh's
19
+ * registry actually has (`bash`, not `Bash`).
16
20
  *
17
21
  * Only lossless projections are applied. A call whose arguments do not carry
18
22
  * the dsh fields is passed through unchanged and simply renders as a generic
@@ -51,9 +55,10 @@ declare module '@deepseek-ai/dsh-session/types' {
51
55
  }
52
56
  }
53
57
  /**
54
- * Project one hosted tool call onto dsh's `tool/call` vocabulary. The engine's
55
- * own assistant-message block is untouched; only the event this returns is
56
- * normalized, so the next step's serialized prompt keeps the engine's spelling.
58
+ * Project one hosted tool call onto dsh's `tool/call` vocabulary. A driver
59
+ * calls this once per call and writes the SAME name and arguments to both the
60
+ * assistant message's `tool-call` block and the durable `tool/call` event, so
61
+ * Session V4's id-and-payload pairing holds.
57
62
  * @param engine - the hosted engine the call came from.
58
63
  * @param name - the engine's own tool name.
59
64
  * @param argumentsJson - the engine's arguments JSON, verbatim.
@@ -52,6 +52,19 @@ export declare class ClaudeCodeAgent implements Agent {
52
52
  * opens the next step (see {@link beginSegment}).
53
53
  */
54
54
  private stepSettledTools;
55
+ /**
56
+ * Calls already given a `tool/result` in the current step. The Claude Agent
57
+ * SDK delivers the same tool result in two `user` messages ~57 ms apart, and
58
+ * Session V4 keys its pending-tool map by call id alone — it deletes the call
59
+ * on the first result, so a second result for the same call is refused
60
+ * (`tool/result <id> has no advertised tool lifecycle`) and the whole session
61
+ * log fails to load. The durable contract is exactly one result per call, so
62
+ * a repeat is dropped rather than appended. Reset per step (see {@link step}),
63
+ * never on an internal segment rotation: call ids are unique for the life of
64
+ * the session, so retaining the marks only widens duplicate suppression and
65
+ * can never hide a legitimate result.
66
+ */
67
+ private readonly settledCalls;
55
68
  /**
56
69
  * Whether the current query has rotated into a second step. The query-total
57
70
  * usage record is only meaningful while one step holds the whole query.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-loop-engine",
3
3
  "description": "Web-switchable agent loop engine selection for the DeepSeek Harness - out-of-tree plugin (Claude Code / Codex / Pi / Kimi Code drivers) maintained by @kuun993, zero main-repo changes",
4
- "version": "0.1.7-rc1",
4
+ "version": "0.1.7-rc3",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -82,20 +82,29 @@
82
82
  "@deepseek-ai/dsh-typert-protocol": ">=0.1.5-rc.1 <0.1.6-0 || >=0.1.7-rc.1 <0.1.8-0"
83
83
  },
84
84
  "devDependencies": {
85
- "@deepseek-ai/dsh-agent-loop": "0.1.7-rc.1",
86
- "@deepseek-ai/dsh-attachment": "0.1.7-rc.1",
87
- "@deepseek-ai/dsh-client-locale": "0.1.7-rc.1",
88
- "@deepseek-ai/dsh-client-store": "0.1.7-rc.1",
89
- "@deepseek-ai/dsh-client-ui-conversation": "0.1.7-rc.1",
90
- "@deepseek-ai/dsh-client-ui-primitives": "0.1.7-rc.1",
91
- "@deepseek-ai/dsh-client-ui-renderer": "0.1.7-rc.1",
92
- "@deepseek-ai/dsh-client-ui-settings": "0.1.7-rc.1",
93
- "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.1",
94
- "@deepseek-ai/dsh-home-paths": "0.1.7-rc.1",
95
- "@deepseek-ai/dsh-session-persistence-jsonl": "0.1.7-rc.1",
96
- "@deepseek-ai/dsh-subprocess-local": "0.1.7-rc.1",
97
- "@deepseek-ai/dsh-system-prompt": "0.1.7-rc.1",
98
- "@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.1",
85
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.2",
86
+ "@deepseek-ai/dsh-agent-loop": "0.1.7-rc.2",
87
+ "@deepseek-ai/dsh-invariants": "0.1.7-rc.2",
88
+ "@deepseek-ai/dsh-llm": "0.1.7-rc.2",
89
+ "@deepseek-ai/dsh-scope": "0.1.7-rc.2",
90
+ "@deepseek-ai/dsh-session": "0.1.7-rc.2",
91
+ "@deepseek-ai/dsh-session-persistence": "0.1.7-rc.2",
92
+ "@deepseek-ai/dsh-settings": "0.1.7-rc.2",
93
+ "@deepseek-ai/dsh-subprocess": "0.1.7-rc.2",
94
+ "@deepseek-ai/dsh-timeout": "0.1.7-rc.2",
95
+ "@deepseek-ai/dsh-attachment": "0.1.7-rc.2",
96
+ "@deepseek-ai/dsh-client-locale": "0.1.7-rc.2",
97
+ "@deepseek-ai/dsh-client-store": "0.1.7-rc.2",
98
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.7-rc.2",
99
+ "@deepseek-ai/dsh-client-ui-primitives": "0.1.7-rc.2",
100
+ "@deepseek-ai/dsh-client-ui-renderer": "0.1.7-rc.2",
101
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.7-rc.2",
102
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.2",
103
+ "@deepseek-ai/dsh-home-paths": "0.1.7-rc.2",
104
+ "@deepseek-ai/dsh-session-persistence-jsonl": "0.1.7-rc.2",
105
+ "@deepseek-ai/dsh-subprocess-local": "0.1.7-rc.2",
106
+ "@deepseek-ai/dsh-system-prompt": "0.1.7-rc.2",
107
+ "@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.2",
99
108
  "@types/node": "^22.20.0",
100
109
  "@types/react": "^19.0.0",
101
110
  "@vitest/coverage-v8": "^4.1.8",