@khorsheed/dsh-local-agent-dsh 0.2.0-rc.3 → 0.2.0

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,3 +1,3 @@
1
- import { a as MEMBER_BRIDGE_ENTRY_ENV, c as dshTextTask, d as startDshCliRun, i as DshCliProvider, l as registerMemberRun, n as DEFAULT_DISPOSE_GRACE_MS, o as dshCliVersion, r as DEFAULT_LIVE_MIRROR_INTERVAL_MS, s as dshLaunchArgv, t as CONTAINER_NODE_OPTIONS, u as resolveApiKey } from "./dsh-cli-provider-B7s3zH0m.js";
1
+ import { a as MEMBER_BRIDGE_ENTRY_ENV, c as dshTextTask, d as startDshCliRun, i as DshCliProvider, l as registerMemberRun, n as DEFAULT_DISPOSE_GRACE_MS, o as dshCliVersion, r as DEFAULT_LIVE_MIRROR_INTERVAL_MS, s as dshLaunchArgv, t as CONTAINER_NODE_OPTIONS, u as resolveApiKey } from "./dsh-cli-provider-DYRuRpVz.js";
2
2
  import "./provision.js";
3
3
  export { CONTAINER_NODE_OPTIONS, DEFAULT_DISPOSE_GRACE_MS, DEFAULT_LIVE_MIRROR_INTERVAL_MS, DshCliProvider, MEMBER_BRIDGE_ENTRY_ENV, dshCliVersion, dshLaunchArgv, dshTextTask, registerMemberRun, resolveApiKey, startDshCliRun };
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { f as DEFAULT_LIVE_IDLE_MS, h as localModelLaunch, i as DshCliProvider, m as canInheritModelProvider, o as dshCliVersion, p as DshLiveDriver, t as CONTAINER_NODE_OPTIONS } from "./dsh-cli-provider-B7s3zH0m.js";
1
+ import { f as DEFAULT_LIVE_IDLE_MS, h as localModelLaunch, i as DshCliProvider, m as canInheritModelProvider, o as dshCliVersion, p as DshLiveDriver, t as CONTAINER_NODE_OPTIONS } from "./dsh-cli-provider-DYRuRpVz.js";
2
2
  import { DEFAULT_SUB_PROFILE_NAME, provisionDshScope } from "./provision.js";
3
3
  import { listDshSessions } from "./records.js";
4
4
  import { dirname } from "node:path";
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Generated by scripts/sync-icon-artwork.mts from @deepseek-ai/dsh-client-ui-primitives
3
- * (v0.1.7-rc.1, harness 46a7f68b09) — do not edit by hand. The 0.1.5 and rc.1 host lines
3
+ * (v0.2.1-rc.1, harness 1600d8a1e1) — do not edit by hand. The 0.1.5 and rc.1 host lines
4
4
  * share no icon export name, so the rc.1 artwork is inlined here: pure SVG,
5
5
  * no host runtime identity, identical on every line. Re-run the generator
6
6
  * after adding an icon import or bumping the harness checkout.
@@ -15,11 +15,11 @@ import { randomUUID } from 'node:crypto';
15
15
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
16
16
  import { SessionId } from '@deepseek-ai/dsh-session';
17
17
  import { NO_START_CAPABILITIES, SUBAGENT_DESCRIPTOR_VERSION, settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
18
- import { assertResumeCwdUnchanged, assertResumeScopeUnchanged, resolveRoundModel, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, probeCliVersion, resolveChildCwd, subagentDelegationLabel, } from '@khorsheed/dsh-local-agent';
18
+ import { assertResumeCwdUnchanged, assertResumeScopeUnchanged, resolveRoundModel, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, persistChildSession, probeCliVersion, resolveChildCwd, subagentDelegationLabel, } from '@khorsheed/dsh-local-agent';
19
19
  import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV } from '@khorsheed/dsh-local-agent/types';
20
20
  import { LiveChannelUnavailableError } from './live-driver.js';
21
21
  import { DEFAULT_SUB_PROFILE_NAME, provisionDshScope, unitSubProfileName } from './provision.js';
