dsh-cindy-host 0.1.33 → 0.1.35

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.35 — 2026-11-05
4
+
5
+ - Show distinct native interruption, cancellation, sign-out, output-limit and blocked-turn notices without inventing model failures. Keep explicit native scheduled/started retries nonterminal, with safe known-code/count/delay details and stable live/history identities.
6
+ - Match native cold-query diagnostic recovery only when both the native session and Agent are absent. Apply display-only interrupted-turn closers without changing stored events, receipt evidence or live tool outcomes; restore notices before terminal envelopes under the existing authorization and generation guards.
7
+ - Render canonical tool-role results alongside legacy nested results, preserving call pairing, output and error/unknown-outcome markers. Extend Host diagnostic regressions and the opt-in actual mobile contract; source tests are not handset or arbitrary desktop-UI parity certification.
8
+
9
+ ## 0.1.34 — 2026-11-05
10
+
11
+ - 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.
12
+
13
+ - 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.
14
+ - 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.
15
+
3
16
  ## 0.1.33 — 2026-11-04
4
17
 
5
18
  - 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.35** adds native interruption, cancellation, output-limit and retry notices, cold-session diagnostic recovery and canonical tool-result pairing. It retains 0.1.34's question synchronization and safe model-failure display, plus the earlier regional authentication, 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.35
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.35",
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
@@ -288,12 +288,22 @@ const RENDERABLE_SOURCE_KINDS = new Set(['user', 'model', 'tool']);
288
288
  */
