dsh-cindy-host 0.1.35 → 0.1.36

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.36 — 2026-11-06
4
+
5
+ - Project proven native interruption identities and timestamp tuples consistently into session list/get, boundaries and reconnect updates. Clear non-pending/hidden/running presentation without fabricating transcript-clear or completion facts.
6
+ - Serve existing interruption acknowledgement and committed-error dismissal calls through authenticated owner-scoped, revisioned native settings. Store hashed display preferences only, verify authoritative readback, guard source/controller/tail races, and overlay history without rewriting source events or receipt evidence.
7
+ - Treat the pinned current Cindy continuation prompt as a new explicit durable human input with planMode:false. Capture and recheck the native tail around proven scoped plan-off, deduplicate continuations for that tail, and reconcile superseded/non-admitted refusals as removed receipts instead of retrying automatically. Legacy non-durable fallback refuses this control contract.
8
+ - Extend Host/ledger/native-input regressions and opt-in read-only Cindy consumer contracts. Existing wire limitations remain explicit: no atomic CAS against unrelated desktop writers, screen-generation token in acknowledgement, immediate client rollback of failed ignores, or automatic replay of unknown tool outcomes.
9
+
3
10
  ## 0.1.35 — 2026-11-05
4
11
 
5
12
  - 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.
package/README.md CHANGED
@@ -4,16 +4,20 @@ A Host-side adapter connecting current Cindy clients to DeepSeek Harness.
4
4
 
5
5
  ## Requirements and installation
6
6
 
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.
7
+ Version **0.1.36** adds native interruption banner/status metadata, owner-scoped interruption acknowledgement and error dismissal, and guarded manual continuation with verified native plan-mode off. It retains the earlier safe diagnostics, question synchronization, 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.35
12
+ dsh plugin --profile <profile> add dsh-cindy-host@0.1.36
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.
16
16
 
17
+ ## 0.1.36: interruption controls
18
+
19
+ The existing Cindy acknowledgement/dismissal actions update owner-scoped display preferences, not failure or completion facts. Manual continuation is a new human input; it verifies the current native tail and effective plan-mode off rather than retrying old tool calls. These changes are Host-only: Cindy is a read-only protocol/test dependency. See the [diagnostic protocol and limitations](<doc/model-errors.md>) for persistence, concurrency and current-client boundaries. Source contracts do not establish live-handset acceptance.
20
+
17
21
  ## 0.1.32: credentials without keytar
18
22
 
19
23
  No native npm credential module or C++ build toolchain is required. Linux uses `secret-tool` with an unlocked Secret Service (headless sessions need their own DBus/keyring setup); macOS uses the system Keychain through `/usr/bin/security`. Both keep the existing `DSH Cindy Host` service and installation-specific account.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cindy-host",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "private": false,
5
5
  "description": "Cindy 手机客户端连接 DSH 的 Host 侧插件:会话/消息/审批/todo/goal 的手机通道,以及配套的设置卡片",
6
6
  "license": "MIT",
@@ -21,6 +21,7 @@ import { toGoalStatusPayload } from './host-goals.js';
21
21
  import { hostAttachmentsOf } from './host-attachments.js';
22
22
  import { historyLocateOptions, locateCommittedMessages, capReferenceRows } from './host-history-locate.js';
23
23
  import { assertNoStructuredSessionReferences } from './session-reference-boundary.js';
24
+ import { continuationKind } from './continuation-protocol.js';
24
25
 
