dsh-cindy-host 0.1.33 → 0.1.34

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.34 — 2026-11-05
4
+
5
+ - Project durable native failed turns as stable, sanitized error rows and terminal maker events in both cold history and live delivery. Preserve native failure/retry ownership, expose only fixed known-code messages and validated HTTP status, and repair reconnect state without stopping newer turns or leaking provider diagnostics.
6
+
7
+ - Register user questions before either surface displays them, including sessions a phone has never visited. Replay the existing pending cards on session subscribe/reconnect; keep them through temporary relay recovery and remove the hidden Host question timeout, without changing permission ownership/deadlines.
8
+ - Settle local and phone answers through the same pending completion; preserve native cancellation/timeout errors and clear both cards on task stop instead of returning empty/undefined answers. Keep the existing Cindy protocol and DSH native UI. Add focused Host regressions and opt-in tests against installed Cordis/userQuestions/Gateway; these are not live-handset acceptance or a production deployment.
9
+
3
10
  ## 0.1.33 — 2026-11-04
4
11
 
5
12
  - Add authorized current-surface reference-source history through native observations, real message identities, filters/cursors and explicit clipping metadata. Keep frozen target-reference capability false and reject structured contexts before every admission/edit/preparation/replay boundary; do not disguise source reads or plain links as target consumption.
package/README.md CHANGED
@@ -4,12 +4,12 @@ A Host-side adapter connecting current Cindy clients to DeepSeek Harness.
4
4
 
5
5
  ## Requirements and installation
6
6
 
7
- Version **0.1.33** adds the regional authentication UI, public reasoning streaming and the native capability subsets described below.
7
+ Version **0.1.34** improves native/phone question synchronization and cancellation, and safely displays durable model/turn failures. It retains 0.1.33's regional authentication UI, public reasoning streaming and native capability subsets.
8
8
 
9
9
  It supports the exact **DSH 0.2.2-alpha.2** carrier, while retaining `0.2.1-rc.3`, `0.2.1-rc.4` and `0.2.1-rc.5`. Alpha requires **Node.js >=24.9** (tested with 24.12); the older carriers retain this plugin's Node.js >=22.16 minimum. Agent/session/settings peers and alpha Schemastery are explicitly allowed, not a blanket promise for future prereleases. The full Web installation gate uses DSH **0.2.1-rc.5**; alpha core-contract checks do not establish alpha Web/relay/handset acceptance. Install through the Desktop DSH plugin manager. CLI-managed profiles can use:
10
10
 
11
11
  ```sh
12
- dsh plugin --profile <profile> add dsh-cindy-host@0.1.33
12
+ dsh plugin --profile <profile> add dsh-cindy-host@0.1.34
13
13
  ```
14
14
 
15
15
  Reload the Host plugin after installation. A full DSH restart is separate. Keep existing Cindy sign-in, device identity and settings; do not run two Host processes with the same identity.
@@ -99,6 +99,20 @@ During generation, `assistant/chunk` reasoning deltas are displayed as temporary
99
99
 
100
100
  This ships in 0.1.33; handset acceptance is separate from package checks and local Host activation. Offline regressions execute the pinned real Cindy mobile thinking normalizer; live phone/network acceptance remains separate.
101
101
 
102
+ ## Questions on DSH and Cindy
103
+
104
+ While the Host bridge is enabled, questions are registered before presentation, even if no phone has opened that session. DSH's native question UI and the existing Cindy card can both answer; the first accepted answer settles the shared pending entry and dismisses the other surface. Session open/reconnect replays pending cards with the same request IDs. Temporary relay loss does not cancel questions.
105
+
106
+ Questions have no extra Host timeout. The caller's stop/cancel and DSH's own timed-question lifecycle still apply; explicit Host switch-off/unload cancels outstanding bridge waits. Permission cards retain their existing ownership and deadline. No Cindy/DSH patch or phone protocol upgrade is needed. This does not add cross-process recovery or mobile late-answer support after DSH has continued a timed call.
107
+
108
+ Run the focused regressions with:
109
+
110
+ ```sh
111
+ node --test test/user-question-bridge.test.js test/host-approvals.test.js test/dsh-plugin.test.js
112
+ ```
113
+
114
+ The opt-in installed-runtime suite additionally exercises real Cordis, userQuestions and Gateway event delivery/replay/cancellation. Point `DSH_TEST_NATIVE_MODULES` at a built DSH node_modules directory, then run `node --test --test-isolation=none test/user-question-native.test.js`. For an Electron asar installation use the installed executable with `ELECTRON_RUN_AS_NODE=1`. These checks use isolated contexts and do not reload the running GUI; real-handset acceptance is separate.
115
+
102
116
  ## Model switching follows DSH
103
117
 
104
118
  When native `sessionController.selectModel` is composed, the Host tells Cindy to delegate model-window decisions to DSH. Users may select another model during a running turn and select the original model again. DSH validates the provider/model/effort and records the selection for the **next model request**; an already-issued request is not interrupted. DSH owns context preparation and compaction, not Cindy's legacy 90%-window precheck or a Host-side summary/rebuild implementation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cindy-host",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "private": false,
5
5
  "description": "Cindy 手机客户端连接 DSH 的 Host 侧插件:会话/消息/审批/todo/goal 的手机通道,以及配套的设置卡片",
6
6
  "license": "MIT",
@@ -609,13 +609,17 @@ export function createChannelRouter({
609
609
  }
610
610
  }