289
289
  export function toCindyMessageRows(message, { sessionId, createdAt, now = () => new Date(), promptClientId = null }) {
290
290
  const rows = [];
291
+ // Canonical DSH results use a tool-role message with root outcome/call ID,
292
+ // not the legacy user-role nested tool-result block. Keep both formats.
293
+ if (message?.role === 'tool') {
294
+ if (message.source?.kind !== undefined && message.source.kind !== 'tool') return rows;
295
+ if (typeof message.toolCallId !== 'string' || !message.toolCallId) return rows;
296
+ const id = `${sessionId}:${message.id}:0`;
297
+ return [{ id, clientId: id, sessionId, role: 'tool_result', toolUseId: message.toolCallId, agentMeta: null,
298
+ createdAt: createdAt ?? now().toISOString(),
299
+ content: { text: flattenToolResult(message.content), isError: message.isError === true } }];
300
+ }
291
301
  const role = ROW_ROLE_BY_MESSAGE_ROLE.get(message?.role);
292
302
  // `system` (the prompt injection), a hook's injected turn, or any role this
293
303
  // Host has never seen: none of them is something a participant said.
294
304
  if (role === undefined) return rows;
295
305
  const blocks = Array.isArray(message?.content) ? message.content : [];
296
- // DSH records a tool result as a `user`-role message whose source is
306
+ // Older DSH messages record a tool result as a `user`-role message whose source is
297
307
  // `{kind: 'tool'}`. Its content block is what makes it a result row; a *text*
298
308
  // block on such a message is not the user speaking, so it is not rendered as
299
309
  // one.
@@ -1,5 +1,6 @@
1
1
  import { createAttachmentMaterializer, hostAttachmentsOf } from './host-attachments.js';
2
2
  import { foldSessionEvent } from './dsh-message-fold.js';
3
+ import { interruptedTurnClosers } from '@deepseek-ai/dsh-session';
3
4
  import { queuedRowFromController } from './host-input-queue.js';
4
5
  import { deliveryError } from './host-input-delivery-store.js';
5
6
  import { assertNoStructuredSessionReferences } from './session-reference-boundary.js';
@@ -36,7 +37,14 @@ export function createDshInputDeliveryBackend({getServices,sessionController,get
36
37
  const backend={
37
38
  available(){try{services();return true;}catch{return false;}},
38
39
  stop(){active=false;},
39
- async readRows(sid){const events=await readLog(sid);const rows=[];for(const event of events){const folded=foldSessionEvent(event,{sessionId:sid});folded.forEach((row,index)=>{const rowid=event.seq*65536+index;if(!Number.isSafeInteger(rowid))throw deliveryError('DELIVERY_EVIDENCE_CORRUPT','Message order exceeds the safe protocol range');rows.push({...row,rowid});});}return rows.reverse();},
40
+ async readRows(sid){
41
+ const persisted=await readLog(sid);const s=services();
42
+ // Match native session-query cold reads. This is a display-only overlay;
43
+ // evidence()/receipts still read the untouched persisted prefix. A live
44
+ // session or agent must never be diagnosed as a crashed writer.
45
+ const cold=!s.sessions.get(sid)&&typeof s.agents?.get==='function'&&!s.agents.get(sid);
46
+ const events=cold?[...persisted,...interruptedTurnClosers(persisted)]:persisted;
47
+ const rows=[];for(const event of events){const folded=foldSessionEvent(event,{sessionId:sid});folded.forEach((row,index)=>{const rowid=event.seq*65536+index;if(!Number.isSafeInteger(rowid))throw deliveryError('DELIVERY_EVIDENCE_CORRUPT','Message order exceeds the safe protocol range');rows.push({...row,rowid});});}return rows.reverse();},
40
48
  async evidence(sid){const events=await readLog(sid);const proof=durableInputEvidence(events,sid);const agent=getServices().agents?.get(sid);return {...proof,running:agent?.status==='running',live:agent!==undefined};},
41
49
  async prepare(sid,row,{resolveAttachmentRef,mode='queue'}={}){
42
50
  assertNoStructuredSessionReferences(row);
@@ -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 { turnDiagnosticRow, 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 diagnostic = turnDiagnosticRow(event, sessionId);
86
+ if (diagnostic) return [diagnostic];
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 diagnostic = turnDiagnosticRow(event, sessionId);
394
+ if (diagnostic) {
395
+ entries.push({ rows: [diagnostic] });
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,10 @@ 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);
2036
+ if (typeof runtime.pushSessionDiagnostic === 'function') runtime.pushSessionDiagnostic(sessionId, event);
2037
2037
  // Chunks are intentionally absent from deriveEventMessage. Publish a temporary
2038
2038
  // thinking overlay too, retiring it before the durable assistant rows below.
2039
2039
  runtime.pushSessionReasoning?.(sessionId, event, {
@@ -2065,12 +2065,12 @@ export function apply(ctx, config) {
2065
2065
  return arbitrateInteraction({
2066
2066
  request: req,
2067
2067
  next,
2068
- begin: () => runtime.beginApproval({
2068
+ begin: (signal) => runtime.beginApproval({
2069
2069
  sessionId: typeof sessionId === 'string' ? sessionId : '',
2070
2070
  toolName: typeof req?.toolName === 'string' ? req.toolName : '',
2071
2071
  reason: req?.reason,
2072
2072
  callId: req?.callId,
2073
- signal: req?.signal,
2073
+ signal,
2074
2074
  }),
2075
2075
  settleLocal: (requestId, outcome) => runtime.settleInteractionOutcome(requestId, outcome),
2076
2076
  });
@@ -2092,28 +2092,26 @@ export function apply(ctx, config) {
2092
2092
  // to the browser path unchanged.
2093
2093
  }), { prepend: true, global: true }), 'dsh-cindy-host: approval answerer');
2094
2094
 
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.
2095
+ // Questions are registered even before a phone opens the session, then also
2096
+ // forwarded to DSH. Unlike side-effect listeners this awaited waterfall must
2097
+ // propagate cancellation instead of returning an undefined successful result.
2100
2098
  ctx.effect(() => ctx.on('user-questions/request', guarded('user-questions-request', async (request, next) => {
2101
2099
  if (!runtime || typeof runtime.beginUserQuestion !== 'function') return next();
2102
2100
  const sessionId = request?.agent?.session?.header?.id;
2103
2101
  return arbitrateInteraction({
2104
2102
  request,
2105
2103
  next,
2106
- begin: () => runtime.beginUserQuestion({
2104
+ begin: (signal) => runtime.beginUserQuestion({
2107
2105
  sessionId: typeof sessionId === 'string' ? sessionId : '',
2108
2106
  questions: request?.questions,
2109
- signal: request?.signal,
2107
+ signal,
2110
2108
  }),
2111
2109
  settleLocal: (requestId, outcome) => runtime.settleInteractionOutcome(requestId, outcome),
2112
2110
  });
2113
2111
  // Same `prepend` + `global` requirement as the approval answerer above, and the
2114
2112
  // same reason: this waterfall is agent-scoped and the Web bundle's remote
2115
2113
  // forwarding listener parks ahead of a late registration.
2116
- }), { prepend: true, global: true }), 'dsh-cindy-host: user-question answerer');
2114
+ }, { rethrow: true }), { prepend: true, global: true }), 'dsh-cindy-host: user-question answerer');
2117
2115
 
2118
2116
  // The settings page reaches the Host through this route. `webServer` is absent
2119
2117
  // 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.35';
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 { turnDiagnosticRow, turnDiagnosticPayload } 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,86 @@ 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, row) {
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
+ // Reconnect must restore the actual diagnostic, not merely stop a spinner.
935
+ if (row) {
936
+ const notice = { sessionId, message: row };
937
+ recordPush(sessionId, 'local-db:messages:created', offlineDevices.has(deviceId) ? 0 : 1, notice);
938
+ send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'local-db:messages:created', payload: notice } });
939
+ }
940
+ recordPush(sessionId, 'maker:event', offlineDevices.has(deviceId) ? 0 : 1, payload);
941
+ send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'maker:event', payload } });
942
+ if (!offlineDevices.has(deviceId)) {
943
+ terminalIdentities.set(key, stamp);
944
+ noteTerminalAnnounce(deviceId, sessionId);
945
+ }
946
+ return true;
947
+ }
948
+
949
+ /** Event callers intentionally fire-and-forget; provider replacement must not
950
+ * turn a failed recovery into an unhandled rejection at the DSH boundary. */
951
+ async function restoreWatchedTurnState(deviceId, sessionId) {
952
+ try { await readWatchedTurnState(deviceId, sessionId); } catch {
953
+ if (!stopped) recordHandlerError('turn-recovery', new Error('Native turn recovery capability is unavailable'));
954
+ }
955
+ }
956
+
957
+ /** Cold/reconnect repair uses fresh native evidence; every await is generation guarded. */
958
+ async function readWatchedTurnState(deviceId, sessionId) {
959
+ const reader = options.resolveCapabilities?.()?.readMessages;
960
+ if (typeof reader?.turnState !== 'function') {
961
+ announceWatchedTurnState(deviceId, sessionId);
962
+ return;
963
+ }
964
+ const owner = source; const revision = sourceRevision; const connection = generation;
965
+ const version = turnVersions.get(sessionId);
966
+ const key = terminalAnnounceKey(deviceId, sessionId);
967
+ const lease = subscriptionLeases.get(key);
968
+ const valid = () => !stopped && active && source === owner && sourceRevision === revision
969
+ && generation === connection && turnVersions.get(sessionId) === version
970
+ && subscriptionLeases.get(key) === lease && acceptedControllers.has(deviceId) && policy.canAccept(deviceId)
971
+ && !sessionIsHidden(sessionId) && sessionSubscribers.get(sessionId)?.has(deviceId)
972
+ && options.resolveCapabilities?.()?.readMessages === reader;
973
+ if (!valid()) return;
974
+ try {
975
+ const state = await reader.turnState(sessionId);
976
+ if (!valid() || !state) return;
977
+ // The log can end before a newly admitted turn has appended its start.
978
+ // A live registry check after replay prevents that gap being called idle.
979
+ let live;
980
+ if (typeof owner?.listSessionStates === 'function') {
981
+ const rows = await owner.listSessionStates(sessionId);
982
+ live = Array.isArray(rows) ? rows.find(row => String(row?.id) === sessionId) : null;
983
+ } else if (typeof owner?.getSession === 'function') live = await owner.getSession(sessionId);
984
+ if (!valid() || !live || typeof live.running !== 'boolean') return;
985
+ if (live.running) {
986
+ setCachedRunning(sessionId, true);
987
+ const payload = { sessionId, event: runningStatusEvent(state.retryNotice?.content ?? 'running') };
988
+ recordPush(sessionId, 'maker:event', 1, payload);
989
+ send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'maker:event', payload } });
990
+ return;
991
+ }
992
+ if (state.running) return; // An unfinished historical turn is not live-registry evidence.
993
+ if ((nativeBoundaries.get(sessionId)?.seq ?? -1) > state.boundarySeq) return;
994
+ // No await after the final guard: a new boundary cannot interleave with send.
995
+ setCachedRunning(sessionId, false);
996
+ const diagnostic = state.error ?? state.notice;
997
+ const payload = diagnostic ? turnDiagnosticPayload(diagnostic) : { sessionId, event: { type: 'done' } };
998
+ sendNativeTerminal(deviceId, sessionId, payload, `end:${state.boundarySeq}`, diagnostic);
999
+ } catch {
1000
+ if (!valid()) return;
1001
+ // Never reflect arbitrary upstream exception prose into remote diagnostics.
1002
+ recordHandlerError('turn-recovery', new Error('Native turn state is unavailable; retry on the next synchronization'));
1003
+ if (valid()) pushHistoryViewChanged(sessionId);
1004
+ }
1005
+ }
1006
+
901
1007
  /** Note that one device has just been told a session's turn is over. */
