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 +7 -0
- package/README.md +6 -2
- package/package.json +1 -1
- package/src/cindy-channels.js +13 -0
- package/src/cindy-session-row.js +1 -0
- package/src/continuation-protocol.js +31 -0
- package/src/diagnostic-presentation.js +65 -0
- package/src/dsh-input-delivery.js +28 -2
- package/src/dsh-message-fold.js +11 -5
- package/src/dsh-plugin.js +10 -1
- package/src/dsh-session-source.js +2 -2
- package/src/host-input-delivery.js +23 -4
- package/src/host-settings.js +15 -1
- package/src/host-version.js +1 -1
- package/src/host.js +104 -6
- package/src/turn-error.js +6 -2
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.
|
|
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.
|
|
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
package/src/cindy-channels.js
CHANGED
|
@@ -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
|
package/src/cindy-session-row.js
CHANGED
|
@@ -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});
|
package/src/dsh-message-fold.js
CHANGED
|
@@ -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) =>
|
|
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);
|
|
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
|
|
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
|
},
|
package/src/host-settings.js
CHANGED
|
@@ -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)) {
|
package/src/host-version.js
CHANGED
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
|
-
|
|
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
|
|
998
|
-
|
|
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
|
|
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
|
}
|