dsh-cindy-host 0.1.35 → 0.1.37

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.37 — 2026-11-06
4
+
5
+ - Use explicit source-order history snapshots for both validated persistence and the hot fallback reader. Preserve legacy raw order, reconcile live reasoning through native history-live client identities, and page by source cuts; unknown cursors now fail explicitly rather than silently skipping history.
6
+ - Fold raw thinking pushes only for the subscribed controller that successfully received a current history page. Reset this disposable state on read/send failure and lifecycle changes, preserve pending-read trailing refreshes, and fence replies against stale relay generations.
7
+ - Coalesce interim reasoning at 1s with latest-wins deduplication and serialized UTF-8 budgets (32 KiB/s, 64 KiB burst/snapshot). Bound automatic preview ranges to 5 rows/6 KiB without truncating full details or delaying terminal retirement. These are Host-side safeguards, not handset white-screen acceptance or proof that every device's failure is resolved.
8
+
9
+ ## 0.1.36 — 2026-11-06
10
+
11
+ - 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.
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+
3
16
  ## 0.1.35 — 2026-11-05
4
17
 
5
18
  - 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.37** stabilizes source-order history pagination, per-controller history delivery state and bounded interim reasoning updates. It retains 0.1.36's interruption controls and the earlier diagnostics, question synchronization, authentication and native capability subsets. Host regressions do not establish that handset white-screen issues are resolved.
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.37
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.37",
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
@@ -286,7 +286,7 @@ const RENDERABLE_SOURCE_KINDS = new Set(['user', 'model', 'tool']);
286
286
  * user turn — the `clientId` the controller minted for the prompt.
287
287
  * @returns one row per renderable content block, in order.
288
288
  */
289
- export function toCindyMessageRows(message, { sessionId, createdAt, now = () => new Date(), promptClientId = null }) {
289
+ export function toCindyMessageRows(message, { sessionId, createdAt, now = () => new Date(), promptClientId = null, reasoningIdentity = null }) {
290
290
  const rows = [];
291
291
  // Canonical DSH results use a tool-role message with root outcome/call ID,
292
292
  // not the legacy user-role nested tool-result block. Keep both formats.
@@ -372,7 +372,12 @@ export function toCindyMessageRows(message, { sessionId, createdAt, now = () =>
372
372
  if (typeof block.text !== 'string' || block.text === '') return;
373
373
  // Thinking is a structured native role on both Cindy clients. Unlike
374
374
  // ordinary prose, a string here is discarded by mobile normalizeThinking.
375
- rows.push({ ...base, content: { text: block.text } });
375
+ // Native history-live refs resolve by clientId after the durable id replaces the live id.
376
+ const identity = reasoningIdentity;
377
+ const logical = Number.isSafeInteger(identity?.turn) && identity.turn >= 0
378
+ && Number.isSafeInteger(identity?.step) && identity.step >= 0
379
+ ? `${sessionId}:reasoning-stream:${identity.turn}:${identity.step}:${index}` : clientId;
380
+ rows.push({ ...base, clientId: logical, content: { text: block.text } });
376
381
  return;
377
382
  case 'tool-call':
378
383
  rows.push({
@@ -437,6 +442,7 @@ export function toCindyMessages(messages, { sessionId, now = () => new Date() })
437
442
  createdAt: entry.createdAt,
438
443
  now,
439
444
  promptClientId: entry.promptClientId ?? null,
445
+ reasoningIdentity: entry.reasoningIdentity ?? null,
440
446
  }));
441
447
  const rows = [];
442
448
  for (const group of perMessage.reverse()) rows.push(...group);
@@ -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
- import { foldSessionEvent } from './dsh-message-fold.js';
2
+ import { historySnapshotFromEvents } 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;},
@@ -44,7 +57,14 @@ export function createDshInputDeliveryBackend({getServices,sessionController,get
44
57
  // session or agent must never be diagnosed as a crashed writer.
45
58
  const cold=!s.sessions.get(sid)&&typeof s.agents?.get==='function'&&!s.agents.get(sid);
46
59
  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();},
60
+ // Preserve the raw newest-first order/rowid contract while including the same
61
+ // public live overlay as history. Receipt evidence still reads the native log only.
62
+ return historySnapshotFromEvents(events, sid, { rowids: true }).rows.reverse();},
63
+ async historySnapshot(sid) {
64
+ const persisted = await readLog(sid); const s = services();
65
+ const cold = !s.sessions.get(sid) && typeof s.agents?.get === 'function' && !s.agents.get(sid);
66
+ return historySnapshotFromEvents(cold ? [...persisted, ...interruptedTurnClosers(persisted)] : persisted, sid);
67
+ },
48
68
  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};},