902
1008
  function noteTerminalAnnounce(deviceId, sessionId) {
903
1009
  lastTerminalAnnounce.set(terminalAnnounceKey(deviceId, sessionId), now().getTime());
@@ -942,6 +1048,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
942
1048
  function markDeviceUnreachable(deviceId) {
943
1049
  if (typeof deviceId !== 'string' || deviceId === '') return;
944
1050
  offlineDevices.add(deviceId);
1051
+ for (const sessionId of watchedSessionsByDevice.get(deviceId) ?? []) renewTurnLease(deviceId, sessionId);
945
1052
  }
946
1053
 
947
1054
  /**
@@ -964,6 +1071,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
964
1071
  for (const sessionId of sessions) {
965
1072
  announceWatchedTurnState(deviceId, sessionId);
966
1073
  pushHistoryViewChanged(sessionId);
1074
+ replayUserQuestions(sessionId);
967
1075
  }
968
1076
  return sessions;
969
1077
  }
@@ -980,6 +1088,10 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
980
1088
  * @param sessionId - the session whose turn state to report.
981
1089
  */
982
1090
  function announceWatchedTurnState(deviceId, sessionId) {
1091
+ if (typeof options.resolveCapabilities?.()?.readMessages?.turnState === 'function') {
1092
+ void restoreWatchedTurnState(deviceId, sessionId);
1093
+ return;
1094
+ }
983
1095
  const cached = sessionRowFor(sessionId);
984
1096
  if (cached === undefined || cached.running === true) return;
985
1097
  const key = terminalAnnounceKey(deviceId, sessionId);
@@ -1096,6 +1208,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1096
1208
  watchedListDevices.add(deviceId);
1097
1209
  } else if (topic.startsWith('session:')) {
1098
1210
  const sessionId = topic.slice('session:'.length);
1211
+ renewTurnLease(deviceId, sessionId);
1099
1212
  const devices = sessionSubscribers.get(sessionId) ?? new Set();
1100
1213
  devices.add(deviceId);
1101
1214
  sessionSubscribers.set(sessionId, devices);
@@ -1107,6 +1220,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1107
1220
  // …and beyond a *disconnect*: when this device re-links, the Host has to be able to
1108
1221
  // tell it the turn state of every session it was inside, not just the running ones.
1109
1222
  rememberWatchedSession(deviceId, sessionId);
1223
+ replayUserQuestions(sessionId);
1110
1224
  }
1111
1225
  }