22
- import { mirrorDshSession } from './session-mirror.js';
22
+ import { closeMirroredOpenStep, mirrorDshSession } from './session-mirror.js';
23
23
  /**
24
24
  * Spawn-env name carrying the bridge entry path into the sub-dsh process,
25
25
  * where the headless bundle's mcp-client row reads it (`!!js`). The name is a
@@ -685,39 +685,61 @@ export async function startDshCliRun(request, spec) {
685
685
  // reached its endpoint and produced an answer.
686
686
  if (settled.stopReason === 'completed')
687
687
  markCredentialVerified(ctx, spec.scope);
688
- // The turn closes at the real settle moment, so the timing projection's
689
- // duration equals the actual CLI runtime. Every terminal path closes the
690
- // window — a failed or cancelled run settles 'error'/'aborted' instead of
691
- // leaving the turn open with a distorted tiny duration.
692
- if (spec.childSession !== undefined) {
693
- if (settled.stopReason === 'completed') {
694
- spec.childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
695
- }
696
- else if (settled.stopReason === 'aborted') {
697
- spec.childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
698
- }
699
- else {
700
- spec.childSession.append('turn/end', {
701
- turn,
702
- reason: { kind: 'error', error: { message: 'dsh exited before the run completed', code: 'UNKNOWN' } },
703
- });
704
- }
705
- }
688
+ // The turn does NOT close here: turn/end lands in the mirror queue after
689
+ // the settle pass (see below), so every mirrored event precedes it in the
690
+ // stored log — the released-log reader rejects content outside an open
691
+ // turn on every cold read.
706
692
  return settled;
707
693
  });
708
694
  // After the child EXITS — however it ended (completed, killed by dispose, or
709
695
  // crashed) — mirror whatever the sub-dsh session already produced into the
710
696
  // dsh subagent session, so every round preserves its conversation and real
711
697
  // token usage instead of showing turn boundaries only. Waits for the settle
712
- // chain first (so turn/end is already appended) AND for the process to
713
- // actually exit (the sub-dsh flushed its session before exiting) before
714
- // reading.
698
+ // chain first AND for the process to actually exit (the sub-dsh flushed its
699
+ // session before exiting) before reading. The turn closes INSIDE the mirror
700
+ // queue, after the settle pass: the copied tail's open step first receives
701
+ // its closure (an interrupted sub-dsh leaves its step — and any advertised
702
+ // tool calls — open, and the released-log reader rejects a turn/end over
703
+ // it), then turn/end lands and is persisted explicitly (the mirror's own
704
+ // persist skips a no-op pass, which is exactly the case that needs the
705
+ // boundary written). Every terminal path closes the window — a failed or
706
+ // cancelled run settles 'error'/'aborted' instead of leaving the turn open.
715
707
  void result.then(() => child.done).then(() => {
716
708
  if (spec.childSession !== undefined) {
717
709
  const childSession = spec.childSession;
718
710
  // The settle pass reports the final mirror count even when polling
719
711
  // already covered the round (empty delta): it is authoritative.
720
- return enqueueMirror(() => mirrorAndReport(childSession, true));
712
+ return enqueueMirror(async () => {
713
+ const settled = await result;
714
+ try {
715
+ await mirrorAndReport(childSession, true);
716
+ }
717
+ catch (error) {
718
+ // The mirror degrades to a warn; the turn boundary still closes —
719
+ // an open turn at EOF reads as an interrupted tail only until the
720
+ // next resume round tries to open its turn over it.
721
+ ctx.logger.warn(`subagent-dsh: settle mirror failed: ${error instanceof Error ? error.message : String(error)}`);
722
+ }
723
+ closeMirroredOpenStep(childSession);
724
+ // The turn closes at the real settle moment (plus the settle-mirror
725
+ // quiescence), so the timing projection's duration equals the actual
726
+ // CLI runtime.
727
+ if (settled.stopReason === 'completed') {
728
+ childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
729
+ }
730
+ else if (settled.stopReason === 'aborted') {
731
+ childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
732
+ }
733
+ else {
734
+ childSession.append('turn/end', {
735
+ turn,
736
+ reason: { kind: 'error', error: { message: 'dsh exited before the run completed', code: 'UNKNOWN' } },
737
+ });
738
+ }
739
+ await persistChildSession(ctx, childSession).catch((error) => {
740
+ ctx.logger.warn(`subagent-dsh: turn/end persistence failed: ${error instanceof Error ? error.message : String(error)}`);
741
+ });
742
+ });
721
743
  }
722
744
  return undefined;
723
745
  }, () => { });
@@ -28,7 +28,7 @@ import { LIVE_SERVER_NAME, LIVE_WIRE_PROTOCOL_VERSION, } from '@khorsheed/dsh-lo
28
28
  import { DEFAULT_DISPOSE_GRACE_MS, dshLaunchArgv, dshTextTask, registerMemberRun, } from './dsh-cli-provider.js';
29
29
  import { localModelLaunch } from './model-launch.js';
30
30
  import { DEFAULT_SUB_PROFILE_NAME, provisionDshScope } from './provision.js';
31
- import { mirrorDshLiveEvent, mirrorDshSession } from './session-mirror.js';
31
+ import { mirrorDshLiveEvent, mirrorDshSession, closeMirroredOpenStep, mirroredOpenStep } from './session-mirror.js';
32
32
  /** Default idle lifetime of an unused resident runtime before reclaim. */