49
69
  async prepare(sid,row,{resolveAttachmentRef,mode='queue'}={}){
50
70
  assertNoStructuredSessionReferences(row);
@@ -54,13 +74,26 @@ export function createDshInputDeliveryBackend({getServices,sessionController,get
54
74
  const materializer=createAttachmentMaterializer({fileSystem:getServices().fileSystem,resolveAttachmentRef,captureOnly:true});
55
75
  const owned=await materializer.materialize({attachments:incoming,signal:AbortSignal.timeout(30000)});
56
76
  if(owned.dropped.length)throw deliveryError('ATTACHMENT_UNAVAILABLE','Cannot durably receive all attachments: '+owned.dropped.map(x=>x.reason).join(', '));
77
+ const kind=continuationKind(text);
78
+ const continuation=kind?{kind,tailId:await continuationTail(sid,kind,row.clientId)}:null;
79
+ if(continuation&&row.createOpts?.planMode!==false)throw deliveryError('INVALID_PARAMS','Manual continuation requires explicit planMode:false');
57
80
  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}:{})}}};
81
+ 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
82
  },
60
83
  async dispatch(entry,{releaseAttachmentRef}={}){
61
84
  assertNoStructuredSessionReferences(entry.request,entry.payload);
62
85
  let agent;
63
86
  try{agent=await agentFor(entry.sessionId);}catch(error){error.deliveryNotAdmitted=true;throw error;}
87
+ if(entry.payload.continuation) {
88
+ try {
89
+ const origin=entry.payload.continuation;
90
+ if(await continuationTail(entry.sessionId,origin.kind,entry.clientId)!==origin.tailId)throw deliveryError('CONTINUATION_SUPERSEDED','A newer task superseded this manual continuation');
91
+ if(typeof ensurePlanModeOff!=='function')throw deliveryError('PLAN_MODE_UNAVAILABLE','Cannot verify native plan mode is off');
92
+ await ensurePlanModeOff({sessionId:entry.sessionId,agent});
93
+ if(await continuationTail(entry.sessionId,origin.kind,entry.clientId)!==origin.tailId)throw deliveryError('CONTINUATION_SUPERSEDED','A newer task superseded this manual continuation');
94
+ services();
95
+ } catch(error) { error.deliveryNotAdmitted=true; if(error.code==='CONTINUATION_SUPERSEDED')error.deliverySuperseded=true; throw error; }
96
+ }
64
97
  const content=entry.payload.text?[{type:'text',text:entry.payload.text}]:[];
65
98
  for(const asset of entry.payload.assets??[]){
66
99
  if(asset.mediaType)content.push({type:'image',mediaType:asset.mediaType,data:asset.data,name:asset.name});
@@ -16,8 +16,9 @@
16
16
  * history, and model requests all share, so a second copy here would drift.
17
17
  */
18
18
  import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface';
19
- import { createReasoningStream } from './reasoning-stream.js';
19
+ import { createHistoryProjection } from './history-snapshot.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. */
@@ -106,7 +107,8 @@ export function foldSessionEvent(event, { sessionId, now = () => new Date() }) {
106
107
  const createdAt = Number.isFinite(event.time) ? new Date(event.time).toISOString() : now().toISOString();
107
108
  // The controller's own id for a submitted prompt travels with the row so it
108
109
  // can retire the copy it showed optimistically.
109
- return toCindyMessageRows(message, { sessionId, createdAt, now, promptClientId: promptRpcIdOf(event) });
110
+ return toCindyMessageRows(message, { sessionId, createdAt, now, promptClientId: promptRpcIdOf(event),
111
+ reasoningIdentity: event.type === 'assistant/message' ? event.data : null });
110
112
  }
111
113
 
112
114
  /** The `source` a message carries, whichever shape the surface handed over. */
@@ -290,6 +292,18 @@ function widenNewestPage(options) {
290
292
  return { ...options, limit: NEWEST_WINDOW_ROWS };
291
293
  }
292
294
 
295
+ /** Shared public display fold; never changes input delivery evidence. */
296
+ export function displayEventRows(event, context) {
297
+ const todo = entryForTodoWrite(event);
298
+ return todo ? toCindyMessageRows(todo.message, { ...context, createdAt: todo.createdAt }) : foldSessionEvent(event, context);
299
+ }
300
+
301
+ export function historySnapshotFromEvents(events, sessionId, options = {}) {
302
+ const projection = createHistoryProjection(sessionId);
303
+ for (const event of events) projection.consume(event, displayEventRows(event, { sessionId }));
304
+ return projection.snapshot(options);
305
+ }
306
+
293
307
  /**
294
308
  * Render one DSH `todo/write` snapshot as the row the phone draws its plan card from.
295
309
  *
@@ -376,49 +390,25 @@ export function createMessageReader({
376
390
  const seeding = new Map();
377
391
  let clock = 0;
378
392
 
379
- /** Fold one session's log into message entries, in log order. */
393
+ /** One fold feeds both the legacy raw order and the explicit history snapshot. */
380
394
  async function entriesFor(sessionId) {
381
395
  const snapshot = await readSessionLog(sessionId);
382
396
  const events = Array.isArray(snapshot?.events) ? snapshot.events : [];
383
397
  const entries = [];
384
- const reasoning = createReasoningStream(sessionId);
398
+ const history = createHistoryProjection(sessionId);
385
399
  let lastSeq = null;
400
+ let nativeMetadata = null;
401
+ let nativeValid = true;
386
402
  for (const event of events) {
387
403
  if (event === null || typeof event !== 'object') continue;
388
- // Every event's seq counts, not only the ones that render: contiguity is a
389
- // property of the log, and a caller comparing against renders would read a
390
- // gap as "nothing happened here".
404
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0 || (lastSeq !== null && event.seq !== lastSeq + 1)) nativeValid = false;
391
405
  if (Number.isFinite(event.seq)) lastSeq = event.seq;
392
- reasoning.consume(event);
393
- const diagnostic = turnDiagnosticRow(event, sessionId);
394
- if (diagnostic) {
395
- entries.push({ rows: [diagnostic] });
396
- continue;
397
- }
398
- const todo = entryForTodoWrite(event);
399
- if (todo !== null) {
400
- entries.push(todo);
401
- continue;
402
- }
403
- if (!isAppendSurfaceEvent(event)) continue;
404
- let message;
405
- try {
406
- message = deriveEventMessage(event);
407
- } catch {
408
- // One unprojectable event must not cost the whole transcript; the same
409
- // rule the surface fold applies per node.
410
- continue;
411
- }
412
- if (message === null || message === undefined) continue;
413
- entries.push({
414
- message,
415
- createdAt: Number.isFinite(event.time) ? new Date(event.time).toISOString() : now().toISOString(),
416
- // Same reason as the live fold: the durable read has to identify a user
417
- // turn by the id the controller minted, or its optimistic copy survives.
418
- promptClientId: promptRpcIdOf(event),
419
- });
406
+ nativeMetadata = advanceInterruptionMetadata(nativeMetadata, event, sessionId);
407
+ const rows = displayEventRows(event, { sessionId, now });
408
+ history.consume(event, rows);
409
+ if (rows.length) entries.push({ rows });
420
410
  }
421
- return { entries, lastSeq, reasoning };
411
+ return { entries, lastSeq, history, reasoning: history.reasoning, nativeValid, nativeMetadata: nativeValid ? nativeMetadata : null };
422
412
  }
423
413
 
424
414
  /** Drop the least recently used transcripts, keeping the window bounded. */
@@ -498,9 +488,9 @@ export function createMessageReader({
498
488
  // invalidation also detaches this flight so a post-invalidation read starts
499
489
  // fresh; an older completion must never overwrite that newer generation.
500
490
  if (seeding.get(sessionId) === flight && !flight.dirty) {
501
- touch(sessionId, { rows, reasoning: read.reasoning, seq: read.lastSeq, at: clock });
491
+ touch(sessionId, { rows, history: read.history, reasoning: read.reasoning, seq: read.lastSeq, at: clock, nativeValid: read.nativeValid, nativeMetadata: read.nativeMetadata });
502
492
  }
503
- return transcripts.get(sessionId) ?? { rows, reasoning: read.reasoning };
493
+ return transcripts.get(sessionId) ?? { rows, history: read.history, reasoning: read.reasoning, nativeMetadata: read.nativeMetadata };
504
494
  } finally {
505
495
  // Failure is not an authoritative empty transcript. Release this flight
506
496
  // on success or rejection, without deleting a newer read's ownership.
@@ -552,9 +542,10 @@ export function createMessageReader({
552
542
  transcripts.delete(sessionId);
553
543
  return false;
554
544
  }
545
+ if (entry.nativeValid !== false) entry.nativeMetadata = advanceInterruptionMetadata(entry.nativeMetadata, event, sessionId);
555
546
  entry.seq = seq;
556
547
  entry.at = ++clock;
557
- entry.reasoning.consume(event);
548
+ // The history projection owns the raw overlay reducer too.
558
549
  // A live todo snapshot becomes the same `TodoWrite` row the durable read produces, so
559
550
  // the phone's plan card updates while the plan is being worked rather than only when
560
551
  // the transcript is re-read.
@@ -562,6 +553,7 @@ export function createMessageReader({
562
553
  const rows = todoEntry === null
563
554
  ? foldSessionEvent(event, { sessionId, now })
564
555
  : toCindyMessageRows(todoEntry.message, { sessionId, createdAt: todoEntry.createdAt, now });
556
+ entry.history.consume(event, rows);
565
557
  // Newest first: this event is the newest thing in the log, so its rows are the
566
558
  // new head of the array, in block order.
567
559
  if (rows.length > 0) entry.rows = [...rows, ...entry.rows];
@@ -590,7 +582,7 @@ export function createMessageReader({
590
582
  readMessages.count = countRows;
591
583
  /** Exact project-grouping evidence derived from the same folded transcript. */
592
584
  readMessages.activity = async (sessionId) => {
593
- const { rows } = await ensureTranscript(sessionId);
585
+ const { rows, nativeMetadata } = await ensureTranscript(sessionId);
594
586
  let userSendAt = null;
595
587
  for (let index = rows.length - 1; index >= 0; index -= 1) {
596
588
  const row = rows[index];
@@ -599,7 +591,7 @@ export function createMessageReader({
599
591
  break;
600
592
  }
601
593
  }
602
- return { messageCount: rows.length, userSendAt };
594
+ return { messageCount: rows.length, userSendAt, ...(nativeMetadata ? { interruption: nativeMetadata.interruption } : {}) };
603
595
  };
604
596
  /**
605
597
  * The whole cached transcript, newest first.
@@ -609,6 +601,7 @@ export function createMessageReader({
609
601
  * The array is the cache's own, so callers treat it as read-only.
610
602
  */
611
603
  readMessages.all = async (sessionId) => visibleRows(await ensureTranscript(sessionId));
604
+ readMessages.historySnapshot = async (sessionId) => (await ensureTranscript(sessionId)).history.snapshot();
612
605
  // Recovery deliberately bypasses the transcript cache: stale history cannot
613
606
  // decide whether the *current* turn has ended. Runtime generation guards the read.
614
607
  readMessages.turnState = async (sessionId) => {
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
  /**
@@ -684,9 +687,9 @@ export function buildDshSource(ctx, serviceName, options = {}) {
684
687
  * The work-grouped history window the controller prefers once it has seen
685
688
  * `history-view-v1`.
686
689
  *
687
- * Grouping runs over the same cached transcript the raw window pages
688
- * (`readMessages.all`), so the two views can never disagree about what the session
689
- * contains — and neither of them re-reads the log to answer.
690
+ * History consumes an explicit source-order snapshot, not the raw window's legacy
691
+ * array order. Prefer the flushed/validated durable backend when available; the
692
+ * fallback reader shares its cached fold and reasoning reducer with its raw window.
690
693
  */
691
694
  const deliveryServices = { sessions: undefined, persistence: undefined, agents: undefined };
692
695
  ctx.inject(['sessions', 'sessionPersistence', 'agents'], deliveryCtx => {
@@ -697,12 +700,20 @@ 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({
705
- rows: committedRows,
712
+ snapshot: async (sessionId) => {
713
+ const snapshot = await (inputDeliveryBackend.available()
714
+ ? inputDeliveryBackend.historySnapshot(sessionId) : readMessages.historySnapshot(sessionId));
715
+ return { ...snapshot, rows: options.projectMessages?.(snapshot.rows) ?? snapshot.rows };
716
+ },
706
717
  // The turn state has to be **wired** here; the controller's own default is `() => false`.
707
718
  // That default is why a running turn was never marked `isStreaming`, which cost two things
708
719
  // at once and looked like a client problem both times: the phone drew no live card, and
@@ -1881,6 +1892,7 @@ export function apply(ctx, config) {
1881
1892
  // Delete/pin and old archive overlays persist here; new archives live in
1882
1893
  // DSH's native workspace registry, not this plugin's settings section.
1883
1894
  persistSessionFlags: sessionFlagsWriter(ctx.settings, settingsNamespace(SETTINGS_NAMESPACE)),
1895
+ diagnosticPreferences: diagnosticPreferenceStore(ctx.settings, settingsNamespace(SETTINGS_NAMESPACE)),
1884
1896
  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
1897
 
1886
1898
  identityIsCurrent: () => admitted || (!runtimeDisposed && lease()),
@@ -1914,6 +1926,7 @@ export function apply(ctx, config) {
1914
1926
  // Read lazily: the runtime resolves after this seam is built, and the state changes on
1915
1927
  // every turn boundary.
1916
1928
  isSessionRunning: (sessionId) => runtime?.isSessionRunning?.(sessionId) === true,
1929
+ projectMessages: rows => runtime?.projectMessages?.(rows) ?? rows,
1917
1930
  getProjectOrderOwner: () => runtime?.getProjectOrderOwner?.(),
1918
1931
  });
1919
1932
  // 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
  });
@@ -0,0 +1,32 @@
1
+ /** Disposable transport state, scoped to an accepted controller and its subscription lease. */
2
+ export function createHistoryDeliveryState(canWatch) {
3
+ const controllers = new Map();
4
+ function forget(deviceId, sessionId) {
5
+ if (sessionId === undefined) controllers.delete(deviceId);
6
+ else { const sessions = controllers.get(deviceId); sessions?.delete(sessionId); if (!sessions?.size) controllers.delete(deviceId); }
7
+ }
8
+ function ready(deviceId, sessionId) {
9
+ if (!canWatch(deviceId, sessionId)) { forget(deviceId, sessionId); return false; }
10
+ return controllers.get(deviceId)?.get(sessionId)?.ready === true;
11
+ }
12
+ function begin(frame) {
13
+ if (frame.payload?.channel !== 'local-db:messages:view') return null;
14
+ const sessionId = frame.payload.args?.[0];
15
+ if (typeof sessionId !== 'string' || !canWatch(frame.src, sessionId)) return null;
16
+ const sessions = controllers.get(frame.src) ?? new Map();
17
+ const latest = !(typeof frame.payload.args?.[1]?.before === 'string' && frame.payload.args[1].before !== '');
18
+ const token = { deviceId: frame.src, sessionId, latest, dirty: false,
19
+ ready: !latest && sessions.get(sessionId)?.ready === true };
20
+ sessions.set(sessionId, token); controllers.set(frame.src, sessions);
21
+ return token;
22
+ }
23
+ function finish(token, success) {
24
+ if (!token || controllers.get(token.deviceId)?.get(token.sessionId) !== token) return false;
25
+ if (!success || !canWatch(token.deviceId, token.sessionId)) { forget(token.deviceId, token.sessionId); return false; }
26
+ if (token.latest) token.ready = true;
27
+ const dirty = token.dirty; token.dirty = false;
28
+ return dirty;
29
+ }
30
+ function changed(sessionId) { for (const sessions of controllers.values()) { const state = sessions.get(sessionId); if (state) state.dirty = true; } }
31
+ return { ready, begin, finish, changed, forget, clear: () => controllers.clear() };
32
+ }