1112
1226
  }
@@ -1116,6 +1230,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1116
1230
  if (topic === 'sessions') subscribers.delete(deviceId);
1117
1231
  else if (topic.startsWith('session:')) {
1118
1232
  const sessionId = topic.slice('session:'.length);
1233
+ renewTurnLease(deviceId, sessionId);
1119
1234
  const devices = sessionSubscribers.get(sessionId);
1120
1235
  if (devices === undefined) continue;
1121
1236
  devices.delete(deviceId);
@@ -1214,25 +1329,27 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1214
1329
  }
1215
1330
 
1216
1331
  /**
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()`.
1332
+ * Register before presentation, even when no phone has opened this session.
1333
+ * Watching controls push delivery only; late phones query the same pending
1334
+ * request while the plugin also forwards it to DSH's native answerer.
1335
+ * Returns null when the bridge is disabled or the session is unidentified.
1227
1336
  */
1228
1337
  async function beginUserQuestion({ sessionId, questions, signal }) {
1229
1338
  if (typeof sessionId !== 'string' || sessionId === '') return null;
1230
- if (!claimsInteraction(sessionId)) return null;
1339
+ if (!active || stopped) return null;
1231
1340
  const handle = await approvals.askUser({ sessionId, questions, signal });
1232
- pushSessionUpdate(sessionId, 'maker:interaction-request', { sessionId, request: handle.request });
1341
+ if (!signal?.aborted) pushSessionUpdate(sessionId, 'maker:interaction-request', { sessionId, request: handle.request });
1233
1342
  return handle;
1234
1343
  }
1235
1344
 