33
33
  export const DEFAULT_LIVE_IDLE_MS = 30 * 60_000;
34
34
  /** Default timeout for a wire request acknowledgement. */
@@ -785,7 +785,13 @@ export class DshLiveDriver {
785
785
  }).then(async (settled) => {
786
786
  roundSettled = true;
787
787
  liveFlush.dispose();
788
- if (turnOpened && streaming !== undefined && streamPublisher !== undefined) {
788
+ // The interrupted stream's partial content folds durably only while its
789
+ // step is still OPEN: a graceful sub-dsh unwind writes its own
790
+ // interrupted assistant/message and step/end (both crossed the wire),
791
+ // and an append at the closed step would fail the released-log reader.
792
+ const openStep = turnOpened ? mirroredOpenStep(childSession) : undefined;
793
+ if (turnOpened && streaming !== undefined && streamPublisher !== undefined
794
+ && openStep !== undefined && openStep.step === streaming.step) {
789
795
  const blocks = streaming.assembler.interruptedBlocks();
790
796
  if (blocks.length > 0) {
791
797
  childSession.append('assistant/message', {
@@ -798,22 +804,6 @@ export class DshLiveDriver {
798
804
  }
799
805
  }
800
806
  streamPublisher?.dispose();
801
- // Identical turn/end bookkeeping to the exec path — but only for a turn
802
- // that actually opened (a pre-accept cancel records no turn at all).
803
- if (turnOpened) {
804
- if (settled.stopReason === 'completed') {
805
- childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
806
- }
807
- else if (settled.stopReason === 'aborted') {
808
- childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
809
- }
810
- else {
811
- childSession.append('turn/end', {
812
- turn,
813
- reason: { kind: 'error', error: nativeFailure ?? { message: 'the live round did not complete', code: 'UNKNOWN' } },
814
- });
815
- }
816
- }
817
807
  // Settlement clears the round's handlers: a late notification drops at
818
808
  // the runtime frame instead of landing in a dead closure.
819
809
  if (runtime !== undefined) {
@@ -821,24 +811,18 @@ export class DshLiveDriver {
821
811
  runtime.onEvent = undefined;
822
812
  runtime.onIdle = undefined;
823
813
  }
824
- // A completed answer is not a durable turn until its final boundary
825
- // and any closing stream checkpoints have reached the child store.
826
814
  if (turnOpened) {
827
- persist();
828
- await persistQueue;
829
- }
830
- return settled;
831
- });
832
- // Settle reconciliation: the file-based mirror pass (the serve side flushed
833
- // before its idle notification) dedupes against the live-mirrored prefix
834
- // and catches anything the wire dropped; the final mirror progress report
835
- // is authoritative, exactly as in the exec path. Then re-arm the reaper. A
836
- // round that never opened owns no span — skip the pass entirely (it would
837
- // re-scan the previous round or, under exec fallback, fight its polling).
838
- void result.then(async () => {
839
- try {
840
- if (turnOpened) {
841
- await persistQueue;
815
+ // Settle reconciliation IN-CHAIN: the file-based mirror pass (the serve
816
+ // side flushed before its idle notification) dedupes against the
817
+ // live-mirrored prefix and catches anything the wire dropped; the
818
+ // final mirror progress report is authoritative, exactly as in the
819
+ // exec path. It runs before turn/end so every mirrored event lands
820
+ // inside the turn window — the released-log reader rejects content
821
+ // outside an open turn on every cold read — and inside the result
822
+ // chain so the round serializer (and a resume round's turn/start)
823
+ // never overtakes the boundary. A mirror fault degrades to a warn;
824
+ // the boundary still closes.
825
+ try {
842
826
  if (mirroredMessages > 0)
843
827
  persist();
844
828
  await persistQueue.catch(() => { });
@@ -863,13 +847,35 @@ export class DshLiveDriver {
863
847
  registry?.recordRoundSettled?.(childSession.id, round);
864
848
  }
865
849
  }
850
+ catch (error) {
851
+ this.ctx.logger.warn(`subagent-dsh: live settle mirror failed: ${thrown(error).message}`);
852
+ }
853
+ // The copied tail's open step (an interrupted round leaves it open —
854
+ // the reader rejects a turn/end over it) receives its closure first.
855
+ closeMirroredOpenStep(childSession);
856
+ // Identical turn/end bookkeeping to the exec path — a completed,
857
+ // interrupted, or failed round each closes its window.
858
+ if (settled.stopReason === 'completed') {
859
+ childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
860
+ }
861
+ else if (settled.stopReason === 'aborted') {
862
+ childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
863
+ }
864
+ else {
865
+ childSession.append('turn/end', {
866
+ turn,
867
+ reason: { kind: 'error', error: nativeFailure ?? { message: 'the live round did not complete', code: 'UNKNOWN' } },
868
+ });
869
+ }
870
+ // A completed answer is not a durable turn until its final boundary
871
+ // and any closing stream checkpoints have reached the child store.
872
+ persist();
873
+ await persistQueue;
866
874
  }
867
- catch (error) {
868
- this.ctx.logger.warn(`subagent-dsh: live settle mirror failed: ${thrown(error).message}`);
869
- }
870
- finally {
871
- this.armIdleTimer(spec.sessionId);
872
- }
875
+ // Re-arm the idle reaper for every settled round — including one that
876
+ // never opened its turn (the runtime is up and must not leak).
877
+ this.armIdleTimer(spec.sessionId);
878
+ return settled;
873
879
  });
874
880
  // Publication gate: hold start() until the turn is accepted (channel
875
881
  // errors still throw for the provider's exec fallback), but NEVER hold it
@@ -29,6 +29,19 @@
29
29
  * passes never duplicate an identical snapshot and the child's `todos`
30
30
  * projection (and the official TodoPanel in non-takeover views) reads the
31
31
  * member's task list for free.
32
+ *
33
+ * Interrupted-tail closure: a sub-dsh that dies mid-step (the exec kill
34
+ * ladder, a live-runtime crash) leaves its log's step — and any advertised
35
+ * tool calls — OPEN, and the verbatim copy carries that open tail into the
36
+ * child session. The released-log reader (harness
37
+ * session-format-v3-to-v4/relationships.ts) rejects a `turn/end` over an open
38
+ * step on every cold read, so the provider's settle flow calls
39
+ * {@link closeMirroredOpenStep} after the settle mirror pass and before its
40
+ * own `turn/end`: each advertised-but-unresolved call receives a synthesized
41
+ * interrupted `tool/result` (the family's shared placeholder for a started
42
+ * call, the validator's exact TOOL_NOT_STARTED repair for one that never
43
+ * started) and the copied step receives its `step/end`. Mid-round the open
44
+ * pair still crosses as-is — the sub-dsh may yet write its end.
32
45
  * @module @khorsheed/dsh-local-agent-dsh/session-mirror
33
46
  */
34
47
  import type { DshSessionLog } from './session-log.ts';
@@ -98,6 +111,48 @@ export interface DshMirrorDelta {
98
111
  }
99
112
  /** How much of the live event stream crosses into the child session. */
100
113
  export type DshLiveMirrorGranularity = 'event' | 'token';
114
+ /**
115
+ * The placeholder text of the synthesized result for a tool call the sub-dsh
116
+ * started but never resolved (its process died between `tool/call` and
117
+ * `tool/result`). Shared verbatim with the kimi family's fold.
118
+ */
119
+ export declare const DSH_INTERRUPTED_TOOL_RESULT = "\uFF08\u5DE5\u5177\u8C03\u7528\u88AB\u4E2D\u65AD\uFF0C\u672A\u80FD\u8BB0\u5F55\u8FD4\u56DE\u7ED3\u679C\uFF09";
120
+ /** What {@link closeMirroredOpenStep} synthesized, when it closed anything. */
121
+ export interface DshMirrorTailClosure {
122
+ /** The copied open step this closure ended. */
123
+ readonly step: {
124
+ turn: number;
125
+ step: number;
126
+ };
127
+ /** The advertised calls that received a synthesized interrupted result, in advertisement order. */
128
+ readonly repairedCalls: readonly string[];
129
+ }
130
+ /**
131
+ * The child session's currently-open copied step: the (turn, step) of a
132
+ * `step/start` whose `step/end` has not crossed, inside a turn the provider
133
+ * has not closed. Undefined when nothing is open. The live driver's
134
+ * interrupted-stream fold consults it: the fold's assistant/message may land
135
+ * only while its step is still open (a graceful sub-dsh unwind writes its own
136
+ * message and step/end, and the fold must not append at the closed step).
137
+ */
138
+ export declare function mirroredOpenStep(childSession: Session): {
139
+ turn: number;
140
+ step: number;
141
+ } | undefined;
142
+ /**
143
+ * Close the copied open tail of the child session's current turn at settle,
144
+ * before the provider's `turn/end`: the released-log reader rejects a
145
+ * `turn/end` over an open step ('turn/end does not match the open turn with
146
+ * no open step') and a step/end leaving an advertised call unresolved, so an
147
+ * interrupted sub-dsh's open step is ended here — each advertised-but-
148
+ * unresolved tool call first receiving its synthesized interrupted result
149
+ * (the family's placeholder for a started call; the validator's exact
150
+ * TOOL_NOT_STARTED repair for one that never started). Mid-round transports
151
+ * never call this: the sub-dsh may yet write its own end, and a synthesized
152
+ * step/end would collide with it on a later pass. Returns what it
153
+ * synthesized, or undefined when the copied tail was already balanced.
154
+ */
155
+ export declare function closeMirroredOpenStep(childSession: Session): DshMirrorTailClosure | undefined;
101
156
  /**
102
157
  * Mirror ONE live-pushed sub-dsh session event (the serve mode's
103
158
  * `session/event` wire notification) into the child session. This is the
@@ -29,6 +29,19 @@
29
29
  * passes never duplicate an identical snapshot and the child's `todos`
30
30
  * projection (and the official TodoPanel in non-takeover views) reads the
31
31
  * member's task list for free.
32
+ *
33
+ * Interrupted-tail closure: a sub-dsh that dies mid-step (the exec kill
34
+ * ladder, a live-runtime crash) leaves its log's step — and any advertised
35
+ * tool calls — OPEN, and the verbatim copy carries that open tail into the
36
+ * child session. The released-log reader (harness
37
+ * session-format-v3-to-v4/relationships.ts) rejects a `turn/end` over an open
38
+ * step on every cold read, so the provider's settle flow calls
39
+ * {@link closeMirroredOpenStep} after the settle mirror pass and before its
40
+ * own `turn/end`: each advertised-but-unresolved call receives a synthesized
41
+ * interrupted `tool/result` (the family's shared placeholder for a started
42
+ * call, the validator's exact TOOL_NOT_STARTED repair for one that never
43
+ * started) and the copied step receives its `step/end`. Mid-round the open
44
+ * pair still crosses as-is — the sub-dsh may yet write its end.
32
45
  * @module @khorsheed/dsh-local-agent-dsh/session-mirror
33
46
  */
34
47
  import { readFile, readdir } from 'node:fs/promises';
@@ -247,7 +260,9 @@ function appendMirroredToolEvent(childSession, event) {
247
260
  * index registers the same step the sub-dsh wrote. Shared by the file
248
261
  * mirror's span loop and the live driver's per-event mirror. An interrupted
249
262
  * step crosses as an OPEN pair (start without end): the location resolves to
250
- * the step either way, and no boundary is ever synthesized.
263
+ * the step either way, and mid-round no boundary is synthesized — the sub-dsh
264
+ * may yet write its end. At settle the provider closes the copied tail with
265
+ * {@link closeMirroredOpenStep} before its own `turn/end`.
251
266
  */
252
267
  function appendMirroredStepBoundary(childSession, event) {
253
268
  if (event.type === 'step/start') {
@@ -274,6 +289,145 @@ function appendMirroredMessageEvent(childSession, event) {
274
289
  }
275
290
  return mirroredEventText(event);
276
291
  }
292
+ /**
293
+ * The placeholder text of the synthesized result for a tool call the sub-dsh
294
+ * started but never resolved (its process died between `tool/call` and
295
+ * `tool/result`). Shared verbatim with the kimi family's fold.
296
+ */
297
+ export const DSH_INTERRUPTED_TOOL_RESULT = '(工具调用被中断,未能记录返回结果)';
298
+ /**
299
+ * The released-log reader's exact repair text for a call that was advertised
300
+ * but never recorded as started: relationships.ts's notStartedRepair admits
301
+ * only that shape (the `interrupted-tool-result-<callId>-<n>` id, the
302
+ * ToolNotStartedError/TOOL_NOT_STARTED error, no sourceEventSeqs, and this
303
+ * single text block) — no other wording resolves the call.
304
+ */
305
+ const TOOL_NOT_STARTED_REPAIR_TEXT = 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.';
306
+ /**
307
+ * The child session's currently-open copied step: the (turn, step) of a
308
+ * `step/start` whose `step/end` has not crossed, inside a turn the provider
309
+ * has not closed. Undefined when nothing is open. The live driver's
310
+ * interrupted-stream fold consults it: the fold's assistant/message may land
311
+ * only while its step is still open (a graceful sub-dsh unwind writes its own
312
+ * message and step/end, and the fold must not append at the closed step).
313
+ */
314
+ export function mirroredOpenStep(childSession) {
315
+ const events = childSession.snapshotEvents();
316
+ // Backward: the latest boundary event says the state — a step/start opens,
317
+ // a step/end or any turn boundary closes the question.
318
+ for (let index = events.length - 1; index >= 0; index -= 1) {
319
+ const type = events[index]?.type;
320
+ if (type === 'step/start')
321
+ return events[index]?.data;
322
+ if (type === 'step/end' || type === 'turn/start' || type === 'turn/end')
323
+ return undefined;
324
+ }
325
+ return undefined;
326
+ }
327
+ /**
328
+ * Close the copied open tail of the child session's current turn at settle,
329
+ * before the provider's `turn/end`: the released-log reader rejects a
330
+ * `turn/end` over an open step ('turn/end does not match the open turn with
331
+ * no open step') and a step/end leaving an advertised call unresolved, so an
332
+ * interrupted sub-dsh's open step is ended here — each advertised-but-
333
+ * unresolved tool call first receiving its synthesized interrupted result
334
+ * (the family's placeholder for a started call; the validator's exact
335
+ * TOOL_NOT_STARTED repair for one that never started). Mid-round transports
336
+ * never call this: the sub-dsh may yet write its own end, and a synthesized
337
+ * step/end would collide with it on a later pass. Returns what it
338
+ * synthesized, or undefined when the copied tail was already balanced.
339
+ */
340
+ export function closeMirroredOpenStep(childSession) {
341
+ const events = childSession.snapshotEvents();
342
+ // Only the current (last) turn can hold the copied open tail: an earlier
343
+ // turn closed with the provider's turn/end, which no valid prefix crossed
344
+ // with open state.
345
+ let turnStart = -1;
346
+ for (let index = events.length - 1; index >= 0; index -= 1) {
347
+ const type = events[index]?.type;
348
+ if (type === 'turn/end')
349
+ return undefined;
350
+ if (type === 'turn/start') {
351
+ turnStart = index;
352
+ break;
353
+ }
354
+ }
355
+ if (turnStart < 0)
356
+ return undefined;
357
+ let openStep;
358
+ // The turn's tool-call ledger: an assistant/message's tool-call blocks
359
+ // advertise, a tool/call starts, a tool/result resolves — its message names
360
+ // the call at the top level (the V4 tool-role shape) or in its single
361
+ // content block (the pre-V4 user-role shape); read both, a mirrored log may
362
+ // predate the upgrade.
363
+ const advertised = new Set();
364
+ const started = new Map();
365
+ const resolved = new Set();
366
+ for (const event of events.slice(turnStart + 1)) {
367
+ if (event.type === 'step/start') {
368
+ openStep = event.data;
369
+ }
370
+ else if (event.type === 'step/end') {
371
+ openStep = undefined;
372
+ }
373
+ else if (event.type === 'assistant/message') {
374
+ const content = event.data.message?.content ?? [];
375
+ for (const block of content) {
376
+ if (block?.type === 'tool-call' && typeof block.id === 'string')
377
+ advertised.add(block.id);
378
+ }
379
+ }
380
+ else if (event.type === 'tool/call') {
381
+ const callId = event.data.callId;
382
+ if (typeof callId === 'string')
383
+ started.set(callId, event.seq);
384
+ }
385
+ else if (event.type === 'tool/result') {
386
+ const message = event.data.message;
387
+ const id = message?.toolCallId ?? message?.content?.[0]?.toolCallId;
388
+ if (typeof id === 'string')
389
+ resolved.add(id);
390
+ }
391
+ }
392
+ if (openStep === undefined)
393
+ return undefined;
394
+ const step = openStep;
395
+ const repaired = [];
396
+ // The seq the next appended event occupies (seq == log length), so the
397
+ // synthesized ids carry the harness's repair-id convention.
398
+ let nextSeq = events.length;
399
+ for (const callId of advertised) {
400
+ if (resolved.has(callId))
401
+ continue;
402
+ const callSeq = started.get(callId);
403
+ const message = {
404
+ id: `interrupted-tool-result-${callId}-${nextSeq}`,
405
+ role: 'tool',
406
+ toolCallId: callId,
407
+ isError: true,
408
+ source: { kind: 'tool', callId },
409
+ content: [{ type: 'text', text: callSeq === undefined ? TOOL_NOT_STARTED_REPAIR_TEXT : DSH_INTERRUPTED_TOOL_RESULT }],
410
+ };
411
+ nextSeq += 1;
412
+ childSession.append('tool/result', {
413
+ turn: step.turn,
414
+ step: step.step,
415
+ message,
416
+ error: callSeq === undefined
417
+ ? { name: 'ToolNotStartedError', code: 'TOOL_NOT_STARTED' }
418
+ : { name: 'ToolOutcomeUnknownError', code: 'TOOL_OUTCOME_UNKNOWN' },
419
+ }, {
420
+ surfaceOp: 'append',
421
+ // The not-started repair must carry NO sourceEventSeqs (the reader
422
+ // admits exactly that shape); the started repair pairs with the copied
423
+ // call event, exactly as a real mirrored result does.
424
+ ...callSeq === undefined ? {} : { sourceEventSeqs: [callSeq] },
425
+ });
426
+ repaired.push(callId);
427
+ }
428
+ childSession.append('step/end', { turn: step.turn, step: step.step });
429
+ return { step, repairedCalls: repaired };
430
+ }
277
431
  /**
278
432
  * Mirror ONE live-pushed sub-dsh session event (the serve mode's
279
433
  * `session/event` wire notification) into the child session. This is the
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "local-agent",
9
9
  "subagent"
10
10
  ],
11
- "version": "0.2.0-rc.3",
11
+ "version": "0.2.0",
12
12
  "type": "module",
13
13
  "main": "lib/index.js",
14
14
  "types": "lib/types/index.d.ts",
@@ -75,8 +75,8 @@
75
75
  "dsh": ">=0.1.5-rc.1 <0.3.0-0"
76
76
  },
77
77
  "dependencies": {
78
- "@khorsheed/dsh-local-agent": "^0.2.0-rc.3",
79
- "@khorsheed/dsh-local-agent-dsh-headless": "^0.2.0-rc.3",
78
+ "@khorsheed/dsh-local-agent": "^0.2.0",
79
+ "@khorsheed/dsh-local-agent-dsh-headless": "^0.2.0",
80
80
  "js-yaml": "^4.3.1"
81
81
  },
82
82
  "peerDependencies": {