611
611
  for (const sessionId of sessionIds) {
612
- let row = live === null ? null : live.find((candidate) => String(candidate?.id) === sessionId) ?? null;
613
- if (typeof getSession === 'function') {
614
- try { row = await getSession(sessionId) ?? null; } catch { row = null; }
612
+ if (typeof subscribing.restoreTurnState === 'function') {
613
+ await subscribing.restoreTurnState(request.src, sessionId);
614
+ } else {
615
+ let row = live === null ? null : live.find((candidate) => String(candidate?.id) === sessionId) ?? null;
616
+ if (typeof getSession === 'function') {
617
+ try { row = await getSession(sessionId) ?? null; } catch { row = null; }
618
+ }
619
+ const running = row === null ? subscribing.isSessionRunning(sessionId) === true : row.running === true;
620
+ if (running) subscribing.pushTurnRunning(sessionId);
621
+ else subscribing.pushTurnIdle(sessionId);
615
622
  }
616
- const running = row === null ? subscribing.isSessionRunning(sessionId) === true : row.running === true;
617
- if (running) subscribing.pushTurnRunning(sessionId);
618
- else subscribing.pushTurnIdle(sessionId);
619
623
  // …and the two things a push that never arrived would have carried.
620
624
  //
621
625
  // Measured: an app returning from the background does an unsubscribe → resubscribe
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface';
19
19
  import { createReasoningStream } from './reasoning-stream.js';
20
+ import { turnErrorRow, turnStateFromEvents } from './turn-error.js';
20
21
  import { toCindyMessageRows, toCindyMessages, hydrateImageAttachments } from './cindy-message-row.js';
21
22
 
22
23
  /** Default page size when the controller does not ask for one. */
@@ -81,6 +82,8 @@ export const TRANSCRIPT_CACHE_SESSIONS = 3;
81
82
  */
82
83
  export function foldSessionEvent(event, { sessionId, now = () => new Date() }) {
83
84
  if (event === null || typeof event !== 'object') return [];
85
+ const error = turnErrorRow(event, sessionId);
86
+ if (error) return [error];
84
87
  if (!isAppendSurfaceEvent(event)) return [];
85
88
  let message;
86
89
  try {
@@ -387,6 +390,11 @@ export function createMessageReader({
387
390
  // gap as "nothing happened here".
388
391
  if (Number.isFinite(event.seq)) lastSeq = event.seq;
389
392
  reasoning.consume(event);
393
+ const error = turnErrorRow(event, sessionId);
394
+ if (error) {
395
+ entries.push({ rows: [error] });
396
+ continue;
397
+ }
390
398
  const todo = entryForTodoWrite(event);
391
399
  if (todo !== null) {
392
400
  entries.push(todo);
@@ -477,7 +485,15 @@ export function createMessageReader({
477
485
  flight.promise = Promise.resolve().then(async () => {
478
486
  try {
479
487
  const read = await entriesFor(sessionId);
480
- const rows = toCindyMessages(read.entries, { sessionId, now });
488
+ // Error rows are presentation, never fabricated model messages. Keep each
489
+ // message's block order while reversing event order, as toCindyMessages does.
490
+ const seen = new Set();
491
+ const rows = read.entries.slice().reverse().flatMap(entry => entry.rows
492
+ ?? toCindyMessages([entry], { sessionId, now })).filter(row => {
493
+ if (seen.has(row.id)) return false;
494
+ seen.add(row.id);
495
+ return true;
496
+ });
481
497
  // A live event during replay makes the snapshot unsafe to cache. Explicit
482
498
  // invalidation also detaches this flight so a post-invalidation read starts
483
499
  // fresh; an older completion must never overwrite that newer generation.
@@ -593,6 +609,12 @@ export function createMessageReader({
593
609
  * The array is the cache's own, so callers treat it as read-only.
594
610
  */
595
611
  readMessages.all = async (sessionId) => visibleRows(await ensureTranscript(sessionId));
612
+ // Recovery deliberately bypasses the transcript cache: stale history cannot
613
+ // decide whether the *current* turn has ended. Runtime generation guards the read.
614
+ readMessages.turnState = async (sessionId) => {
615
+ const snapshot = await readSessionLog(sessionId);
616
+ return turnStateFromEvents(snapshot?.events, sessionId);
617
+ };
596
618
  readMessages.stats = stats;
597
619
  return readMessages;
598
620
  }
package/src/dsh-plugin.js CHANGED
@@ -1685,48 +1685,43 @@ export function createSettingsScope(ctx, entryId, projected) {
1685
1685
 
1686
1686
 
1687
1687
  /**
1688
- * Present one interaction to Cindy and the downstream DSH UI, settling on the first answer.
1689
- * The downstream branch receives a private abort signal so the losing local card is dismissed;
1690
- * without that cancellation contract a Promise.race would leak a parked waterfall listener.
1688
+ * Forward once to DSH and settle both surfaces through the existing registry.
1689
+ * The losing native card is dismissed through its private cancellation signal.
1691
1690
  */
1692
1691
  export async function arbitrateInteraction({ request, next, begin, settleLocal }) {
1693
- const handle = await begin();
1694
- if (handle === null) return next();
1695
-
1696
1692
  const downstreamAbort = new AbortController();
1697
1693
  const upstreamSignal = request?.signal;
1698
1694
  const downstreamSignal = upstreamSignal === undefined
1699
1695
  ? downstreamAbort.signal
1700
1696
  : AbortSignal.any([upstreamSignal, downstreamAbort.signal]);
1701
- let cancellable = false;
1702
1697
  try {
1703
1698
  request.signal = downstreamSignal;
1704
- cancellable = request.signal === downstreamSignal;
1705
1699
  } catch {
1706
1700
  try {
1707
1701
  Object.defineProperty(request, 'signal', { value: downstreamSignal, configurable: true, writable: true });
1708
- cancellable = request.signal === downstreamSignal;
1709
1702
  } catch {
1710
- cancellable = false;
1703
+ // Without cancellation, use the original native path rather than a phone-only card.
1704
+ return next();
1711
1705
  }
1712
1706
  }
1713
- // Never start a branch we cannot dismiss; keep the existing Cindy answer path instead.
1714
- if (!cancellable) return handle.answered;
1707
+ if (request.signal !== downstreamSignal) return next();
1708
+ const handle = await begin(upstreamSignal);
1709
+ if (handle === null) return next();
1715
1710
 
1716
- const controller = Promise.resolve(handle.answered).then((value) => ({ kind: 'controller', value }));
1717
- const local = Promise.resolve().then(next).then(
1718
- (value) => ({ kind: 'local', value }),
1719
- (error) => ({ kind: 'local-error', error }),
1720
- );
1721
- const first = await Promise.race([controller, local]);
1722
- if (first.kind === 'local-error') return (await controller).value;
1723
- if (first.kind === 'local') {
1724
- settleLocal(handle.requestId, first.value);
1725
- return first.value;
1711
+ void Promise.resolve().then(next).then(
1712
+ (value) => settleLocal(handle.requestId, value),
1713
+ (error) => {
1714
+ // Native cancellation/timeout is a lifecycle result, not a failed delivery.
1715
+ // A missing/disconnected local answerer still leaves the phone usable.
1716
+ if (error?.name === 'UserQuestionError' && error.code !== 'NO_PROVIDER') handle.cancel?.(error);
1717
+ },
1718
+ ).catch((error) => handle.cancel?.(error));
1719
+ try {
1720
+ // A rejected late local settlement cannot override an already accepted phone answer.
1721
+ return await handle.answered;
1722
+ } finally {
1723
+ downstreamAbort.abort(new Error('interaction settled'));
1726
1724
  }
1727
- downstreamAbort.abort(new Error('interaction settled by Cindy'));
1728
- void local.then(() => undefined, () => undefined);
1729
- return first.value;
1730
1725
  }
1731
1726
 
1732
1727
  export function apply(ctx, config) {
@@ -1784,27 +1779,30 @@ export function apply(ctx, config) {
1784
1779
  }
1785
1780
 
1786
1781
  /**
1787
- * Wrap one host-facing listener in the plugin's single guarded entry point.
1782
+ * Record failures; contain side effects but propagate awaited question failures.
1788
1783
  * @param label - what this listener is, for the diagnostics record.
1789
1784
  * @param listener - the listener as written.
1790
- * @returns a listener that cannot let its failure escape.
1785
+ * @param options - rethrow only for awaited requests, never fire-and-forget events.
1786
+ * @returns the guarded listener.
1791
1787
  */
1792
- function guarded(label, listener) {
1788
+ function guarded(label, listener, { rethrow = false } = {}) {
1793
1789
  guardedListeners.push(label);
1794
1790
  return (...args) => {
1795
1791
  try {
1796
1792
  const result = listener(...args);
1797
1793
  if (result !== null && typeof result === 'object' && typeof result.then === 'function') {
1798
- // A waterfall listener's rejection is the dangerous shape: DSH awaits what it
1799
- // returns, so a rejection here arrives as an unhandled one at the process level.
1794
+ // Awaited questions reach userQuestions.ask so cancellation keeps its
1795
+ // meaning; fire-and-forget side effects still contain their failures.
1800
1796
  return result.catch((error) => {
1801
1797
  noteBoundaryError(label, error);
1798
+ if (rethrow) throw error;
1802
1799
  return undefined;
1803
1800
  });
1804
1801
  }
1805
1802
  return result;
1806
1803
  } catch (error) {
1807
1804
  noteBoundaryError(label, error);
1805
+ if (rethrow) throw error;
1808
1806
  return undefined;
1809
1807
  }
1810
1808
  };
@@ -2004,9 +2002,7 @@ export function apply(ctx, config) {
2004
2002
  // stopped its timer while the agent was still generating. `turn/start` and
2005
2003
  // `turn/end` are the boundaries that mean what the phone reads into them.
2006
2004
  if (event?.type === 'turn/start' && typeof runtime.pushTurnRunning === 'function') {
2007
- runtime.pushTurnRunning(sessionId);
2008
- } else if (event?.type === 'turn/end' && typeof runtime.pushTurnIdle === 'function') {
2009
- runtime.pushTurnIdle(sessionId);
2005
+ runtime.pushTurnRunning(sessionId, event);
2010
2006
  }
2011
2007
  // A real boundary needs no fallback, and arming one here is not harmless: this
2012
2008
  // handler runs *after* the boundary was announced, so it re-armed the very
@@ -2034,6 +2030,9 @@ export function apply(ctx, config) {
2034
2030
  currentSeam.readMessages.noteEvent(sessionId, event);
2035
2031
  currentSource?.invalidateActivityEvidence?.(sessionId);
2036
2032
  }
2033
+ // Make the failed history row readable before publishing its terminal identity.
2034
+ // Everything above is synchronous, so a new turn cannot interleave here.
2035
+ if (event?.type === 'turn/end' && typeof runtime.pushTurnIdle === 'function') runtime.pushTurnIdle(sessionId, event);
2037
2036
  // Chunks are intentionally absent from deriveEventMessage. Publish a temporary
2038
2037
  // thinking overlay too, retiring it before the durable assistant rows below.
2039
2038
  runtime.pushSessionReasoning?.(sessionId, event, {
@@ -2065,12 +2064,12 @@ export function apply(ctx, config) {
2065
2064
  return arbitrateInteraction({
2066
2065
  request: req,
2067
2066
  next,
2068
- begin: () => runtime.beginApproval({
2067
+ begin: (signal) => runtime.beginApproval({
2069
2068
  sessionId: typeof sessionId === 'string' ? sessionId : '',
2070
2069
  toolName: typeof req?.toolName === 'string' ? req.toolName : '',
2071
2070
  reason: req?.reason,
2072
2071
  callId: req?.callId,
2073
- signal: req?.signal,
2072
+ signal,
2074
2073
  }),
2075
2074
  settleLocal: (requestId, outcome) => runtime.settleInteractionOutcome(requestId, outcome),
2076
2075
  });
@@ -2092,28 +2091,26 @@ export function apply(ctx, config) {
2092
2091
  // to the browser path unchanged.
2093
2092
  }), { prepend: true, global: true }), 'dsh-cindy-host: approval answerer');
2094
2093
 
2095
- // The `ask_user` tool blocks on a different waterfall from approvals, and the
2096
- // controller renders it as its own card. Leaving it unanswered does not degrade
2097
- // the conversation — it stops it: the tool call waits for a human who never saw
2098
- // the question. Same rule as approvals: only claim questions for a session a
2099
- // controller is watching, and pass the rest down the chain.
2094
+ // Questions are registered even before a phone opens the session, then also
2095
+ // forwarded to DSH. Unlike side-effect listeners this awaited waterfall must
2096
+ // propagate cancellation instead of returning an undefined successful result.
2100
2097
  ctx.effect(() => ctx.on('user-questions/request', guarded('user-questions-request', async (request, next) => {
2101
2098
  if (!runtime || typeof runtime.beginUserQuestion !== 'function') return next();
2102
2099
  const sessionId = request?.agent?.session?.header?.id;
2103
2100
  return arbitrateInteraction({
2104
2101
  request,
2105
2102
  next,
2106
- begin: () => runtime.beginUserQuestion({
2103
+ begin: (signal) => runtime.beginUserQuestion({
2107
2104
  sessionId: typeof sessionId === 'string' ? sessionId : '',
2108
2105
  questions: request?.questions,
2109
- signal: request?.signal,
2106
+ signal,
2110
2107
  }),
2111
2108
  settleLocal: (requestId, outcome) => runtime.settleInteractionOutcome(requestId, outcome),
2112
2109
  });
2113
2110
  // Same `prepend` + `global` requirement as the approval answerer above, and the
2114
2111
  // same reason: this waterfall is agent-scoped and the Web bundle's remote
2115
2112
  // forwarding listener parks ahead of a late registration.
2116
- }), { prepend: true, global: true }), 'dsh-cindy-host: user-question answerer');
2113
+ }, { rethrow: true }), { prepend: true, global: true }), 'dsh-cindy-host: user-question answerer');
2117
2114
 
2118
2115
  // The settings page reaches the Host through this route. `webServer` is absent
2119
2116
  // in headless compositions, so the row is injected optionally: the runtime and
@@ -15,7 +15,9 @@
15
15
  *
16
16
  * Outcomes are DSH's own vocabulary (`'allowed-once' | 'rejected' | 'cancelled' |
17
17
  * 'unavailable'`), not the controller's: `'allowed-once'` is the only grant DSH
18
- * defines, so every non-allow answer maps to `'rejected'`.
18
+ * defines, so every non-allow answer maps to `'rejected'`. Structured questions
19
+ * are registered independently of watching and have no Host timeout; their
20
+ * caller's cancellation is propagated as a question failure.
19
21
  */
20
22
 
21
23
  /** The controller's interaction kind for a permission question. */
@@ -145,17 +147,20 @@ export function createApprovalRegistry({
145
147
  /**
146
148
  * Register one pending interaction and hand back its lifetime controls.
147
149
  *
148
- * Both seams go through here so their timeouts, aborts and dismissals behave
149
- * identically — a question that can hang a turn deserves the same care as an
150
- * approval that can.
150
+ * Both seams share single settlement and dismissal. Permission waits have a
151
+ * deadline; structured questions follow the calling task's lifetime only.
151
152
  */
152
- function register({ sessionId, request, prefix, signal, answerOf }) {
153
+ function register({ sessionId, request, signal, answerOf, waitMs = timeoutMs }) {
153
154
  let settle;
154
- const answered = new Promise((resolve) => {
155
+ let reject;
156
+ const answered = new Promise((resolve, fail) => {
155
157
  settle = resolve;
158
+ reject = fail;
156
159
  });
160
+ // The upstream signal may already be aborted before the async caller attaches.
161
+ void answered.catch(() => {});
157
162
 
158
- const finish = (outcome) => {
163
+ const finish = (outcome, error) => {
159
164
  const entry = pending.get(request.requestId);
160
165
  if (entry === undefined) return;
161
166
  pending.delete(request.requestId);
@@ -180,22 +185,31 @@ export function createApprovalRegistry({
180
185
  kind: request.kind,
181
186
  });
182
187
  }
183
- settle(outcome);
188
+ if (error !== undefined) reject(error);
189
+ else settle(outcome);
190
+ };
191
+ const cancel = (reason) => {
192
+ if (request.kind !== QUESTION_KIND) return finish(answerOf.cancelled);
193
+ const error = reason?.name === 'UserQuestionError' ? reason : Object.assign(
194
+ new Error('ask_user_question was cancelled', { cause: reason }),
195
+ { name: 'UserQuestionError', code: 'ASK_ABORTED' },
196
+ );
197
+ finish(undefined, error);
184
198
  };
185
199
 
186
- // The asker withdrawing the question settles it immediately, and a late
187
- // answer from the controller is then discarded by `finish`'s guard.
188
- const onAbort = signal === undefined ? null : () => finish(answerOf.cancelled);
200
+ // Withdrawals end the wait, but are never an empty successful answer.
201
+ const onAbort = signal === undefined ? null : () => cancel(signal.reason);
189
202
  if (onAbort !== null) signal.addEventListener('abort', onAbort, { once: true });
190
203
 
191
- const timer = timeoutMs > 0
192
- ? setTimer(() => finish(answerOf.cancelled), timeoutMs)
204
+ const timer = waitMs > 0
205
+ ? setTimer(() => cancel(), waitMs)
193
206
  : null;
194
207
  if (typeof timer?.unref === 'function') timer.unref();
195
208
 
196
- pending.set(request.requestId, { sessionId, request, settle: finish, timer, onAbort, answerOf });
197
- if (request.kind === QUESTION_KIND) notifyQuestionObservers(questionRequestEvent(request));
198
- return { requestId: request.requestId, request, answered };
209
+ pending.set(request.requestId, { sessionId, request, settle: finish, cancel, timer, onAbort, answerOf });
210
+ if (signal?.aborted) cancel(signal.reason);
211
+ else if (request.kind === QUESTION_KIND) notifyQuestionObservers(questionRequestEvent(request));
212
+ return { requestId: request.requestId, request, answered, cancel };
199
213
  }
200
214
 
201
215
  /**
@@ -234,7 +248,7 @@ export function createApprovalRegistry({
234
248
  * sees the card cannot answer it, and the conversation stops there.
235
249
  *
236
250
  * @param options - the questions, the asking session, and the caller's abort signal.
237
- * @returns DSH's `AskUserQuestionAnswer`; a cancellation answers every question with nothing.
251
+ * @returns A pending handle; cancellation rejects with DSH's question error vocabulary.
238
252
  */
239
253
  async function askUser({ sessionId, questions, signal }) {
240
254
  const items = Array.isArray(questions) ? questions : [];
@@ -247,13 +261,12 @@ export function createApprovalRegistry({
247
261
  questions: items.map((question) => ({ ...question })),
248
262
  createdAt: now().toISOString(),
249
263
  };
250
- const cancelled = { answers: items.map((question) => ({ id: String(question?.id ?? ''), selected: [] })) };
251
264
  return register({
252
265
  sessionId,
253
266
  request,
254
267
  signal,
268
+ waitMs: 0, // Like Cindy ask/plan, only the caller's lifecycle ends this wait.
255
269
  answerOf: {
256
- cancelled,
257
270
  from: (decision) => questionAnswerOf(items, decision),
258
271
  },
259
272
  });
@@ -318,10 +331,11 @@ export function createApprovalRegistry({
318
331
  return { accepted: true };
319
332
  }
320
333
 
321
- /** Forget every open question (the switch going off). */
322
- function clear() {
323
- for (const requestId of [...pending.keys()]) {
324
- pending.get(requestId).settle(pending.get(requestId).answerOf.cancelled);
334
+ /** A relay retry preserves questions; explicit shutdown cancels all waits. */
335
+ function clear({ preserveQuestions = false } = {}) {
336
+ for (const entry of [...pending.values()]) {
337
+ if (preserveQuestions && entry.request.kind === QUESTION_KIND) continue;
338
+ entry.cancel();
325
339
  }
326
340
  }
327
341
 
@@ -4,4 +4,4 @@
4
4
  */
5
5
  export const HOST_APP_VERSION = '0.1.82';
6
6
  /** Actual npm release, separate from the Cindy compatibility identity. */
7
- export const HOST_PLUGIN_VERSION = '0.1.33';
7
+ export const HOST_PLUGIN_VERSION = '0.1.34';
package/src/host.js CHANGED
@@ -36,6 +36,7 @@ import { SessionReadModel } from './session-read-model.js';
36
36
  import { ProjectionReadModelSink } from './projection-read-model-sink.js';
37
37
  import { publishLifecycle, runningStatusEvent, turnEventFor } from './session-publisher.js';
38
38
  import { createReasoningPublisher } from './reasoning-publisher.js';
39
+ import { turnErrorRow, turnErrorPayload } from './turn-error.js';
39
40
  import { acceptLink } from './host-authorization.js';
40
41
  import { AuthorizationPolicy } from './authorization-policy.js';
41
42
  import { HostStatus } from './host-status.js';
@@ -338,14 +339,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
338
339
  /** Questions DSH is waiting on, answered by whichever controller is watching. */
339
340
  const approvals = createApprovalRegistry({
340
341
  now,
341
- // How long a controller has to answer a card before the asker is released with
342
- // "cancelled". Two minutes was the default and it is the wrong budget for the
343
- // surface this Host serves: the phone's card has no countdown, the user may be
344
- // away from it, and the path this replaces (the Web bundle's remote forwarder)
345
- // waits indefinitely — so a short Host timeout is strictly worse than the
346
- // reference. Half an hour with cancellation as the escape hatch
347
- // (`maker:input:stop` settles the card immediately) mirrors the reference while
348
- // still bounding a forgotten turn.
342
+ // Permission cards keep their existing deadline. Questions have no Host
343
+ // timeout: only an answer or the caller's stop/cancel lifecycle ends them.
349
344
  timeoutMs: 30 * 60_000,
350
345
  // Push the dismissal to every watcher, not just the controller that
351
346
  // answered: the card is gone for all of them.
@@ -703,7 +698,11 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
703
698
  // ones: a running badge on the session list depends on them, so they must
704
699
  // be answerable from diagnostics too.
705
700
  const record = (channel, watchers, payload) => recordPush(null, channel, watchers, payload);
706
- if (ws !== null) publishLifecycle(ws, subscribers, item, record);
701
+ // A summary-level error is only an invalidation hint; it must not
702
+ // terminate a newer turn on list-only phones either.
703
+ const nativeRecovery = typeof options.resolveCapabilities?.()?.readMessages?.turnState === 'function';
704
+ const lifecycle = item.phase === 'error' && nativeRecovery ? { ...item, phase: 'active' } : item;
705
+ if (ws !== null) publishLifecycle(ws, subscribers, lifecycle, record);
707
706
  publishTurnEvent(item);
708
707
  }));
709
708
  await projection.start(source);
@@ -721,6 +720,18 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
721
720
  * @param item - one lifecycle item from the projection sink.
722
721
  */
723
722
  function publishTurnEvent(item) {
723
+ // The summary error signal has no durable identity and can arrive late.
724
+ // Recover from native boundaries instead of inventing a successful done.
725
+ if (item?.phase === 'error') {
726
+ if (typeof options.resolveCapabilities?.()?.readMessages?.turnState !== 'function') {
727
+ announceTurnIdle(item.sessionId); // Legacy source: keep its status-only correction.
728
+ return;
729
+ }
730
+ for (const deviceId of sessionSubscribers.get(item.sessionId) ?? []) {
731
+ void restoreWatchedTurnState(deviceId, item.sessionId);
732
+ }
733
+ return;
734
+ }
724
735
  // Keep the cached row's turn state in step with the lifecycle itself.
725
736
  //
726
737
  // The cache is refreshed by a **list read**, and a controller sitting inside
@@ -749,6 +760,9 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
749
760
  async function setSource(next) {
750
761
  if (source !== next) {
751
762
  sourceRevision++;
763
+ nativeBoundaries.clear();
764
+ turnVersions.clear();
765
+ terminalIdentities.clear();
752
766
  source?.manualCompact?.cancelAll('DSH source replaced');
753
767
  source?.forkSession?.cancelAll();
754
768
  source?.keywordSearch?.cancelAll();
@@ -872,6 +886,18 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
872
886
  * terminal announcement for the same turn; this is the de-duplication window.
873
887
  */
874
888
  const lastTerminalAnnounce = new Map();
889
+ // Disposable delivery guards, not a second error ledger. Only the latest
890
+ // identity per device/session and native boundary per session are retained.
891
+ const terminalIdentities = new Map();
892
+ const subscriptionLeases = new Map();
893
+ const nativeBoundaries = new Map();
894
+ const turnVersions = new Map();
895
+
896
+ function renewTurnLease(deviceId, sessionId) {
897
+ const key = terminalAnnounceKey(deviceId, sessionId);
898
+ subscriptionLeases.set(key, (subscriptionLeases.get(key) ?? 0) + 1);
899
+ terminalIdentities.delete(key);
900
+ }
875
901
  const TERMINAL_ANNOUNCE_WINDOW_MS = 30_000;
876
902
  /**
877
903
  * Devices the relay has told us it cannot route to.
@@ -898,6 +924,79 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
898
924
  return `${deviceId}\u0000${sessionId}`;
899
925
  }
900
926
 
927
+ /** Send a native terminal identity once per authorized subscription/connection. */
928
+ function sendNativeTerminal(deviceId, sessionId, payload, identity) {
929
+ if (stopped || !active || !acceptedControllers.has(deviceId) || !policy.canAccept(deviceId) || sessionIsHidden(sessionId)
930
+ || !sessionSubscribers.get(sessionId)?.has(deviceId)) return false;
931
+ const key = terminalAnnounceKey(deviceId, sessionId);
932
+ const stamp = `${generation}:${subscriptionLeases.get(key) ?? 0}:${identity}`;
933
+ if (terminalIdentities.get(key) === stamp) return false;
934
+ recordPush(sessionId, 'maker:event', offlineDevices.has(deviceId) ? 0 : 1, payload);
935
+ send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'maker:event', payload } });
936
+ if (!offlineDevices.has(deviceId)) {
937
+ terminalIdentities.set(key, stamp);
938
+ noteTerminalAnnounce(deviceId, sessionId);
939
+ }
940
+ return true;
941
+ }
942
+
943
+ /** Event callers intentionally fire-and-forget; provider replacement must not
944
+ * turn a failed recovery into an unhandled rejection at the DSH boundary. */
945
+ async function restoreWatchedTurnState(deviceId, sessionId) {
946
+ try { await readWatchedTurnState(deviceId, sessionId); } catch {
947
+ if (!stopped) recordHandlerError('turn-recovery', new Error('Native turn recovery capability is unavailable'));
948
+ }
949
+ }
950
+
951
+ /** Cold/reconnect repair uses fresh native evidence; every await is generation guarded. */
952
+ async function readWatchedTurnState(deviceId, sessionId) {
953
+ const reader = options.resolveCapabilities?.()?.readMessages;
954
+ if (typeof reader?.turnState !== 'function') {
955
+ announceWatchedTurnState(deviceId, sessionId);
956
+ return;
957
+ }
958
+ const owner = source; const revision = sourceRevision; const connection = generation;
959
+ const version = turnVersions.get(sessionId);
960
+ const key = terminalAnnounceKey(deviceId, sessionId);
961
+ const lease = subscriptionLeases.get(key);
962
+ const valid = () => !stopped && active && source === owner && sourceRevision === revision
963
+ && generation === connection && turnVersions.get(sessionId) === version
964
+ && subscriptionLeases.get(key) === lease && acceptedControllers.has(deviceId) && policy.canAccept(deviceId)
965
+ && !sessionIsHidden(sessionId) && sessionSubscribers.get(sessionId)?.has(deviceId)
966
+ && options.resolveCapabilities?.()?.readMessages === reader;
967
+ if (!valid()) return;
968
+ try {
969
+ const state = await reader.turnState(sessionId);
970
+ if (!valid() || !state) return;
971
+ // The log can end before a newly admitted turn has appended its start.
972
+ // A live registry check after replay prevents that gap being called idle.
973
+ let live;
974
+ if (typeof owner?.listSessionStates === 'function') {
975
+ const rows = await owner.listSessionStates(sessionId);
976
+ live = Array.isArray(rows) ? rows.find(row => String(row?.id) === sessionId) : null;
977
+ } else if (typeof owner?.getSession === 'function') live = await owner.getSession(sessionId);
978
+ if (!valid() || !live || typeof live.running !== 'boolean') return;
979
+ if (live.running) {
980
+ setCachedRunning(sessionId, true);
981
+ const payload = { sessionId, event: runningStatusEvent() };
982
+ recordPush(sessionId, 'maker:event', 1, payload);
983
+ send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'maker:event', payload } });
984
+ return;
985
+ }
986
+ if (state.running) return; // An unfinished historical turn is not live-registry evidence.
987
+ if ((nativeBoundaries.get(sessionId)?.seq ?? -1) > state.boundarySeq) return;
988
+ // No await after the final guard: a new boundary cannot interleave with send.
989
+ setCachedRunning(sessionId, false);
990
+ const payload = state.error ? turnErrorPayload(state.error) : { sessionId, event: { type: 'done' } };
991
+ sendNativeTerminal(deviceId, sessionId, payload, `end:${state.boundarySeq}`);
992
+ } catch {
993
+ if (!valid()) return;
994
+ // Never reflect arbitrary upstream exception prose into remote diagnostics.
995
+ recordHandlerError('turn-recovery', new Error('Native turn state is unavailable; retry on the next synchronization'));
996
+ if (valid()) pushHistoryViewChanged(sessionId);
997
+ }
998
+ }
999
+
901
1000
  /** Note that one device has just been told a session's turn is over. */
902
1001
  function noteTerminalAnnounce(deviceId, sessionId) {
903
1002
  lastTerminalAnnounce.set(terminalAnnounceKey(deviceId, sessionId), now().getTime());
@@ -942,6 +1041,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
942
1041
  function markDeviceUnreachable(deviceId) {
943
1042
  if (typeof deviceId !== 'string' || deviceId === '') return;
944
1043
  offlineDevices.add(deviceId);
1044
+ for (const sessionId of watchedSessionsByDevice.get(deviceId) ?? []) renewTurnLease(deviceId, sessionId);
945
1045
  }
946
1046
 
947
1047
  /**
@@ -964,6 +1064,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
964
1064
  for (const sessionId of sessions) {
965
1065
  announceWatchedTurnState(deviceId, sessionId);
966
1066
  pushHistoryViewChanged(sessionId);
1067
+ replayUserQuestions(sessionId);
967
1068
  }
968
1069
  return sessions;
969
1070
  }
@@ -980,6 +1081,10 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
980
1081
  * @param sessionId - the session whose turn state to report.
981
1082
  */
982
1083
  function announceWatchedTurnState(deviceId, sessionId) {
1084
+ if (typeof options.resolveCapabilities?.()?.readMessages?.turnState === 'function') {
1085
+ void restoreWatchedTurnState(deviceId, sessionId);
1086
+ return;
1087
+ }
983
1088
  const cached = sessionRowFor(sessionId);
984
1089
  if (cached === undefined || cached.running === true) return;
985
1090
  const key = terminalAnnounceKey(deviceId, sessionId);
@@ -1096,6 +1201,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1096
1201
  watchedListDevices.add(deviceId);
1097
1202
  } else if (topic.startsWith('session:')) {
1098
1203
  const sessionId = topic.slice('session:'.length);
1204
+ renewTurnLease(deviceId, sessionId);
1099
1205
  const devices = sessionSubscribers.get(sessionId) ?? new Set();
1100
1206
  devices.add(deviceId);
1101
1207
  sessionSubscribers.set(sessionId, devices);
@@ -1107,6 +1213,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1107
1213
  // …and beyond a *disconnect*: when this device re-links, the Host has to be able to
1108
1214
  // tell it the turn state of every session it was inside, not just the running ones.
1109
1215
  rememberWatchedSession(deviceId, sessionId);
1216
+ replayUserQuestions(sessionId);
1110
1217
  }
1111
1218
  }
1112
1219
  }
@@ -1116,6 +1223,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1116
1223
  if (topic === 'sessions') subscribers.delete(deviceId);
1117
1224
  else if (topic.startsWith('session:')) {
1118
1225
  const sessionId = topic.slice('session:'.length);
1226
+ renewTurnLease(deviceId, sessionId);
1119
1227
  const devices = sessionSubscribers.get(sessionId);
1120
1228
  if (devices === undefined) continue;
1121
1229
  devices.delete(deviceId);
@@ -1214,25 +1322,27 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1214
1322
  }
1215
1323
 
1216
1324
  /**
1217
- * Ask the controllers watching a session a structured question.
1218
- *
1219
- * DSH's `user-questions/request` waterfall blocks the tool call until this
1220
- * resolves, so an unanswered question does not degrade the conversation — it
1221
- * stops it. The answerer therefore only claims questions for a session a
1222
- * controller is actually watching, and passes every other one down the chain
1223
- * so the local UI can answer it.
1224
- *
1225
- * Returns `null` when nobody is watching, which is the answerer's cue to call
1226
- * `next()`.
1325
+ * Register before presentation, even when no phone has opened this session.
1326
+ * Watching controls push delivery only; late phones query the same pending
1327
+ * request while the plugin also forwards it to DSH's native answerer.
1328
+ * Returns null when the bridge is disabled or the session is unidentified.
1227
1329
  */
1228
1330
  async function beginUserQuestion({ sessionId, questions, signal }) {
1229
1331
  if (typeof sessionId !== 'string' || sessionId === '') return null;
1230
- if (!claimsInteraction(sessionId)) return null;
1332
+ if (!active || stopped) return null;
1231
1333
  const handle = await approvals.askUser({ sessionId, questions, signal });
1232
- pushSessionUpdate(sessionId, 'maker:interaction-request', { sessionId, request: handle.request });
1334
+ if (!signal?.aborted) pushSessionUpdate(sessionId, 'maker:interaction-request', { sessionId, request: handle.request });
1233
1335
  return handle;
1234
1336
  }
1235
1337
 
1338
+ function replayUserQuestions(sessionId) {
1339
+ for (const { request } of approvals.list(sessionId)) {
1340
+ if (request.kind === 'ask_user_question') {
1341
+ pushSessionUpdate(sessionId, 'maker:interaction-request', { sessionId, request });
1342
+ }
1343
+ }
1344
+ }
1345
+
1236
1346
  async function askUserQuestion(options) {
1237
1347
  const handle = await beginUserQuestion(options);
1238
1348
  return handle === null ? null : handle.answered;
@@ -1253,7 +1363,10 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1253
1363
  */
1254
1364
  function pushSessionUpdate(sessionId, channel, payload) {
1255
1365
  if (channel === 'maker:input:projection' && activeDelivery) payload = activeDelivery.currentProjection(sessionId, [], payload);
1256
- const devices = sessionSubscribers.get(sessionId);
1366
+ const guarded = payload?.message?.role === 'error' || payload?.event?.type === 'error';
1367
+ const devices = guarded
1368
+ ? new Set([...(sessionSubscribers.get(sessionId) ?? [])].filter(id => !sessionIsHidden(sessionId) && acceptedControllers.has(id) && policy.canAccept(id)))
1369
+ : sessionSubscribers.get(sessionId);
1257
1370
  const watchers = devices === undefined ? 0 : devices.size;
1258
1371
  const reachable = devices === undefined ? 0 : [...devices].filter((deviceId) => !offlineDevices.has(deviceId)).length;
1259
1372
  recordPush(sessionId, channel, reachable, payload);
@@ -1337,7 +1450,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1337
1450
  // nothing, which is the same class of failure as the bug being fixed.)
1338
1451
  const provider = typeof options.resolveCapabilities === 'function' ? options.resolveCapabilities() : undefined;
1339
1452
  const hydrate = provider?.hydrateImages;
1340
- if (typeof hydrate === 'function') await hydrate([message]);
1453
+ if (message?.role !== 'error' && typeof hydrate === 'function') await hydrate([message]);
1341
1454
  } catch {
1342
1455
  // Un-hydrated is still worth sending.
1343
1456
  }
@@ -1567,6 +1680,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1567
1680
  * running turn must be told so rather than left to infer it from silence.
1568
1681
  */
1569
1682
  pushTurnRunning: (sessionId) => announceTurnRunning(sessionId),
1683
+ restoreTurnState: typeof provided.readMessages?.turnState === 'function' ? restoreWatchedTurnState : undefined,
1570
1684
  // The controller's goal panel refreshes from this push, so a write made on
1571
1685
  // the phone reaches any other screen watching the same session.
1572
1686
  pushGoalStatus: (sessionId, status) => pushSessionUpdate(sessionId, 'maker:goal:status-changed', { sessionId, goal: status }),
@@ -1697,7 +1811,23 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1697
1811
  * pure noise.
1698
1812
  * @param sessionId - the session whose turn ended.
1699
1813
  */
1700
- function announceTurnIdle(sessionId) {
1814
+ function announceTurnIdle(sessionId, event) {
1815
+ if (!event && typeof options.resolveCapabilities?.()?.readMessages?.turnState === 'function') {
1816
+ for (const deviceId of sessionSubscribers.get(sessionId) ?? []) void restoreWatchedTurnState(deviceId, sessionId);
1817
+ return;
1818
+ }
1819
+ const error = turnErrorRow(event, sessionId);
1820
+ const failed = event?.data?.reason?.kind === 'error';
1821
+ if (failed && !error) {
1822
+ recordHandlerError('turn-error', new Error('Invalid native failed-turn identity; history resynchronization required'));
1823
+ pushHistoryViewChanged(sessionId);
1824
+ return;
1825
+ }
1826
+ if (event && Number.isSafeInteger(event.seq)) {
1827
+ if ((nativeBoundaries.get(sessionId)?.seq ?? -1) >= event.seq) return;
1828
+ nativeBoundaries.set(sessionId, { seq: event.seq });
1829
+ }
1830
+ turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
1701
1831
  const timer = pendingReconciles.get(sessionId);
1702
1832
  if (timer !== undefined) {
1703
1833
  pendingReconciles.delete(sessionId);
@@ -1709,7 +1839,16 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1709
1839
  // tells it first — and the re-link repair depends on that record meaning "it arrived", so a
1710
1840
  // device the relay calls unreachable must not be written into it.
1711
1841
  const watchers = [...(sessionSubscribers.get(sessionId) ?? [])];
1712
- pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: { type: 'done' } });
1842
+ if (failed) {
1843
+ const payload = turnErrorPayload(error);
1844
+ for (const deviceId of watchers) sendNativeTerminal(deviceId, sessionId, payload, `end:${event.seq}`);
1845
+ // Error detail never goes to list subscribers or unwatched controllers.
1846
+ publishRowTurnState(sessionId, false);
1847
+ return;
1848
+ }
1849
+ if (event && Number.isSafeInteger(event.seq)) {
1850
+ for (const deviceId of watchers) sendNativeTerminal(deviceId, sessionId, { sessionId, event: { type: 'done' } }, `end:${event.seq}`);
1851
+ } else pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: { type: 'done' } });
1713
1852
  for (const deviceId of watchers) {
1714
1853
  if (!offlineDevices.has(deviceId)) noteTerminalAnnounce(deviceId, sessionId);
1715
1854
  }
@@ -1858,7 +1997,12 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1858
1997
  * Announce that a turn is live.
1859
1998
  * @param sessionId - the session whose turn started.
1860
1999
  */
1861
- function announceTurnRunning(sessionId) {
2000
+ function announceTurnRunning(sessionId, event) {
2001
+ if (event && Number.isSafeInteger(event.seq)) {
2002
+ if ((nativeBoundaries.get(sessionId)?.seq ?? -1) >= event.seq) return;
2003
+ nativeBoundaries.set(sessionId, { seq: event.seq });
2004
+ }
2005
+ turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
1862
2006
  setCachedRunning(sessionId, true);
1863
2007
  pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: runningStatusEvent() });
1864
2008
  publishRowTurnState(sessionId, true);
@@ -2245,6 +2389,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2245
2389
  case 'link-open': {
2246
2390
  if (typeof frame.id !== 'string' || typeof frame.src !== 'string') return;
2247
2391
  if (!policy.canAccept(frame.src)) return;
2392
+ for (const sessionId of watchedSessionsByDevice.get(frame.src) ?? []) renewTurnLease(frame.src, sessionId);
2248
2393
  // A link-open is itself proof the relay has a route to this device.
2249
2394
  markDeviceReachable(frame.src);
2250
2395
  linkController(frame.src);
@@ -2271,6 +2416,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2271
2416
  }
2272
2417
  case 'link-close': {
2273
2418
  if (typeof frame.src !== 'string') return;
2419
+ for (const sessionId of watchedSessionsByDevice.get(frame.src) ?? []) renewTurnLease(frame.src, sessionId);
2274
2420
  filePeer.closePeer(frame.src);
2275
2421
  acceptedControllers.delete(frame.src);
2276
2422
  observeRemindersSource();
@@ -2497,11 +2643,15 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2497
2643
  subscribers.clear();
2498
2644
  sessionSubscribers.clear();
2499
2645
  liveTurnState.clear();
2646
+ nativeBoundaries.clear();
2647
+ turnVersions.clear();
2648
+ terminalIdentities.clear();
2649
+ subscriptionLeases.clear();
2500
2650
  lastRepairPulse.clear();
2501
2651
  directoryAsked.clear();
2502
- // A question asked over a link that is going away can never be answered;
2503
- // settling them cancelled is what releases DSH's awaiting turn.
2504
- approvals.clear();
2652
+ // Relay recovery is not task cancellation. Keep questions for the next
2653
+ // subscription; explicit switch-off/stop still releases all waits.
2654
+ approvals.clear({ preserveQuestions: keepSwitch });
2505
2655
  inputQueue.clear();
2506
2656
  projections.clear();
2507
2657
 
@@ -2703,8 +2853,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2703
2853
  * projection's running flag flaps inside a single turn, so it is not allowed
2704
2854
  * to end one (see `turnEventFor`).
2705
2855
  */
2706
- pushTurnRunning: (sessionId) => announceTurnRunning(sessionId),
2707
- pushTurnIdle: (sessionId) => announceTurnIdle(sessionId),
2856
+ pushTurnRunning: (sessionId, event) => announceTurnRunning(sessionId, event),
2857
+ pushTurnIdle: (sessionId, event) => announceTurnIdle(sessionId, event),
2708
2858
  /**
2709
2859
  * Make sure a controller is not left spinning after a turn it was watching.
2710
2860
  *
@@ -107,9 +107,10 @@ export function turnEventFor(item) {
107
107
  return { channel: 'maker:event', payload: { sessionId, event: runningStatusEvent() } };
108
108
  case 'waiting':
109
109
  return { channel: 'maker:event', payload: { sessionId, event: runningStatusEvent('waiting on input') } };
110
- // A failure is unambiguous.
110
+ // Summary errors have no turn identity. The native boundary/recovery path
111
+ // publishes an error with persistId; a bare done would erase that distinction.
111
112
  case 'error':
112
- return { channel: 'maker:event', payload: { sessionId, event: { type: 'done' } } };
113
+ return null;
113
114
  // `idle` is deliberately NOT an ending here.
114
115
  //
115
116
  // This flag flaps: `api-session/status` reports `running: false` for a moment
@@ -0,0 +1,60 @@
1
+ /** Display-only projection of a durable failed turn. DSH owns failure and retry policy. */
2
+ const SAFE_MESSAGES = new Map([
3
+ ['AUTH', 'Model authentication failed. Check the provider configuration.'],
4
+ ['MISSING_CREDENTIAL', 'Model credentials are missing. Check the provider configuration.'],
5
+ ['INVALID_CREDENTIAL', 'Model credentials are invalid. Check the provider configuration.'],
6
+ ['RATE_LIMIT', 'The model request was rate limited.'],
7
+ ['QUOTA', 'The model account quota is exhausted.'],
8
+ ['ACCOUNT_QUOTA', 'The model account quota is exhausted.'],
9
+ ['CONTEXT_WINDOW_EXCEEDED', 'The model context window was exceeded.'],
10
+ ['TIMEOUT', 'The model request timed out.'],
11
+ ['TRANSPORT', 'The model connection failed.'],
12
+ ['SERVER', 'The model service failed.'],
13
+ ['EMPTY_RESPONSE', 'The model returned an empty response.'],
14
+ ['NO_ADAPTER', 'The selected model route is unavailable.'],
15
+ ]);
16
+
17
+ /** Never copy provider prose, unknown codes, request IDs or nested diagnostic objects. */
18
+ export function turnErrorRow(event, sessionId) {
19
+ if (event?.type !== 'turn/end' || event.data?.reason?.kind !== 'error') return null;
20
+ if (typeof sessionId !== 'string' || !sessionId || !Number.isSafeInteger(event.seq) || event.seq < 0) return null;
21
+ // No wall-clock fallback: cold replay and live delivery must produce identical rows.
22
+ if (!Number.isFinite(event.time) || !Number.isFinite(new Date(event.time).getTime())) return null;
23
+ const failure = event.data.reason.error;
24
+ const code = SAFE_MESSAGES.has(failure?.code) ? failure.code : null;
25
+ const status = Number.isInteger(failure?.status) && failure.status >= 100 && failure.status <= 599 ? failure.status : null;
26
+ const detail = [code, status === null ? null : `HTTP ${status}`].filter(Boolean).join('; ');
27
+ const message = (SAFE_MESSAGES.get(code) ?? 'This turn failed. See the local DSH diagnostics for details.')
28
+ + (detail ? ` (${detail})` : '');
29
+ const id = `${sessionId}:turn-error:${event.seq}`;
30
+ return { id, clientId: id, sessionId, role: 'error', toolUseId: null, agentMeta: null,
31
+ createdAt: new Date(event.time).toISOString(),
32
+ content: { message, reason: 'dsh-turn-error', ...(code ? { code } : {}), ...(status === null ? {} : { status }) } };
33
+ }
34
+
35
+ /** The maker envelope and the history row share one durable identity. */
36
+ export function turnErrorPayload(row) {
37
+ return { sessionId: row.sessionId, persistId: row.clientId, event: { type: 'error',
38
+ data: { ...row.content, isTerminal: true, willRetry: false } } };
39
+ }
40
+
41
+ /** Latest native turn boundary, not the latest error in an arbitrary history page. */
42
+ export function turnStateFromEvents(events, sessionId) {
43
+ if (!Array.isArray(events)) throw new TypeError('Native turn recovery requires a session event snapshot');
44
+ let boundary = null;
45
+ let seq = -1;
46
+ for (const event of events) {
47
+ if (!Number.isSafeInteger(event?.seq) || event.seq < 0 || event.seq <= seq
48
+ || (seq >= 0 && event.seq !== seq + 1)) {
49
+ throw new TypeError('Native turn recovery requires ordered unique event sequences');
50
+ }
51
+ seq = event.seq;
52
+ if (event.type === 'turn/start' || event.type === 'turn/end') boundary = event;
53
+ }
54
+ if (!boundary) return null;
55
+ if (boundary.type === 'turn/start') return { seq, boundarySeq: boundary.seq, running: true, error: null };
56
+ const error = turnErrorRow(boundary, sessionId);
57
+ // A malformed failure is not evidence of a successful ending.
58
+ if (boundary.data?.reason?.kind === 'error' && !error) throw new TypeError('Invalid native failed-turn identity');
59
+ return { seq, boundarySeq: boundary.seq, running: false, error };
60
+ }