1345
+ function replayUserQuestions(sessionId) {
1346
+ for (const { request } of approvals.list(sessionId)) {
1347
+ if (request.kind === 'ask_user_question') {
1348
+ pushSessionUpdate(sessionId, 'maker:interaction-request', { sessionId, request });
1349
+ }
1350
+ }
1351
+ }
1352
+
1236
1353
  async function askUserQuestion(options) {
1237
1354
  const handle = await beginUserQuestion(options);
1238
1355
  return handle === null ? null : handle.answered;
@@ -1253,7 +1370,10 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1253
1370
  */
1254
1371
  function pushSessionUpdate(sessionId, channel, payload) {
1255
1372
  if (channel === 'maker:input:projection' && activeDelivery) payload = activeDelivery.currentProjection(sessionId, [], payload);
1256
- const devices = sessionSubscribers.get(sessionId);
1373
+ const guarded = channel === 'local-db:messages:created' || channel === 'maker:event';
1374
+ const devices = guarded
1375
+ ? new Set([...(sessionSubscribers.get(sessionId) ?? [])].filter(id => !sessionIsHidden(sessionId) && acceptedControllers.has(id) && policy.canAccept(id)))
1376
+ : sessionSubscribers.get(sessionId);
1257
1377
  const watchers = devices === undefined ? 0 : devices.size;
1258
1378
  const reachable = devices === undefined ? 0 : [...devices].filter((deviceId) => !offlineDevices.has(deviceId)).length;
1259
1379
  recordPush(sessionId, channel, reachable, payload);
@@ -1337,7 +1457,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1337
1457
  // nothing, which is the same class of failure as the bug being fixed.)
1338
1458
  const provider = typeof options.resolveCapabilities === 'function' ? options.resolveCapabilities() : undefined;
1339
1459
  const hydrate = provider?.hydrateImages;
1340
- if (typeof hydrate === 'function') await hydrate([message]);
1460
+ if (!['error', 'system'].includes(message?.role) && typeof hydrate === 'function') await hydrate([message]);
1341
1461
  } catch {
1342
1462
  // Un-hydrated is still worth sending.
1343
1463
  }
@@ -1567,6 +1687,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1567
1687
  * running turn must be told so rather than left to infer it from silence.
1568
1688
  */
1569
1689
  pushTurnRunning: (sessionId) => announceTurnRunning(sessionId),
1690
+ restoreTurnState: typeof provided.readMessages?.turnState === 'function' ? restoreWatchedTurnState : undefined,
1570
1691
  // The controller's goal panel refreshes from this push, so a write made on
1571
1692
  // the phone reaches any other screen watching the same session.
1572
1693
  pushGoalStatus: (sessionId, status) => pushSessionUpdate(sessionId, 'maker:goal:status-changed', { sessionId, goal: status }),
@@ -1697,7 +1818,23 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1697
1818
  * pure noise.
1698
1819
  * @param sessionId - the session whose turn ended.
1699
1820
  */
1700
- function announceTurnIdle(sessionId) {
1821
+ function announceTurnIdle(sessionId, event) {
1822
+ if (!event && typeof options.resolveCapabilities?.()?.readMessages?.turnState === 'function') {
1823
+ for (const deviceId of sessionSubscribers.get(sessionId) ?? []) void restoreWatchedTurnState(deviceId, sessionId);
1824
+ return;
1825
+ }
1826
+ const diagnostic = turnDiagnosticRow(event, sessionId);
1827
+ const requiresNotice = diagnostic !== null || ['error', 'interrupted', 'max-tokens', 'aborted', 'blocked'].includes(event?.data?.reason?.kind);
1828
+ if (requiresNotice && !diagnostic) {
1829
+ recordHandlerError('turn-error', new Error('Invalid native failed-turn identity; history resynchronization required'));
1830
+ pushHistoryViewChanged(sessionId);
1831
+ return;
1832
+ }
1833
+ if (event && Number.isSafeInteger(event.seq)) {
1834
+ if ((nativeBoundaries.get(sessionId)?.seq ?? -1) >= event.seq) return;
1835
+ nativeBoundaries.set(sessionId, { seq: event.seq, turn: event.data?.turn });
1836
+ }
1837
+ turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
1701
1838
  const timer = pendingReconciles.get(sessionId);
1702
1839
  if (timer !== undefined) {
1703
1840
  pendingReconciles.delete(sessionId);
@@ -1709,7 +1846,16 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1709
1846
  // tells it first — and the re-link repair depends on that record meaning "it arrived", so a
1710
1847
  // device the relay calls unreachable must not be written into it.
1711
1848
  const watchers = [...(sessionSubscribers.get(sessionId) ?? [])];
1712
- pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: { type: 'done' } });
1849
+ if (requiresNotice) {
1850
+ const payload = turnDiagnosticPayload(diagnostic);
1851
+ for (const deviceId of watchers) sendNativeTerminal(deviceId, sessionId, payload, `end:${event.seq}`);
1852
+ // Error detail never goes to list subscribers or unwatched controllers.
1853
+ publishRowTurnState(sessionId, false);
1854
+ return;
1855
+ }
1856
+ if (event && Number.isSafeInteger(event.seq)) {
1857
+ for (const deviceId of watchers) sendNativeTerminal(deviceId, sessionId, { sessionId, event: { type: 'done' } }, `end:${event.seq}`);
1858
+ } else pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: { type: 'done' } });
1713
1859
  for (const deviceId of watchers) {
1714
1860
  if (!offlineDevices.has(deviceId)) noteTerminalAnnounce(deviceId, sessionId);
1715
1861
  }