25
26
  async function deliveryReply(request, operation) {
26
27
  try { return invokeResult(request, await operation()); }
@@ -217,6 +218,8 @@ export const SUPPORTED_CHANNELS = Object.freeze([
217
218
  'local-db:sessions:list',
218
219
  'local-db:sessions:get',
219
220
  'local-db:messages:list',
221
+ 'local-db:messages:dismiss-error',
222
+ 'local-db:sessions:ack-interrupted',
220
223
  'local-db:messages:around',
221
224
  'local-db:messages:around-client-id',
222
225
  'local-db:sessions:patch-meta',
@@ -648,6 +651,14 @@ export function createChannelRouter({
648
651
  return invokeResult(request, sessionListPage(await sessionRows(), args));
649
652
  }
650
653
 
654
+ if (channel === 'local-db:sessions:ack-interrupted' || channel === 'local-db:messages:dismiss-error') {
655
+ const sid = args[0]; const cid = args[1]; const caps = capabilitiesNow();
656
+ if (typeof sid !== 'string' || !sid || args.length !== (channel === 'local-db:sessions:ack-interrupted' ? 1 : 2) || (channel === 'local-db:messages:dismiss-error' && (typeof cid !== 'string' || !cid))) return invokeError(request, 'INVALID_PARAMS', 'Session and message identities are required');
657
+ const action = channel === 'local-db:sessions:ack-interrupted' ? caps.ackInterrupted : caps.dismissError;
658
+ if (typeof action !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'Diagnostic presentation actions are unavailable');
659
+ return deliveryReply(request, () => channel === 'local-db:sessions:ack-interrupted' ? action(sid, request.src) : action(sid, cid, request.src));
660
+ }
661
+
651
662
  if (channel === 'local-db:sessions:get') {
652
663
  const sessionId = String(args[0] ?? '');
653
664
  // A known blank session must be readable before its first enqueue. The
@@ -1164,6 +1175,8 @@ export function createChannelRouter({
1164
1175
  if (delivery) return deliveryReply(request, () => channel === 'maker:input:steer' ? delivery.steer(sessionId, args[1], args[2] ?? {}) : delivery.accept(sessionId, args[1], args[2] ?? {}));
1165
1176
  }
1166
1177
 
1178
+ if (continuationKind(extractSendText(args[1]))) return invokeError(request, 'NOT_AVAILABLE', 'Safe manual continuation requires native durable input delivery');
1179
+
1167
1180
  // A `steer` naming an item DSH already holds is a **promotion** of that
1168
1181
  // queued item, not a new message: the controller sends the queued row back
1169
1182
  // with `removeFromQueue`. Re-sending its text would duplicate it, which is
@@ -126,6 +126,7 @@ export function toCindySessionListRow(item, { now = () => new Date(), device, de
126
126
  // Not part of the stored row's contract, but harmless and honest: the
127
127
  // controller reads running state from `maker:list-active`, not from here.
128
128
  running: item.running === true,
129
+ ...(Object.hasOwn(item, 'activeTurnStartedAt') ? { activeTurnStartedAt: item.activeTurnStartedAt, interruptedTurnStartedAt: item.interruptedTurnStartedAt, lastTurnEndedAt: item.lastTurnEndedAt, clearedAt: item.clearedAt ?? null } : {}),
129
130
  // Which device this row came from. The controller's home aggregates sessions
130
131
  // from every linked computer and filters with
131
132
  // `canonicalDeviceId ?? deviceLinkDeviceId === selectedDeviceId`
@@ -0,0 +1,31 @@
1
+ // Pinned Cindy maker-shared/syntheticTrigger constants; actual-consumer tests
2
+ // assert byte equality. These are explicit manual inputs, never tool retries.
3
+ const COMMON = ' Review the conversation above to determine how far the task progressed,'
4
+ + ' then continue from where it left off.'
5
+ + ' Do not repeat steps that already completed and had external effects'
6
+ + ' (messages or comments already sent, commits already pushed, files already written).';
7
+ export const CONTINUE_INTERRUPTED = '[UI_ACTION_TRIGGER] The previous turn was interrupted because the app exited mid-run.' + COMMON;
8
+ export const CONTINUE_ERROR = '[UI_ACTION_TRIGGER] The previous turn errored partway through.' + COMMON;
9
+ export function continuationKind(text) { return text === CONTINUE_INTERRUPTED ? 'interrupted' : text === CONTINUE_ERROR ? 'error' : null; }
10
+
11
+ /** Public native getters/projections prove the effective switch; never parse command prose. */
12
+ export async function ensureNativeContinuationPlanOff({agent,controller,getService}) {
13
+ try {
14
+ const registry=agent?.ctx?.get?.('sessionProjections')??getService('sessionProjections');
15
+ const read=()=>controller?.get?.(agent)??registry?.stateOf?.(agent.session,'plan');
16
+ const off=()=>{const state=read();return state?.active===false&&state.pending!==true&&state.wanted!==true&&!state.running;};
17
+ // A logged projection alone cannot rule out a controller-private pending
18
+ // entry. Without the live getter, issue off even when logged active=false.
19
+ if(typeof controller?.get==='function'&&off())return;
20
+ if(typeof controller?.set==='function')await controller.set(agent,false);
21
+ else {
22
+ const commands=getService('commands');
23
+ if(typeof commands?.find!=='function'||typeof agent?.ctx?.get!=='function')throw Error('Native plan composition is not observable');
24
+ if(commands.find(agent,'plan')===undefined&&!controller)return; // exact resolved agent has no plan-mode owner
25
+ if(typeof commands.execute!=='function')throw Error('Native plan control is unavailable');
26
+ const result=await commands.execute(agent,'/plan off',[],AbortSignal.timeout(30000));
27
+ if(result?.result?.kind==='error')throw Error('Native plan-mode switch was refused');
28
+ }
29
+ if(!off())throw Error('Native plan-mode off is not committed');
30
+ } catch(cause) { throw Object.assign(new Error('Cannot verify native plan mode is off; continuation was not sent'),{code:'PLAN_MODE_UNAVAILABLE',cause}); }
31
+ }
@@ -0,0 +1,65 @@
1
+ import { createHash } from 'node:crypto';
2
+ const hash = value => createHash('sha256').update(value).digest('hex');
3
+ const unavailable = message => Object.assign(new Error(message), { code: 'NOT_AVAILABLE' });
4
+ export const EMPTY_INTERRUPTION = Object.freeze({ activeTurnStartedAt: null, interruptedTurnStartedAt: null, lastTurnEndedAt: null, clearedAt: null });
5
+
6
+ /** Presentation identities use the original turn/start, never a synthetic closer. */
7
+ export function nativeInterruption(sessionId, start, end, priorEndedAt) {
8
+ if (end?.data?.reason?.kind !== 'interrupted' || !Number.isSafeInteger(start?.data?.turn) || start.data.turn < 0 || start?.data?.turn !== end?.data?.turn
9
+ || !Number.isSafeInteger(end.seq) || end.seq <= start?.seq || !Number.isFinite(end.time)
10
+ || !Number.isSafeInteger(start?.seq) || start.seq < 0 || !Number.isFinite(start.time) || start.time <= 0
11
+ || (Number.isFinite(priorEndedAt) && priorEndedAt >= start.time)) return null;
12
+ return { id: `${sessionId}:interrupted:${start.seq}:${start.time}:${start.data.turn}`,
13
+ startedAt: start.time, priorEndedAt: Number.isFinite(priorEndedAt) ? priorEndedAt : null };
14
+ }
15
+ export function advanceInterruptionMetadata(state, event, sessionId) {
16
+ const previous = state ?? { start: null, priorEndedAt: null, interruption: null };
17
+ if (event?.type === 'turn/start') return { ...previous, start: { seq: event.seq, time: event.time, data: { turn: event.data?.turn } }, interruption: null };
18
+ if (event?.type !== 'turn/end') return previous;
19
+ return { ...previous, interruption: nativeInterruption(sessionId, previous.start, event, previous.priorEndedAt),
20
+ priorEndedAt: event.data?.reason?.kind === 'interrupted' ? previous.priorEndedAt : Number.isFinite(event.time) ? event.time : previous.priorEndedAt };
21
+ }
22
+ export function diagnosticOwner(namespace, profile = '') {
23
+ return typeof namespace === 'string' && namespace ? hash(`${profile}|${namespace}`) : null;
24
+ }
25
+ export function preferenceHas(data, owner, sid, kind, id) {
26
+ return Boolean(owner && data?.[owner]?.[hash(sid)]?.[kind]?.[hash(id)] === true);
27
+ }
28
+ export function interruptionFields(candidate, running, hidden, data, owner, sid) {
29
+ if (!candidate || running || hidden || preferenceHas(data, owner, sid, 'interruptions', candidate.id)) return { ...EMPTY_INTERRUPTION };
30
+ return { activeTurnStartedAt: candidate.startedAt, interruptedTurnStartedAt: candidate.startedAt, lastTurnEndedAt: candidate.priorEndedAt, clearedAt: null };
31
+ }
32
+ export function projectDismissedErrors(rows, data, owner) {
33
+ return rows.map(row => row?.role === 'error' && preferenceHas(data, owner, row.sessionId, 'errors', row.clientId)
34
+ ? { ...row, content: { ...row.content, dismissed: true } } : row);
35
+ }
36
+
37
+ /** One transactional settings owner; no optimistic mirror of error/turn facts.
38
+ * Leaf writes preserve other grants/preferences. Revision conflicts are retried
39
+ * from describe, and successful writes are verified by authoritative readback. */
40
+ export function diagnosticPreferenceStore(settings, ns) {
41
+ function describe() {
42
+ if (typeof settings?.describe !== 'function' || typeof settings?.mutate !== 'function') throw unavailable('Diagnostic preferences require revisioned native settings');
43
+ const entry = settings.describe().find(entry => entry.ns === ns);
44
+ if (!entry || !Number.isSafeInteger(entry.revision)) throw unavailable('Diagnostic settings namespace is unavailable');
45
+ return entry;
46
+ }
47
+ return {
48
+ snapshot() { try { return describe().value?.diagnosticPresentationV1 ?? {}; } catch { return {}; } },
49
+ async commit({ owner, sessionId, kind, id }, valid = () => true) {
50
+ if (!owner || !['interruptions', 'errors'].includes(kind)) throw unavailable('No authenticated diagnostic preference owner');
51
+ for (let attempt = 0; attempt < 3; attempt++) {
52
+ if (!valid()) throw unavailable('Diagnostic action scope changed');
53
+ const entry = describe();
54
+ if (preferenceHas(entry.value?.diagnosticPresentationV1, owner, sessionId, kind, id)) return;
55
+ try {
56
+ await settings.mutate(ns, [{ op: 'set', path: ['diagnosticPresentationV1', owner, hash(sessionId), kind, hash(id)], value: true }], entry.revision);
57
+ } catch (error) { if (error?.code === 'SETTINGS_CONFLICT') continue; throw error; }
58
+ if (!valid()) throw unavailable('Diagnostic action completed in an expired scope');
59
+ if (!preferenceHas(describe().value?.diagnosticPresentationV1, owner, sessionId, kind, id)) throw unavailable('Diagnostic preference was not committed');
60
+ return;
61
+ }
62
+ throw unavailable('Diagnostic settings changed concurrently; retry the action');
63
+ },
64
+ };
65
+ }
@@ -1,6 +1,8 @@
1
1
  import { createAttachmentMaterializer, hostAttachmentsOf } from './host-attachments.js';
2
2
  import { foldSessionEvent } from './dsh-message-fold.js';
3
3
  import { interruptedTurnClosers } from '@deepseek-ai/dsh-session';
4
+ import { turnStateFromEvents } from './turn-error.js';
5
+ import { continuationKind } from './continuation-protocol.js';
4
6
  import { queuedRowFromController } from './host-input-queue.js';
5
7
  import { deliveryError } from './host-input-delivery-store.js';
6
8
  import { assertNoStructuredSessionReferences } from './session-reference-boundary.js';
@@ -23,7 +25,7 @@ export function durableInputEvidence(events,sessionId) {
23
25
  const inbox=Object.entries(queues).flatMap(([placement,items])=>items.map(message=>({id:message.id,clientId:message.source?.kind==='user'&&typeof message.source.rpcId==='string'?message.source.rpcId:message.id,placement,message,foreign:message.source?.kind!=='user'})));
24
26
  return {sessionId,users,inbox,throughSeq:events.length-1};
25
27
  }
26
- export function createDshInputDeliveryBackend({getServices,sessionController,getSession}){
28
+ export function createDshInputDeliveryBackend({getServices,sessionController,getSession,ensurePlanModeOff}){
27
29
  let active=true;
28
30
  function services(){if(!active)throw deliveryError('DELIVERY_BACKEND_STOPPED','Input delivery service was detached');const s=getServices();if(!s.sessions||typeof s.sessions.flush!=='function'||!s.persistence||typeof s.persistence.open!=='function')throw deliveryError('DELIVERY_NOT_AVAILABLE','Durable DSH session services are not mounted');return s;}
29
31
  async function readLog(sid){
@@ -34,6 +36,17 @@ export function createDshInputDeliveryBackend({getServices,sessionController,get
34
36
  }
35
37
  async function agentFor(sid){const resolved=await sessionController.resolveAgent(sid);if(resolved?.error)throw resolved.error;if(!resolved?.agent)throw deliveryError('DELIVERY_AGENT_UNAVAILABLE','DSH did not resolve an agent');return resolved.agent;}
36
38
  async function flush(sid){const s=services();const session=s.sessions.get(sid);if(!session||!await s.sessions.flush(session))throw deliveryError('DELIVERY_DURABILITY_UNAVAILABLE','Cannot verify the DSH mutation durability');}
39
+ async function continuationTail(sid,kind,clientId) {
40
+ const persisted=await readLog(sid); const s=services();
41
+ const agent=s.agents?.get(sid);
42
+ if(agent?.status==='running')throw deliveryError('CONTINUATION_SUPERSEDED','The session has already started another turn');
43
+ const events=!s.sessions.get(sid)&&!agent?[...persisted,...interruptedTurnClosers(persisted)]:persisted;
44
+ const state=turnStateFromEvents(events,sid);
45
+ const id=kind==='interrupted'?state?.interruption?.id:state?.error?.clientId;
46
+ if(!id||state.running||events.some(event=>event.seq>state.boundarySeq&&event.type==='user/message'&&event.data?.source?.kind==='user'&&event.data.source.rpcId!==clientId)
47
+ ||durableInputEvidence(persisted,sid).inbox.some(item=>item.clientId!==clientId))throw deliveryError('CONTINUATION_SUPERSEDED','The original continuation tail is no longer current');
48
+ return id;
49
+ }
37
50
  const backend={
38
51
  available(){try{services();return true;}catch{return false;}},
39
52
  stop(){active=false;},
@@ -54,13 +67,26 @@ export function createDshInputDeliveryBackend({getServices,sessionController,get
54
67
  const materializer=createAttachmentMaterializer({fileSystem:getServices().fileSystem,resolveAttachmentRef,captureOnly:true});
55
68
  const owned=await materializer.materialize({attachments:incoming,signal:AbortSignal.timeout(30000)});
56
69
  if(owned.dropped.length)throw deliveryError('ATTACHMENT_UNAVAILABLE','Cannot durably receive all attachments: '+owned.dropped.map(x=>x.reason).join(', '));
70
+ const kind=continuationKind(text);
71
+ const continuation=kind?{kind,tailId:await continuationTail(sid,kind,row.clientId)}:null;
72
+ if(continuation&&row.createOpts?.planMode!==false)throw deliveryError('INVALID_PARAMS','Manual continuation requires explicit planMode:false');
57
73
  const queued=queuedRowFromController({clientId:row.clientId,text,session});if(!queued)throw deliveryError('DELIVERY_SESSION_UNAVAILABLE','Session working directory is unavailable');
58
- return {payload:{text,assets:owned.parts,consumed:owned.consumed,mode},queued:{...queued,files:incoming,chatMessage:{...queued.chatMessage,...(row.chatMessage?.images?{images:row.chatMessage.images}:{})}}};
74
+ return {payload:{text,assets:owned.parts,consumed:owned.consumed,mode,...(continuation?{continuation}: {})},queued:{...queued,...(continuation?{createOpts:{...queued.createOpts,planMode:false}}:{}),files:incoming,chatMessage:{...queued.chatMessage,...(row.chatMessage?.images?{images:row.chatMessage.images}:{})}}};
59
75
  },
60
76
  async dispatch(entry,{releaseAttachmentRef}={}){
61
77
  assertNoStructuredSessionReferences(entry.request,entry.payload);
62
78
  let agent;
63
79
  try{agent=await agentFor(entry.sessionId);}catch(error){error.deliveryNotAdmitted=true;throw error;}
80
+ if(entry.payload.continuation) {
81
+ try {
82
+ const origin=entry.payload.continuation;
83
+ if(await continuationTail(entry.sessionId,origin.kind,entry.clientId)!==origin.tailId)throw deliveryError('CONTINUATION_SUPERSEDED','A newer task superseded this manual continuation');
84
+ if(typeof ensurePlanModeOff!=='function')throw deliveryError('PLAN_MODE_UNAVAILABLE','Cannot verify native plan mode is off');
85
+ await ensurePlanModeOff({sessionId:entry.sessionId,agent});
86
+ if(await continuationTail(entry.sessionId,origin.kind,entry.clientId)!==origin.tailId)throw deliveryError('CONTINUATION_SUPERSEDED','A newer task superseded this manual continuation');
87
+ services();
88
+ } catch(error) { error.deliveryNotAdmitted=true; if(error.code==='CONTINUATION_SUPERSEDED')error.deliverySuperseded=true; throw error; }
89
+ }
64
90
  const content=entry.payload.text?[{type:'text',text:entry.payload.text}]:[];
65
91
  for(const asset of entry.payload.assets??[]){
66
92
  if(asset.mediaType)content.push({type:'image',mediaType:asset.mediaType,data:asset.data,name:asset.name});
@@ -18,6 +18,7 @@
18
18
  import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface';
19
19
  import { createReasoningStream } from './reasoning-stream.js';
20
20
  import { turnDiagnosticRow, turnStateFromEvents } from './turn-error.js';
21
+ import { advanceInterruptionMetadata } from './diagnostic-presentation.js';
21
22
  import { toCindyMessageRows, toCindyMessages, hydrateImageAttachments } from './cindy-message-row.js';
22
23
 
23
24
  /** Default page size when the controller does not ask for one. */
@@ -383,12 +384,16 @@ export function createMessageReader({
383
384
  const entries = [];
384
385
  const reasoning = createReasoningStream(sessionId);
385
386
  let lastSeq = null;
387
+ let nativeMetadata = null;
388
+ let nativeValid = true;
386
389
  for (const event of events) {
387
390
  if (event === null || typeof event !== 'object') continue;
388
391
  // Every event's seq counts, not only the ones that render: contiguity is a
389
392
  // property of the log, and a caller comparing against renders would read a
390
393
  // gap as "nothing happened here".
394
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0 || (lastSeq !== null && event.seq !== lastSeq + 1)) nativeValid = false;
391
395
  if (Number.isFinite(event.seq)) lastSeq = event.seq;
396
+ nativeMetadata = advanceInterruptionMetadata(nativeMetadata, event, sessionId);
392
397
  reasoning.consume(event);
393
398
  const diagnostic = turnDiagnosticRow(event, sessionId);
394
399
  if (diagnostic) {
@@ -418,7 +423,7 @@ export function createMessageReader({
418
423
  promptClientId: promptRpcIdOf(event),
419
424
  });
420
425
  }
421
- return { entries, lastSeq, reasoning };
426
+ return { entries, lastSeq, reasoning, nativeValid, nativeMetadata: nativeValid ? nativeMetadata : null };
422
427
  }
423
428
 
424
429
  /** Drop the least recently used transcripts, keeping the window bounded. */
@@ -498,9 +503,9 @@ export function createMessageReader({
498
503
  // invalidation also detaches this flight so a post-invalidation read starts
499
504
  // fresh; an older completion must never overwrite that newer generation.
500
505
  if (seeding.get(sessionId) === flight && !flight.dirty) {
501
- touch(sessionId, { rows, reasoning: read.reasoning, seq: read.lastSeq, at: clock });
506
+ touch(sessionId, { rows, reasoning: read.reasoning, seq: read.lastSeq, at: clock, nativeValid: read.nativeValid, nativeMetadata: read.nativeMetadata });
502
507
  }
503
- return transcripts.get(sessionId) ?? { rows, reasoning: read.reasoning };
508
+ return transcripts.get(sessionId) ?? { rows, reasoning: read.reasoning, nativeMetadata: read.nativeMetadata };
504
509
  } finally {
505
510
  // Failure is not an authoritative empty transcript. Release this flight
506
511
  // on success or rejection, without deleting a newer read's ownership.
@@ -552,6 +557,7 @@ export function createMessageReader({
552
557
  transcripts.delete(sessionId);
553
558
  return false;
554
559
  }
560
+ if (entry.nativeValid !== false) entry.nativeMetadata = advanceInterruptionMetadata(entry.nativeMetadata, event, sessionId);
555
561
  entry.seq = seq;
556
562
  entry.at = ++clock;
557
563
  entry.reasoning.consume(event);
@@ -590,7 +596,7 @@ export function createMessageReader({
590
596
  readMessages.count = countRows;
591
597
  /** Exact project-grouping evidence derived from the same folded transcript. */
592
598
  readMessages.activity = async (sessionId) => {
593
- const { rows } = await ensureTranscript(sessionId);
599
+ const { rows, nativeMetadata } = await ensureTranscript(sessionId);
594
600
  let userSendAt = null;
595
601
  for (let index = rows.length - 1; index >= 0; index -= 1) {
596
602
  const row = rows[index];
@@ -599,7 +605,7 @@ export function createMessageReader({
599
605
  break;
600
606
  }
601
607
  }
602
- return { messageCount: rows.length, userSendAt };
608
+ return { messageCount: rows.length, userSendAt, ...(nativeMetadata ? { interruption: nativeMetadata.interruption } : {}) };
603
609
  };
604
610
  /**
605
611
  * The whole cached transcript, newest first.
package/src/dsh-plugin.js CHANGED
@@ -3,6 +3,8 @@ import { createRequire } from 'node:module';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { createDshInputDeliveryBackend } from './dsh-input-delivery.js';
6
+ import { diagnosticPreferenceStore } from './diagnostic-presentation.js';
7
+ import { ensureNativeContinuationPlanOff } from './continuation-protocol.js';
6
8
  import { DEFAULT_HOST_SETTINGS, SETTINGS_NAMESPACE, validateHostSettings } from './host-settings.js';
7
9
  import { DshHostSource } from './dsh-host-source.js';
8
10
  import { createSessionControllerSource } from './dsh-session-source.js';
@@ -70,6 +72,7 @@ const HostSettingsSchema = Schema.object({
70
72
  deviceId: Schema.string().default(DEFAULT_HOST_SETTINGS.deviceId),
71
73
  deviceName: Schema.string().default(DEFAULT_HOST_SETTINGS.deviceName),
72
74
  sessionFlags: Schema.dict(SessionFlagSchema).default({}),
75
+ diagnosticPresentationV1: Schema.dict(Schema.dict(Schema.dict(Schema.dict(Schema.boolean())))).default({}),
73
76
  });
74
77
 
75
78
  /**
@@ -697,8 +700,12 @@ export function buildDshSource(ctx, serviceName, options = {}) {
697
700
  getServices: () => ({ ...deliveryServices, fileSystem: ctx.get('fs'), fileUploads: controls.fileUploads }),
698
701
  sessionController,
699
702
  getSession: (sessionId) => sessionSource.getSession(sessionId),
703
+ ensurePlanModeOff: ({agent}) => ensureNativeContinuationPlanOff({agent,controller:planModeFor(agent),getService:name=>ctx.get(name)}),
700
704
  });
701
- const committedRows = (sessionId) => inputDeliveryBackend.available() ? inputDeliveryBackend.readRows(sessionId) : readMessages.all(sessionId);
705
+ const committedRows = async (sessionId) => {
706
+ const rows = await (inputDeliveryBackend.available() ? inputDeliveryBackend.readRows(sessionId) : readMessages.all(sessionId));
707
+ return options.projectMessages?.(rows) ?? rows;
708
+ };
702
709
  const historyView = readMessages === undefined
703
710
  ? undefined
704
711
  : createHistoryViewController({
@@ -1881,6 +1888,7 @@ export function apply(ctx, config) {
1881
1888
  // Delete/pin and old archive overlays persist here; new archives live in
1882
1889
  // DSH's native workspace registry, not this plugin's settings section.
1883
1890
  persistSessionFlags: sessionFlagsWriter(ctx.settings, settingsNamespace(SETTINGS_NAMESPACE)),
1891
+ diagnosticPreferences: diagnosticPreferenceStore(ctx.settings, settingsNamespace(SETTINGS_NAMESPACE)),
1884
1892
  inputDeliveryDirectory: join(process.env.DSH_PROFILE_DIR || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles', process.env.DSH_PROFILE || 'desktop'), 'state', 'dsh-cindy-host'),
1885
1893
 
1886
1894
  identityIsCurrent: () => admitted || (!runtimeDisposed && lease()),
@@ -1914,6 +1922,7 @@ export function apply(ctx, config) {
1914
1922
  // Read lazily: the runtime resolves after this seam is built, and the state changes on
1915
1923
  // every turn boundary.
1916
1924
  isSessionRunning: (sessionId) => runtime?.isSessionRunning?.(sessionId) === true,
1925
+ projectMessages: rows => runtime?.projectMessages?.(rows) ?? rows,
1917
1926
  getProjectOrderOwner: () => runtime?.getProjectOrderOwner?.(),
1918
1927
  });
1919
1928
  // The filesystem is a separate service; the controller's file reads are
@@ -207,7 +207,7 @@ export function createSessionControllerSource({
207
207
  const value = result.value;
208
208
  if (!Number.isFinite(value?.messageCount) || value.messageCount < 0) return;
209
209
  if (value.userSendAt !== null && typeof value.userSendAt !== 'string') return;
210
- const evidence = { messageCount: Math.floor(value.messageCount), userSendAt: value.userSendAt };
210
+ const evidence = { messageCount: Math.floor(value.messageCount), userSendAt: value.userSendAt, ...(Object.hasOwn(value, 'interruption') ? { interruption: value.interruption } : {}) };
211
211
  activityEvidenceCache.set(ids[index], evidence);
212
212
  out?.set(ids[index], evidence);
213
213
  });
@@ -359,7 +359,7 @@ export function createSessionControllerSource({
359
359
  ...(typeof selectedProvider === 'string' && selectedProvider !== '' ? { providerId: selectedProvider } : {}),
360
360
  ...(typeof selectedEffort === 'string' && selectedEffort !== '' ? { effort: selectedEffort } : {}),
361
361
  ...(currentPermission === undefined ? {} : { permissionMode: currentPermission }),
362
- ...(activity === undefined ? {} : { messageCount: activity.messageCount, userSendAt: activity.userSendAt }),
362
+ ...(activity === undefined ? {} : { messageCount: activity.messageCount, userSendAt: activity.userSendAt, ...(Object.hasOwn(activity, 'interruption') ? { interruption: activity.interruption } : {}) }),
363
363
  blank: item.blank === true,
364
364
  };
365
365
  });
@@ -35,7 +35,7 @@ export function createInputDeliveryCoordinator({store,backend,baseProjection=()=
35
35
  if((e.phase==='stored'||e.phase==='DSH-pending')&&e.queued){const at=pending.findIndex(p=>p.clientId===e.clientId);if(at<0)pending.push(e.queued);else pending[at]=e.queued;}
36
36
  }
37
37
  const running=runningBySession.get(sid)??base.turnInProgress;
38
- return {...base,...(typeof running==='boolean'?{turnInProgress:running}:{}),sessionId:sid,pendingQueue:pending,steeringQueueClientIds:[...steering],inputDeliveryVersion:1,clearBoundaryMs:store.boundary(sid),deliveryReceipts:ids.map(clientId=>{const e=store.get(sid,clientId);return{clientId,state:e?e.phase==='removed'?'removed':e.phase==='accepted'?'accepted':e.payload===undefined?'unknown':'pending':'unknown'};})};
38
+ return {...base,...(typeof running==='boolean'?{turnInProgress:running}:{}),sessionId:sid,pendingQueue:pending,steeringQueueClientIds:[...steering],inputDeliveryVersion:1,clearBoundaryMs:store.boundary(sid),deliveryReceipts:ids.map(clientId=>{const e=store.get(sid,clientId);return{clientId,state:e?e.phase==='removed'?'removed':e.phase==='accepted'?'accepted':e.payload===undefined?'unknown':'pending':'unknown',...(e?.removalReason?{reason:e.removalReason}:{})};})};
39
39
  }
40
40
  async function evidence(sid){
41
41
  const proof=await backend.evidence(sid);
@@ -85,7 +85,16 @@ export function createInputDeliveryCoordinator({store,backend,baseProjection=()=
85
85
  proof=await evidence(sid);
86
86
  const settled=store.get(sid,current.clientId);
87
87
  if(!terminal(settled.phase))save({...settled,phase:proof.inbox.some(item=>item.clientId===current.clientId)?'DSH-pending':'claimed',error:null});
88
- }catch(error){const e=store.get(sid,current.clientId);if(!terminal(e.phase))save({...e,...(error.deliveryNotAdmitted?{phase:'stored',...(error.deliveryRetryMode?{payload:{...e.payload,mode:error.deliveryRetryMode}}:{}),nextAttemptAt:now()+Math.min(30000,1000*2**Math.min(e.attemptNo??1,5))}:{}),error:String(error?.code??'DELIVERY_ADMISSION_UNCERTAIN')});onError(error);if(error.deliveryNotAdmitted){notify(sid);break;}}
88
+ }catch(error){const e=store.get(sid,current.clientId);
89
+ if(error.deliverySuperseded||(error.deliveryNotAdmitted&&error.code==='PLAN_MODE_UNAVAILABLE')){
90
+ const checked=await evidence(sid);const latest=store.get(sid,current.clientId);
91
+ if(latest.phase==='accepted')continue;
92
+ // Fresh native ownership wins over this attempt's early refusal. Never
93
+ // roll DSH-pending back to stored and accidentally permit re-admission.
94
+ if(checked.inbox.some(item=>item.clientId===current.clientId)){notify(sid);onError(error);break;}
95
+ save({...latest,phase:'removed',payload:null,error:error.code,removalReason:error.deliverySuperseded?'continuation-superseded':'continuation-plan-mode-unavailable'});notify(sid);continue;
96
+ }
97
+ if(!terminal(e.phase))save({...e,...(error.deliveryNotAdmitted?{phase:'stored',...(error.deliveryRetryMode?{payload:{...e.payload,mode:error.deliveryRetryMode}}:{}),nextAttemptAt:now()+Math.min(30000,1000*2**Math.min(e.attemptNo??1,5))}:{}),error:String(error?.code??'DELIVERY_ADMISSION_UNCERTAIN')});onError(error);if(error.deliveryNotAdmitted){notify(sid);break;}}
89
98
  notify(sid);
90
99
  }
91
100
  }
@@ -115,14 +124,24 @@ export function createInputDeliveryCoordinator({store,backend,baseProjection=()=
115
124
  if(row.durableDelivery===true && !hasBoundary)throw deliveryError('INVALID_PARAMS','Durable input requires expectedClearBoundaryMs, including explicit null');
116
125
  const expectedBoundary=hasBoundary?options.expectedClearBoundaryMs:store.boundary(sid);
117
126
  if(!e && expectedBoundary!==store.boundary(sid))throw deliveryError('REMOTE_OPTIMISTIC_INPUT_CLEARED','REMOTE_OPTIMISTIC_INPUT_CLEARED: input clear boundary changed');
118
- const prepared=await backend.prepare(sid,row,options);
127
+ const refuseContinuation = reason => {
128
+ const existing=store.get(sid,row.clientId);
129
+ if(existing?.phase==='removed')return view(sid,[row.clientId]);
130
+ if(existing)throw deliveryError('INPUT_DELIVERY_CONFLICT','clientId already belongs to an existing input');
131
+ save({sessionId:sid,clientId:row.clientId,phase:'removed',request:requestShape(row),rawFingerprint:raw,payload:null,queued:null,clearBoundaryMs:store.boundary(sid),createdAt:now(),attemptNo:0,cancelRequested:true,removalReason:reason});
132
+ notify(sid);return view(sid,[row.clientId]);
133
+ };
134
+ let prepared;
135
+ try{prepared=await backend.prepare(sid,row,options);}catch(error){if(error.code==='CONTINUATION_SUPERSEDED')return refuseContinuation('continuation-superseded');throw error;}
119
136
  if(!prepared || prepared.payload===undefined || !prepared.queued)throw deliveryError('DELIVERY_PREPARATION_FAILED','Host did not obtain complete replayable input');
120
137
  const {consumed: _transit,mode: _mode,...semanticPayload}=prepared.payload;const fingerprint=inputFingerprint(semanticPayload);e=store.get(sid,row.clientId);
121
138
  if(e){if(e.phase==='removed')return view(sid,[row.clientId]);if(e.fingerprint!==fingerprint)throw deliveryError('INPUT_DELIVERY_CONFLICT','clientId already belongs to a different input');return view(sid,[row.clientId]);}
122
139
  if(expectedBoundary!==store.boundary(sid))throw deliveryError('REMOTE_OPTIMISTIC_INPUT_CLEARED','REMOTE_OPTIMISTIC_INPUT_CLEARED: input was cleared during preparation');
140
+ const origin=prepared.payload.continuation;
141
+ if(origin&&store.list(sid).some(other=>other.clientId!==row.clientId&&other.phase!=='removed'&&other.continuation?.tailId===origin.tailId))return refuseContinuation('continuation-already-submitted');
123
142
  const preview=view(sid,[row.clientId]);preview.pendingQueue=[...preview.pendingQueue,prepared.queued];preview.deliveryReceipts=[{clientId:row.clientId,state:'pending'}];
124
143
  if(!validateProjection(preview))throw deliveryError('DELIVERY_TOO_LARGE','Input queue projection cannot fit the negotiated transport budget');
125
- save({sessionId:sid,clientId:row.clientId,phase:'stored',request:requestShape(row),rawFingerprint:raw,fingerprint,payload:prepared.payload,queued:prepared.queued,clearBoundaryMs:store.boundary(sid),sendAtMs:options.sendAtMs??null,createdAt:now(),attemptNo:0,cancelRequested:false});
144
+ save({sessionId:sid,clientId:row.clientId,phase:'stored',request:requestShape(row),rawFingerprint:raw,fingerprint,payload:prepared.payload,...(origin?{continuation:origin}:{}),queued:prepared.queued,clearBoundaryMs:store.boundary(sid),sendAtMs:options.sendAtMs??null,createdAt:now(),attemptNo:0,cancelRequested:false});
126
145
  notify(sid);schedule(sid);return view(sid,[row.clientId]);
127
146
  });
128
147
  },
@@ -5,7 +5,7 @@ export const SETTINGS_NAMESPACE = 'dsh-cindy-host';
5
5
  // Controller-only delete/pin metadata and legacy archive flags survive restart.
6
6
  // New archive/unarchive operations persist in DSH's native workspace registry;
7
7
  // legacy archived flags are retained until explicitly retired (see `session-flags.js`).
8
- export const DEFAULT_HOST_SETTINGS = Object.freeze({ transportEnabled: false, remoteControlEnabled: false, controllers: {}, deviceId: '', deviceName: '', sessionFlags: {} });
8
+ export const DEFAULT_HOST_SETTINGS = Object.freeze({ transportEnabled: false, remoteControlEnabled: false, controllers: {}, deviceId: '', deviceName: '', sessionFlags: {}, diagnosticPresentationV1: {} });
9
9
 
10
10
  /**
11
11
  * The Host's own relay identity, when it has one.
@@ -46,6 +46,20 @@ export function validateHostSettings(value) {
46
46
  }
47
47
  // An absent section is valid — every settings file written before this field
48
48
  // existed has none, and rejecting it would fail the load instead of the flag.
49
+ if (value.diagnosticPresentationV1 !== undefined) {
50
+ const record = entry => entry !== null && typeof entry === 'object' && !Array.isArray(entry);
51
+ const data=value.diagnosticPresentationV1;
52
+ if(!record(data))throw new Error('Invalid diagnostic presentation preferences');
53
+ for(const sessions of Object.values(data)){
54
+ if(!record(sessions))throw new Error('Invalid diagnostic owner preferences');
55
+ for(const kinds of Object.values(sessions)){
56
+ if(!record(kinds))throw new Error('Invalid diagnostic session preferences');
57
+ for(const [kind,ids] of Object.entries(kinds)){
58
+ if(!['errors','interruptions'].includes(kind)||!record(ids)||Object.values(ids).some(flag=>typeof flag!=='boolean'))throw new Error('Invalid diagnostic dismissal preferences');
59
+ }
60
+ }
61
+ }
62
+ }
49
63
  if (value.sessionFlags !== undefined) {
50
64
  if (value.sessionFlags === null || typeof value.sessionFlags !== 'object') throw new Error('Invalid session flags');
51
65
  for (const [sessionId, flags] of Object.entries(value.sessionFlags)) {
@@ -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.35';
7
+ export const HOST_PLUGIN_VERSION = '0.1.36';
package/src/host.js CHANGED
@@ -37,6 +37,7 @@ 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
39
  import { turnDiagnosticRow, turnDiagnosticPayload } from './turn-error.js';
40
+ import { advanceInterruptionMetadata, diagnosticOwner, interruptionFields, projectDismissedErrors } from './diagnostic-presentation.js';
40
41
  import { acceptLink } from './host-authorization.js';
41
42
  import { AuthorizationPolicy } from './authorization-policy.js';
42
43
  import { HostStatus } from './host-status.js';
@@ -374,6 +375,69 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
374
375
  let reminderObserverSource;
375
376
  let reminderObserverPort;
376
377
  const sessionMetaWrites = new Map();
378
+ const interruptionStates = new Map();
379
+ const nativePresentationMetadata = new Map();
380
+ const presentationData = () => options.diagnosticPreferences?.snapshot?.() ?? {};
381
+ const presentationOwner = () => diagnosticOwner(deliveryNamespace, options.inputDeliveryDirectory ?? '');
382
+ const presentationFields = (sid, running = false, candidate = interruptionStates.get(sid)) => interruptionFields(candidate, running, sessionIsHidden(sid), presentationData(), presentationOwner(), sid);
383
+ const projectMessages = rows => projectDismissedErrors(rows, presentationData(), presentationOwner());
384
+
385
+ async function refreshInterruption(sid, row) {
386
+ const owner = source; const revision = sourceRevision; const version = turnVersions.get(sid); const connection = generation;
387
+ const reader = options.resolveCapabilities?.()?.readMessages;
388
+ if (typeof reader?.turnState !== 'function') return;
389
+ const state = await reader.turnState(sid);
390
+ if (stopped || generation !== connection || source !== owner || sourceRevision !== revision || turnVersions.get(sid) !== version || options.resolveCapabilities?.()?.readMessages !== reader) throw Object.assign(new Error('Native session changed; synchronize again'), { code: 'NOT_AVAILABLE' });
391
+ let live = row;
392
+ if (typeof owner?.listSessionStates === 'function') {
393
+ const rows = await owner.listSessionStates(sid); live = rows.find(item => String(item.id) === sid);
394
+ }
395
+ if (stopped || generation !== connection || source !== owner || sourceRevision !== revision || turnVersions.get(sid) !== version || options.resolveCapabilities?.()?.readMessages !== reader || !live || typeof live.running !== 'boolean' || (nativeBoundaries.get(sid)?.seq ?? -1) > (state?.seq ?? -1)) throw Object.assign(new Error('Native session state is unavailable'), { code: 'NOT_AVAILABLE' });
396
+ if (Number.isSafeInteger(state?.boundarySeq)) nativeBoundaries.set(sid, { seq: state.boundarySeq, turn: state.nativeMetadata?.start?.data?.turn });
397
+ interruptionStates.set(sid, live.running || state?.running ? null : state?.interruption ?? null);
398
+ if (state?.nativeMetadata) nativePresentationMetadata.set(sid, state.nativeMetadata);
399
+ }
400
+
401
+ async function diagnosticAction(sid, peer, clientId) {
402
+ const scopeOwner = source; const revision = sourceRevision; const owner = presentationOwner(); const connection = generation;
403
+ const grant = current?.controllers?.[peer]?.grantRevision;
404
+ const valid = () => !stopped && active && owner && presentationOwner() === owner && source === scopeOwner && sourceRevision === revision
405
+ && generation === connection && acceptedControllers.has(peer) && policy.canAccept(peer) && !sessionIsHidden(sid)
406
+ && current?.controllers?.[peer]?.grantRevision === grant;
407
+ const requireCurrent = () => { if (!valid()) throw Object.assign(new Error('Diagnostic action scope is unavailable'), { code: 'NOT_AVAILABLE' }); };
408
+ requireCurrent();
409
+ const row = await (typeof source.getSession === 'function' ? source.getSession(sid) : source.listSessions().then(rows => rows.find(row => String(row.id) === sid)));
410
+ requireCurrent(); if (!row) throw Object.assign(new Error('Session does not exist'), { code: 'NOT_FOUND' });
411
+ const preferences = options.diagnosticPreferences;
412
+ if (typeof preferences?.commit !== 'function') throw Object.assign(new Error('Durable diagnostic preferences are unavailable'), { code: 'NOT_AVAILABLE' });
413
+ if (clientId === undefined) {
414
+ await refreshInterruption(sid, row); requireCurrent();
415
+ const candidate = interruptionStates.get(sid);
416
+ if (!candidate) return { ok: true };
417
+ await preferences.commit({ owner, sessionId: sid, kind: 'interruptions', id: candidate.id }, valid);
418
+ requireCurrent();
419
+ // Re-read after persistence; an in-flight old acknowledgement never clears a new interruption.
420
+ await refreshInterruption(sid, row); requireCurrent();
421
+ publishRowPatch(sid, presentationFields(sid, isSessionRunningNow(sid)));
422
+ routeInvoke.invalidateSessionRows();
423
+ return { ok: true };
424
+ }
425
+ const reader = options.resolveCapabilities?.()?.readAllMessages;
426
+ if (typeof reader !== 'function') throw Object.assign(new Error('Committed message history is unavailable'), { code: 'NOT_AVAILABLE' });
427
+ const rows = await reader(sid); requireCurrent();
428
+ const prefix = `${sid}:turn-error:`;
429
+ const message = rows.find(item => {
430
+ if (item.sessionId !== sid || item.clientId !== clientId || item.id !== clientId || item.role !== 'error' || typeof item.id !== 'string' || !item.id.startsWith(prefix)) return false;
431
+ const seq = Number(item.id.slice(prefix.length));
432
+ return Number.isSafeInteger(seq) && seq >= 0 && item.id === `${prefix}${seq}`;
433
+ });
434
+ if (!message) throw Object.assign(new Error('Host error message does not exist'), { code: 'NOT_FOUND' });
435
+ await preferences.commit({ owner, sessionId: sid, kind: 'errors', id: clientId }, valid); requireCurrent();
436
+ const [effective] = projectMessages([message]);
437
+ pushSessionUpdate(sid, 'local-db:messages:created', { sessionId: sid, message: effective });
438
+ pushHistoryViewChanged(sid);
439
+ return effective;
440
+ }
377
441
  function serializeSessionMeta(sessionId, operation) {
378
442
  const pending = (sessionMetaWrites.get(sessionId) ?? Promise.resolve()).then(operation);
379
443
  const settled = pending.then(() => {}, () => {});
@@ -384,7 +448,14 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
384
448
  function projectSessionRows(rows) {
385
449
  const archived = source?.archive?.snapshot(); // Read the native set once per page, not once per row.
386
450
  const pinned = source?.archive?.pinSnapshot?.();
387
- return sessionFlags.projectAll(rows).map(row => {
451
+ // Native settings describe can traverse every plugin: read once per page.
452
+ const data = presentationData(); const owner = presentationOwner();
453
+ const nativeReader = typeof options.resolveCapabilities?.()?.readMessages?.turnState === 'function';
454
+ return sessionFlags.projectAll(rows).map(original => {
455
+ const sid = String(original.id);
456
+ const native = nativeReader || Object.hasOwn(original, 'interruption');
457
+ const candidate = interruptionStates.has(sid) ? interruptionStates.get(sid) : original.interruption;
458
+ const row = native ? { ...original, ...interruptionFields(candidate, original.running === true || isSessionRunningNow(sid), sessionIsHidden(sid), data, owner, sid) } : original;
388
459
  if (archived?.has(String(row.id)) === true) {
389
460
  return { ...row, status: row.status === 'deleted' ? 'deleted' : 'archived', pinnedAt: null };
390
461
  }
@@ -761,6 +832,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
761
832
  if (source !== next) {
762
833
  sourceRevision++;
763
834
  nativeBoundaries.clear();
835
+ interruptionStates.clear();
836
+ nativePresentationMetadata.clear();
764
837
  turnVersions.clear();
765
838
  terminalIdentities.clear();
766
839
  source?.manualCompact?.cancelAll('DSH source replaced');
@@ -982,6 +1055,9 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
982
1055
  live = Array.isArray(rows) ? rows.find(row => String(row?.id) === sessionId) : null;
983
1056
  } else if (typeof owner?.getSession === 'function') live = await owner.getSession(sessionId);
984
1057
  if (!valid() || !live || typeof live.running !== 'boolean') return;
1058
+ interruptionStates.set(sessionId, live.running || state.running ? null : state.interruption ?? null);
1059
+ if (state.nativeMetadata) nativePresentationMetadata.set(sessionId, state.nativeMetadata);
1060
+ publishRowPatch(sessionId, presentationFields(sessionId, live.running));
985
1061
  if (live.running) {
986
1062
  setCachedRunning(sessionId, true);
987
1063
  const payload = { sessionId, event: runningStatusEvent(state.retryNotice?.content ?? 'running') };
@@ -994,8 +1070,9 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
994
1070
  // No await after the final guard: a new boundary cannot interleave with send.
995
1071
  setCachedRunning(sessionId, false);
996
1072
  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);
1073
+ const [effective] = diagnostic ? projectMessages([diagnostic]) : [];
1074
+ const payload = effective ? turnDiagnosticPayload(effective) : { sessionId, event: { type: 'done' } };
1075
+ sendNativeTerminal(deviceId, sessionId, payload, `end:${state.boundarySeq}`, effective);
999
1076
  } catch {
1000
1077
  if (!valid()) return;
1001
1078
  // Never reflect arbitrary upstream exception prose into remote diagnostics.
@@ -1444,6 +1521,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1444
1521
  * chip instead of the picture.
1445
1522
  */
1446
1523
  async function pushSessionMessage(sessionId, message) {
1524
+ [message] = projectMessages([message]);
1447
1525
  if (message?.role === 'user' && activeDelivery?.owns(sessionId, message.clientId)) {
1448
1526
  await new Promise(resolve => queueMicrotask(resolve));
1449
1527
  const proof = await activeDelivery.projection(sessionId, { deliveryClientIds: [message.clientId] });
@@ -1546,6 +1624,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1546
1624
  ? await source.getSession(sessionId)
1547
1625
  : (await source.listSessions()).find((candidate) => String(candidate.id) === sessionId);
1548
1626
  if (row == null) return undefined;
1627
+ await refreshInterruption(sessionId, row);
1549
1628
  const [flagged] = projectSessionRows([row]);
1550
1629
  if (flagged !== undefined) sessionRowCache.set(String(flagged.id), flagged);
1551
1630
  return flagged;
@@ -1692,6 +1771,10 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1692
1771
  // the phone reaches any other screen watching the same session.
1693
1772
  pushGoalStatus: (sessionId, status) => pushSessionUpdate(sessionId, 'maker:goal:status-changed', { sessionId, goal: status }),
1694
1773
  ...provided,
1774
+ ackInterrupted: (sid, peer) => diagnosticAction(sid, peer),
1775
+ dismissError: (sid, cid, peer) => diagnosticAction(sid, peer, cid),
1776
+ ...(typeof provided.readMessages === 'function' ? { readMessages: async (...args) => projectMessages(await provided.readMessages(...args)) } : {}),
1777
+ ...(typeof provided.readAllMessages === 'function' ? { readAllMessages: async (...args) => projectMessages(await provided.readAllMessages(...args)) } : {}),
1695
1778
  /**
1696
1779
  * The plugin's send seam, carrying this Host's media access.
1697
1780
  *
@@ -1823,7 +1906,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1823
1906
  for (const deviceId of sessionSubscribers.get(sessionId) ?? []) void restoreWatchedTurnState(deviceId, sessionId);
1824
1907
  return;
1825
1908
  }
1826
- const diagnostic = turnDiagnosticRow(event, sessionId);
1909
+ const rawDiagnostic = turnDiagnosticRow(event, sessionId);
1910
+ const diagnostic = rawDiagnostic ? projectMessages([rawDiagnostic])[0] : null;
1827
1911
  const requiresNotice = diagnostic !== null || ['error', 'interrupted', 'max-tokens', 'aborted', 'blocked'].includes(event?.data?.reason?.kind);
1828
1912
  if (requiresNotice && !diagnostic) {
1829
1913
  recordHandlerError('turn-error', new Error('Invalid native failed-turn identity; history resynchronization required'));
@@ -1835,6 +1919,15 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1835
1919
  nativeBoundaries.set(sessionId, { seq: event.seq, turn: event.data?.turn });
1836
1920
  }
1837
1921
  turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
1922
+ if (event) {
1923
+ const meta = advanceInterruptionMetadata(nativePresentationMetadata.get(sessionId), event, sessionId);
1924
+ nativePresentationMetadata.set(sessionId, meta); interruptionStates.set(sessionId, meta.interruption);
1925
+ if (event.data?.reason?.kind === 'interrupted' && !meta.start) {
1926
+ interruptionStates.delete(sessionId);
1927
+ void refreshInterruption(sessionId, sessionRowFor(sessionId)).then(() => publishRowPatch(sessionId, presentationFields(sessionId, isSessionRunningNow(sessionId))))
1928
+ .catch(error => recordHandlerError('interruption-metadata', error));
1929
+ }
1930
+ }
1838
1931
  const timer = pendingReconciles.get(sessionId);
1839
1932
  if (timer !== undefined) {
1840
1933
  pendingReconciles.delete(sessionId);
@@ -1912,7 +2005,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1912
2005
  * @param running - the turn state DSH just reported.
1913
2006
  */
1914
2007
  function publishRowTurnState(sessionId, running) {
1915
- publishRowPatch(sessionId, { running, updatedAt: now().toISOString() });
2008
+ publishRowPatch(sessionId, { running, ...presentationFields(sessionId, running), updatedAt: now().toISOString() });
1916
2009
  }
1917
2010
 
1918
2011
  /**
@@ -1931,7 +2024,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1931
2024
  for (const deviceId of sessionSubscribers.get(sessionId) ?? []) devices.add(deviceId);
1932
2025
  recordPush(sessionId, 'local-db:sessions:patched', devices.size, payload);
1933
2026
  if (devices.size === 0) return;
1934
- for (const dst of devices) send({ v: PROTOCOL_VERSION, kind: 'push', dst, payload: { channel: 'local-db:sessions:patched', payload } });
2027
+ for (const dst of devices) if (acceptedControllers.has(dst) && policy.canAccept(dst)) send({ v: PROTOCOL_VERSION, kind: 'push', dst, payload: { channel: 'local-db:sessions:patched', payload } });
1935
2028
  }
1936
2029
 
1937
2030
  /**
@@ -2010,6 +2103,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2010
2103
  nativeBoundaries.set(sessionId, { seq: event.seq, turn: event.data?.turn });
2011
2104
  }
2012
2105
  turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
2106
+ interruptionStates.set(sessionId, null);
2107
+ if (event) nativePresentationMetadata.set(sessionId, advanceInterruptionMetadata(nativePresentationMetadata.get(sessionId), event, sessionId));
2013
2108
  setCachedRunning(sessionId, true);
2014
2109
  pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: runningStatusEvent() });
2015
2110
  publishRowTurnState(sessionId, true);
@@ -2651,6 +2746,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2651
2746
  sessionSubscribers.clear();
2652
2747
  liveTurnState.clear();
2653
2748
  nativeBoundaries.clear();
2749
+ interruptionStates.clear();
2750
+ nativePresentationMetadata.clear();
2654
2751
  turnVersions.clear();
2655
2752
  terminalIdentities.clear();
2656
2753
  subscriptionLeases.clear();
@@ -2851,6 +2948,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2851
2948
  }),
2852
2949
  /** Push one live session message to the controllers watching that session. */
2853
2950
  pushSessionMessage,
2951
+ projectMessages,
2854
2952
  pushSessionReasoning: reasoningPublisher.noteEvent,
2855
2953
  // Only explicit native scheduled/started retry events assert ongoing work.
2856
2954
  // Failed attempts alone never terminate or start a turn.
package/src/turn-error.js CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Display-only native turn diagnostics. DSH owns failure, recovery and retry policy. */
2
+ import { advanceInterruptionMetadata } from './diagnostic-presentation.js';
2
3
  const SAFE_MESSAGES = new Map([
3
4
  ['AUTH', 'Model authentication failed. Check the provider configuration.'],
4
5
  ['ACCOUNT_SIGNED_OUT', '任务已因退出 DeepSeek 登录而停止。'],
@@ -92,6 +93,7 @@ export function turnDiagnosticRow(event, sessionId) {
92
93
 
93
94
  /** Notices close the running flag without pretending cancellation is a model failure. */
94
95
  export function turnDiagnosticPayload(row) {
96
+ if (row.role === 'error' && row.content?.dismissed === true) return { sessionId: row.sessionId, persistId: row.clientId, event: { type: 'done', data: { reason: 'dsh-dismissed-error' } } };
95
97
  return row.role === 'error' ? turnErrorPayload(row) : { sessionId: row.sessionId, persistId: row.clientId,
96
98
  event: { type: 'done', data: { message: row.content, reason: 'dsh-turn-notice' } } };
97
99
  }
@@ -100,6 +102,7 @@ export function turnDiagnosticPayload(row) {
100
102
  export function turnStateFromEvents(events, sessionId) {
101
103
  if (!Array.isArray(events)) throw new TypeError('Native turn recovery requires a session event snapshot');
102
104
  let boundary = null;
105
+ let nativeMetadata = null;
103
106
  let retryNotice = null;
104
107
  let seq = -1;
105
108
  for (const event of events) {
@@ -108,17 +111,18 @@ export function turnStateFromEvents(events, sessionId) {
108
111
  throw new TypeError('Native turn recovery requires ordered unique event sequences');
109
112
  }
110
113
  seq = event.seq;
114
+ nativeMetadata = advanceInterruptionMetadata(nativeMetadata, event, sessionId);
111
115
  if (event.type === 'turn/start' || event.type === 'turn/end') { boundary = event; retryNotice = null; }
112
116
  else if (['step/start', 'assistant/message'].includes(event.type)) retryNotice = null;
113
117
  else if (boundary?.type === 'turn/start' && boundary.data?.turn === event.data?.turn
114
118
  && ['llm/retry', 'llm/retry-started'].includes(event.type)) retryNotice = turnDiagnosticRow(event, sessionId);
115
119
  }
116
120
  if (!boundary) return null;
117
- if (boundary.type === 'turn/start') return { seq, boundarySeq: boundary.seq, running: true, error: null, ...(retryNotice ? { retryNotice } : {}) };
121
+ if (boundary.type === 'turn/start') return { seq, boundarySeq: boundary.seq, running: true, error: null, nativeMetadata, ...(retryNotice ? { retryNotice } : {}) };
118
122
  const diagnostic = turnDiagnosticRow(boundary, sessionId);
119
123
  const error = diagnostic?.role === 'error' ? diagnostic : null;
120
124
  // A malformed failure is not evidence of a successful ending.
121
125
  if (boundary.data?.reason?.kind === 'error' && !error) throw new TypeError('Invalid native failed-turn identity');
122
126
  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 } : {}) };
127
+ return { seq, boundarySeq: boundary.seq, running: false, error, nativeMetadata, interruption: nativeMetadata?.interruption ?? null, ...(diagnostic?.role === 'system' ? { notice: diagnostic } : {}) };
124
128
  }