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 +13 -0
- package/README.md +6 -2
- package/package.json +1 -1
- package/src/cindy-channels.js +13 -0
- package/src/cindy-message-row.js +8 -2
- 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 +37 -4
- package/src/dsh-message-fold.js +34 -41
- package/src/dsh-plugin.js +18 -5
- package/src/dsh-session-source.js +2 -2
- package/src/history-delivery-state.js +32 -0
- package/src/history-snapshot.js +58 -0
- package/src/host-history-view-limits.js +5 -6
- package/src/host-history-view.js +39 -32
- 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 +146 -10
- package/src/reasoning-publisher.js +44 -17
- package/src/reasoning-stream.js +7 -6
- package/src/turn-error.js +6 -2
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.
|
|
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.
|
|
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
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-message-row.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
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
|
-
import {
|
|
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
|
-
|
|
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});
|
package/src/dsh-message-fold.js
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
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) =>
|
|
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
|
-
|
|
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
|
+
}
|