@@ -1858,7 +2004,12 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1858
2004
  * Announce that a turn is live.
1859
2005
  * @param sessionId - the session whose turn started.
1860
2006
  */
1861
- function announceTurnRunning(sessionId) {
2007
+ function announceTurnRunning(sessionId, event) {
2008
+ if (event && Number.isSafeInteger(event.seq)) {
2009
+ if ((nativeBoundaries.get(sessionId)?.seq ?? -1) >= event.seq) return;
2010
+ nativeBoundaries.set(sessionId, { seq: event.seq, turn: event.data?.turn });
2011
+ }
2012
+ turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
1862
2013
  setCachedRunning(sessionId, true);
1863
2014
  pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: runningStatusEvent() });
1864
2015
  publishRowTurnState(sessionId, true);
@@ -2245,6 +2396,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2245
2396
  case 'link-open': {
2246
2397
  if (typeof frame.id !== 'string' || typeof frame.src !== 'string') return;
2247
2398
  if (!policy.canAccept(frame.src)) return;
2399
+ for (const sessionId of watchedSessionsByDevice.get(frame.src) ?? []) renewTurnLease(frame.src, sessionId);
2248
2400
  // A link-open is itself proof the relay has a route to this device.
2249
2401
  markDeviceReachable(frame.src);
2250
2402
  linkController(frame.src);
@@ -2271,6 +2423,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2271
2423
  }
2272
2424
  case 'link-close': {
2273
2425
  if (typeof frame.src !== 'string') return;
2426
+ for (const sessionId of watchedSessionsByDevice.get(frame.src) ?? []) renewTurnLease(frame.src, sessionId);
2274
2427
  filePeer.closePeer(frame.src);
2275
2428
  acceptedControllers.delete(frame.src);
2276
2429
  observeRemindersSource();
@@ -2497,11 +2650,15 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2497
2650
  subscribers.clear();
2498
2651
  sessionSubscribers.clear();
2499
2652
  liveTurnState.clear();
2653
+ nativeBoundaries.clear();
2654
+ turnVersions.clear();
2655
+ terminalIdentities.clear();
2656
+ subscriptionLeases.clear();
2500
2657
  lastRepairPulse.clear();
2501
2658
  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();
2659
+ // Relay recovery is not task cancellation. Keep questions for the next
2660
+ // subscription; explicit switch-off/stop still releases all waits.
2661
+ approvals.clear({ preserveQuestions: keepSwitch });
2505
2662
  inputQueue.clear();
2506
2663
  projections.clear();
2507
2664
 
@@ -2695,6 +2852,18 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2695
2852
  /** Push one live session message to the controllers watching that session. */
2696
2853
  pushSessionMessage,
2697
2854
  pushSessionReasoning: reasoningPublisher.noteEvent,
2855
+ // Only explicit native scheduled/started retry events assert ongoing work.
2856
+ // Failed attempts alone never terminate or start a turn.
2857
+ pushSessionDiagnostic: (sessionId, event) => {
2858
+ if (!['llm/retry', 'llm/retry-started'].includes(event?.type)) return;
2859
+ const row = turnDiagnosticRow(event, sessionId);
2860
+ const boundary = nativeBoundaries.get(sessionId);
2861
+ if (!row || (boundary?.seq ?? -1) >= event.seq
2862
+ || (boundary?.turn !== undefined && boundary.turn !== event.data?.turn)) return;
2863
+ turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
2864
+ setCachedRunning(sessionId, true);
2865
+ pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: runningStatusEvent(row.content) });
2866
+ },
2698
2867
  /**
2699
2868
  * Announce a real turn boundary to a session's watchers.
2700
2869
  *
@@ -2703,8 +2872,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2703
2872
  * projection's running flag flaps inside a single turn, so it is not allowed
2704
2873
  * to end one (see `turnEventFor`).
2705
2874
  */
2706
- pushTurnRunning: (sessionId) => announceTurnRunning(sessionId),
2707
- pushTurnIdle: (sessionId) => announceTurnIdle(sessionId),
2875
+ pushTurnRunning: (sessionId, event) => announceTurnRunning(sessionId, event),
2876
+ pushTurnIdle: (sessionId, event) => announceTurnIdle(sessionId, event),
2708
2877
  /**
2709
2878
  * Make sure a controller is not left spinning after a turn it was watching.
2710
2879
  *
@@ -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,124 @@
1
+ /** Display-only native turn diagnostics. DSH owns failure, recovery and retry policy. */
2
+ const SAFE_MESSAGES = new Map([
3
+ ['AUTH', 'Model authentication failed. Check the provider configuration.'],
4
+ ['ACCOUNT_SIGNED_OUT', '任务已因退出 DeepSeek 登录而停止。'],
5
+ ['ACCOUNT_SIGN_IN_REQUIRED', '请先登录 DeepSeek,并确认请求地址支持账号认证。'],
6
+ ['MISSING_CREDENTIAL', 'Model credentials are missing. Check the provider configuration.'],
7
+ ['INVALID_CREDENTIAL', 'Model credentials are invalid. Check the provider configuration.'],
8
+ ['RATE_LIMIT', 'The model request was rate limited.'],
9
+ ['QUOTA', 'The model account quota is exhausted.'],
10
+ ['ACCOUNT_QUOTA', 'The model account quota is exhausted.'],
11
+ ['CONTEXT_WINDOW_EXCEEDED', 'The model context window was exceeded.'],
12
+ ['TIMEOUT', 'The model request timed out.'],
13
+ ['TRANSPORT', 'The model connection failed.'],
14
+ ['SERVER', 'The model service failed.'],
15
+ ['EMPTY_RESPONSE', 'The model returned an empty response.'],
16
+ ['NO_ADAPTER', 'The selected model route is unavailable.'],
17
+ ]);
18
+
19
+ /** Never copy provider prose, unknown codes, request IDs or nested diagnostic objects. */
20
+ export function turnErrorRow(event, sessionId) {
21
+ if (event?.type !== 'turn/end' || event.data?.reason?.kind !== 'error') return null;
22
+ if (typeof sessionId !== 'string' || !sessionId || !Number.isSafeInteger(event.seq) || event.seq < 0) return null;
23
+ // No wall-clock fallback: cold replay and live delivery must produce identical rows.
24
+ if (!Number.isFinite(event.time) || !Number.isFinite(new Date(event.time).getTime())) return null;
25
+ const content = safeFailureContent(event.data.reason.error);
26
+ const id = `${sessionId}:turn-error:${event.seq}`;
27
+ return { id, clientId: id, sessionId, role: 'error', toolUseId: null, agentMeta: null,
28
+ createdAt: new Date(event.time).toISOString(), content };
29
+ }
30
+
31
+ /** One safe allowlist for terminal model failures and scheduled retry details. */
32
+ export function safeFailureContent(failure) {
33
+ const code = SAFE_MESSAGES.has(failure?.code) ? failure.code : null;
34
+ const status = Number.isInteger(failure?.status) && failure.status >= 100 && failure.status <= 599 ? failure.status : null;
35
+ const detail = [code, status === null ? null : `HTTP ${status}`].filter(Boolean).join('; ');
36
+ const message = (SAFE_MESSAGES.get(code) ?? 'This turn failed. See the local DSH diagnostics for details.')
37
+ + (detail ? ` (${detail})` : '');
38
+ return { message, reason: 'dsh-turn-error', ...(code ? { code } : {}), ...(status === null ? {} : { status }) };
39
+ }
40
+
41
+ /** The maker envelope and the history row share one durable identity. */
42
+ export function turnErrorPayload(row) {
43
+ return { sessionId: row.sessionId, persistId: row.clientId, event: { type: 'error',
44
+ data: { ...row.content, isTerminal: true, willRetry: false } } };
45
+ }
46
+
47
+ /** User-visible desktop turn tails, kept separate from model failures. Never
48
+ * infer an interruption from an ordinary aborted turn or an arbitrary string. */
49
+ export function turnDiagnosticRow(event, sessionId) {
50
+ const failure = turnErrorRow(event, sessionId);
51
+ if (failure) return failure;
52
+ if (!Number.isSafeInteger(event?.seq) || event.seq < 0 || !Number.isFinite(event.time)
53
+ || !Number.isFinite(new Date(event.time).getTime()) || typeof sessionId !== 'string' || !sessionId) return null;
54
+ let content;
55
+ const data = event.data;
56
+ if (event.type === 'turn/end') {
57
+ const reason = data?.reason;
58
+ switch (reason?.kind) {
59
+ case 'interrupted':
60
+ content = '任务已停止:DSH 进程在本轮完成前退出(例如被强制结束、崩溃或关闭超时),重新启动后本轮已中止。未完成的工具调用结果未知。';
61
+ break;
62
+ case 'max-tokens':
63
+ content = '已达到输出 token 上限。回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。';
64
+ break;
65
+ case 'blocked': content = '本轮执行被阻止,未正常完成。原生结束事件未提供更多原因。'; break;
66
+ case 'aborted':
67
+ content = reason.reason?.kind === 'hook' && reason.reason.reason === 'deepseek-account/signed-out'
68
+ ? '任务已因退出 DeepSeek 登录而停止。'
69
+ : reason.reason?.kind === 'user' ? '任务已停止:用户取消了本轮执行。'
70
+ : reason.reason?.kind === 'parent' ? '任务已停止:上级任务取消了本轮执行。'
71
+ : reason.reason?.kind === 'disposed' ? '任务已停止:本轮运行实例已被释放。'
72
+ : '任务已停止:本轮执行已被取消。';
73
+ break;
74
+ default:
75
+ if (!reason || typeof reason.kind !== 'string' || ['completed', 'forked'].includes(reason.kind)) return null;
76
+ content = '本轮已结束。当前 Host 尚不能显示此结束原因的详细信息,请在 DSH 桌面查看。';
77
+ break;
78
+ }
79
+ } else if (event.type === 'llm/retry') {
80
+ if (!['normal', 'always'].includes(data?.mode) || !Number.isSafeInteger(data?.retry) || data.retry < 1 || !Number.isFinite(data.delayMs) || data.delayMs < 0) return null;
81
+ const maximum = data.mode === 'always' ? '不限' : Number.isSafeInteger(data.maxRetries) && data.maxRetries >= data.retry ? data.maxRetries : null;
82
+ if (maximum === null) return null;
83
+ content = `模型请求失败,DSH 已安排重试(${data.retry}/${maximum}),等待 ${Math.max(1, Math.ceil(data.delayMs / 1000))} 秒。${safeFailureContent(data.failure).message}`;
84
+ } else if (event.type === 'llm/retry-started') {
85
+ if (!Number.isSafeInteger(data?.retry) || data.retry < 1) return null;
86
+ content = `DSH 已开始第 ${data.retry} 次模型请求重试。`;
87
+ } else return null;
88
+ const id = `${sessionId}:turn-notice:${event.seq}`;
89
+ return { id, clientId: id, sessionId, role: 'system', toolUseId: null, agentMeta: null,
90
+ createdAt: new Date(event.time).toISOString(), content };
91
+ }
92
+
93
+ /** Notices close the running flag without pretending cancellation is a model failure. */
94
+ export function turnDiagnosticPayload(row) {
95
+ return row.role === 'error' ? turnErrorPayload(row) : { sessionId: row.sessionId, persistId: row.clientId,
96
+ event: { type: 'done', data: { message: row.content, reason: 'dsh-turn-notice' } } };
97
+ }
98
+
99
+ /** Latest native turn boundary, not the latest error in an arbitrary history page. */
100
+ export function turnStateFromEvents(events, sessionId) {
101
+ if (!Array.isArray(events)) throw new TypeError('Native turn recovery requires a session event snapshot');
102
+ let boundary = null;
103
+ let retryNotice = null;
104
+ let seq = -1;
105
+ for (const event of events) {
106
+ if (!Number.isSafeInteger(event?.seq) || event.seq < 0 || event.seq <= seq
107
+ || (seq >= 0 && event.seq !== seq + 1)) {
108
+ throw new TypeError('Native turn recovery requires ordered unique event sequences');
109
+ }
110
+ seq = event.seq;
111
+ if (event.type === 'turn/start' || event.type === 'turn/end') { boundary = event; retryNotice = null; }
112
+ else if (['step/start', 'assistant/message'].includes(event.type)) retryNotice = null;
113
+ else if (boundary?.type === 'turn/start' && boundary.data?.turn === event.data?.turn
114
+ && ['llm/retry', 'llm/retry-started'].includes(event.type)) retryNotice = turnDiagnosticRow(event, sessionId);
115
+ }
116
+ if (!boundary) return null;
117
+ if (boundary.type === 'turn/start') return { seq, boundarySeq: boundary.seq, running: true, error: null, ...(retryNotice ? { retryNotice } : {}) };
118
+ const diagnostic = turnDiagnosticRow(boundary, sessionId);
119
+ const error = diagnostic?.role === 'error' ? diagnostic : null;
120
+ // A malformed failure is not evidence of a successful ending.
121
+ if (boundary.data?.reason?.kind === 'error' && !error) throw new TypeError('Invalid native failed-turn identity');
122
+ if (['interrupted', 'max-tokens', 'blocked', 'aborted'].includes(boundary.data?.reason?.kind) && !diagnostic) throw new TypeError('Invalid native turn-notice identity');
123
+ return { seq, boundarySeq: boundary.seq, running: false, error, ...(diagnostic?.role === 'system' ? { notice: diagnostic } : {}) };
124
+ }