@adhdev/daemon-core 0.9.82-rc.466 → 0.9.82-rc.468

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.
@@ -0,0 +1,30 @@
1
+ export declare function getEffectDedupKey(effect: {
2
+ id?: string;
3
+ type: string;
4
+ message?: {
5
+ content?: unknown;
6
+ };
7
+ toast?: {
8
+ message?: string;
9
+ };
10
+ notification?: {
11
+ title?: string;
12
+ body?: string;
13
+ };
14
+ }): string;
15
+ export declare function getPersistedEffectContent(effect: {
16
+ type: string;
17
+ message?: {
18
+ content?: unknown;
19
+ };
20
+ toast?: {
21
+ message?: string;
22
+ };
23
+ notification?: {
24
+ title?: string;
25
+ body?: string;
26
+ bubbleContent?: unknown;
27
+ };
28
+ }): string | null;
29
+ export declare function formatApprovalRequestMessage(modalMessage?: string, buttons?: string[]): string;
30
+ export declare function formatMarkerTimestamp(timestamp: number): string;
@@ -0,0 +1,45 @@
1
+ export declare const STATUS_HYDRATION_TAIL_LIMIT = 200;
2
+ export type CompletedDebouncePending = {
3
+ chatTitle: string;
4
+ duration: number;
5
+ timestamp: number;
6
+ firstObservedAt: number;
7
+ previousStatus: string;
8
+ loggedBlockReason?: string;
9
+ loggedTranscriptProbe?: boolean;
10
+ transcriptProbeHistory?: ExternalTranscriptProbe[];
11
+ taskId?: string;
12
+ turnStartedAt?: number;
13
+ busyEpochAtArm?: number;
14
+ lastOutputAtArm?: number;
15
+ };
16
+ export type CompletedFinalizationBlock = {
17
+ reason: string;
18
+ terminal?: boolean;
19
+ allowTimeout?: boolean;
20
+ holdForTranscript?: boolean;
21
+ };
22
+ export type CompletionFinalAssistantEvidence = {
23
+ present: boolean;
24
+ messages: unknown[];
25
+ source: 'parsed' | 'external-native' | 'unavailable';
26
+ };
27
+ export type ExternalTranscriptProbe = {
28
+ readAt: number;
29
+ msgCount: number;
30
+ lastRole: string | null;
31
+ lastKind: string | null;
32
+ contentLen: number;
33
+ sourcePath: string | null;
34
+ sourceMtimeMs: number | null;
35
+ mtimeAgeMs: number | null;
36
+ };
37
+ export declare const COMPLETED_FINALIZATION_RETRY_MS = 1000;
38
+ export declare const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30000;
39
+ export declare const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
40
+ export declare const USER_INPUT_ACK_DEDUP_WINDOW_MS = 60000;
41
+ export declare const STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS = 12000;
42
+ /** Events that signal a dispatched mesh task has reached a terminal state.
43
+ * Detach the mesh assignment after emitting one of these so the worker's
44
+ * next unrelated turn doesn't impersonate another completion. */
45
+ export declare const TERMINAL_MESH_EVENTS: Set<string>;
@@ -278,7 +278,6 @@ export declare class CliProviderInstance implements ProviderInstance {
278
278
  private lastExternalCompletionProbe;
279
279
  private enforceFreshSessionLaunchIfNeeded;
280
280
  private completionHasFinalAssistantMessage;
281
- private buildExternalTranscriptProbe;
282
281
  private recordPendingTranscriptProbe;
283
282
  /**
284
283
  * The spawned CLI's env overrides (e.g. the mesh coordinator points hermes
@@ -312,6 +311,18 @@ export declare class CliProviderInstance implements ProviderInstance {
312
311
  * genuine resolution frees the gate promptly.
313
312
  */
314
313
  private autoApproveContinuityWindowMs;
314
+ /**
315
+ * The settle-gate identity signature for a raw activeModal, or null when the
316
+ * modal is NOT a concrete auto-approvable consent prompt (no captured buttons,
317
+ * a picker/confirm kind, or no reliable affirmative+decline anchor). Mirrors the
318
+ * gates the auto-approve fire path applies before computing modalSignature, so
319
+ * the mask-stall nudge can ask the SAME question the settle gate is tracking —
320
+ * "is THIS frame's modal the identity the settle clock is accruing against?" —
321
+ * without duplicating the button-pick logic. The signature is message +
322
+ * normalized affirmative label only (no volatile counters/button set), matching
323
+ * the fire path exactly (AUTOAPPROVE-SETTLE-FLAP).
324
+ */
325
+ private approvableModalSignature;
315
326
  private isAutonomousMeshSession;
316
327
  /**
317
328
  * ARCH-REFACTOR R1: the taskId to attribute the CURRENTLY-completing turn to.
@@ -378,8 +389,6 @@ export declare class CliProviderInstance implements ProviderInstance {
378
389
  private pushEvent;
379
390
  private flushEvents;
380
391
  private applyProviderResponse;
381
- private getEffectDedupKey;
382
- private getPersistedEffectContent;
383
392
  getAdapter(): ProviderCliAdapter;
384
393
  get cliType(): string;
385
394
  get cliName(): string;
@@ -422,13 +431,10 @@ export declare class CliProviderInstance implements ProviderInstance {
422
431
  private maybeEmitStalledApprovalNudge;
423
432
  private recordAutoApproval;
424
433
  recordApprovalSelection(buttonText: string): void;
425
- private formatMarkerTimestamp;
426
434
  private maybeAppendRuntimeRecoveryMessage;
427
435
  private appendRuntimeSystemMessage;
428
436
  private appendRuntimeMessage;
429
437
  mergeRuntimeChatMessages(parsedMessages: ChatMessage[]): ChatMessage[];
430
- private mergeConversationMessages;
431
- private formatApprovalRequestMessage;
432
438
  private promoteProviderSessionId;
433
439
  private shouldHydrateExistingProviderHistory;
434
440
  private shouldSuppressFreshLaunchStartupReplay;
@@ -0,0 +1,7 @@
1
+ import type { ChatMessage } from '../types.js';
2
+ import type { ExternalTranscriptProbe } from './cli-provider-instance-types.js';
3
+ export declare function mergeConversationMessages(runtimeMessages: Array<{
4
+ key: string;
5
+ message: ChatMessage;
6
+ }>, parsedMessages: any[]): ChatMessage[];
7
+ export declare function buildExternalTranscriptProbe(messages: unknown[], sourcePath?: string, sourceMtimeMs?: number): ExternalTranscriptProbe;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.466",
3
+ "version": "0.9.82-rc.468",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "0.9.82-rc.466",
51
- "@adhdev/session-host-core": "0.9.82-rc.466",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.468",
51
+ "@adhdev/session-host-core": "0.9.82-rc.468",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -551,6 +551,8 @@ export function buildPendingEventFingerprint(event: PendingMeshCoordinatorEvent)
551
551
  // wasDirectDeliveredToCoordinator) was removed when spontaneous PTY direct-inject was retired.
552
552
  // Delivery is now queue-drain-only: an event is consumed by exactly one drainer via the atomic
553
553
  // SQLite drained=1 marking, so there is no PTY-vs-poll double-delivery left to dedup against.
554
+ // The dormant mesh_direct_delivered_events table that backed it was dropped in
555
+ // MeshRuntimeStore.migrateMeshIsolationColumns (DROP TABLE IF EXISTS, migration step 5).
554
556
 
555
557
  export function hasPendingCoordinatorEventDuplicate(event: PendingMeshCoordinatorEvent): boolean {
556
558
  const fingerprint = buildPendingEventFingerprint(event);
@@ -1531,7 +1531,11 @@ async function pullRemoteNodeQueues(
1531
1531
  ? candidateDaemonIds.map(id => ({ meshId, coordinatorDaemonId: id }))
1532
1532
  : [{ meshId }];
1533
1533
 
1534
- for (const node of mesh.nodes) {
1534
+ // Parallelize across nodes: a single connected-but-slow node must not serially
1535
+ // block the other nodes for the rest of the tick. Each node callback is fully
1536
+ // self-contained (local/candidate skip, peer-connected pre-check, per-candidate
1537
+ // pulls, extract→re-inject) and best-effort — allSettled swallows per-node errors.
1538
+ await Promise.allSettled(mesh.nodes.map(async (node) => {
1535
1539
  const nodeDaemonId = readNonEmptyString(node.daemonId);
1536
1540
  // Skip nodes without a daemon, and nodes on THIS daemon (their events are
1537
1541
  // already in the local queue drained in PHASE 2). "This daemon" is matched
@@ -1539,9 +1543,22 @@ async function pullRemoteNodeQueues(
1539
1543
  // localDaemonId — a self node can be registered under the config-form daemonId
1540
1544
  // (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
1541
1545
  // from ourselves over P2P is both wasteful and a self-dispatch hazard.
1542
- if (!nodeDaemonId) continue;
1543
- if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) continue;
1544
- if (daemonIdListIncludes(candidateDaemonIds, nodeDaemonId)) continue;
1546
+ if (!nodeDaemonId) return;
1547
+ if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) return;
1548
+ if (daemonIdListIncludes(candidateDaemonIds, nodeDaemonId)) return;
1549
+
1550
+ // Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a)): a degraded peer whose
1551
+ // DataChannel is not open would sink this pull into peer.connectQueue and stall
1552
+ // until CONNECT_TIMEOUT_MS (90s), formerly freezing the whole serial loop and
1553
+ // delaying completion-event recovery from healthy nodes. Skip such a node THIS
1554
+ // tick and retry next tick — LOSSLESS: an unconnected peer has not drained
1555
+ // anything (drained=0 preserved), so its events are recovered whole on the next
1556
+ // successful tick. Skip = delay, never loss.
1557
+ // • snapshot present and state !== 'connected' → skip (continue next tick).
1558
+ // • snapshot null/undefined (getter unwired, e.g. standalone) → DO NOT skip;
1559
+ // fall through to the legacy path so this stays regression-free.
1560
+ const peerSnapshot = components.getMeshPeerConnectionStatus?.(nodeDaemonId);
1561
+ if (peerSnapshot && String(peerSnapshot.state) !== 'connected') return;
1545
1562
 
1546
1563
  for (const pendingEventArgs of pulls) {
1547
1564
  let events: unknown;
@@ -1560,7 +1577,7 @@ async function pullRemoteNodeQueues(
1560
1577
  } catch { /* best-effort re-inject */ }
1561
1578
  }
1562
1579
  }
1563
- }
1580
+ }));
1564
1581
  }
1565
1582
 
1566
1583
  // Pull the read_chat payload out of whatever envelope the transport returned.
@@ -1989,6 +2006,16 @@ async function collectLiveNodesWithSessions(
1989
2006
  const isLocalNode = !nodeDaemonId
1990
2007
  || daemonIdListIncludes(selfIds, nodeDaemonId)
1991
2008
  || daemonIdsEquivalent(nodeDaemonId, localDaemonId);
2009
+ // Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a)): mirror pullRemoteNodeQueues.
2010
+ // Without this the 90s connect-deadline block re-enters via this Promise.all —
2011
+ // a degraded remote's get_status_metadata sinks into peer.connectQueue and stalls
2012
+ // the whole prune probe. Only call the remote when the peer is 'connected'; an
2013
+ // unconnected peer is left undecorated (empty session list), same as unreachable.
2014
+ // Getter unwired (null/undefined) → do NOT skip, fall through (regression-free).
2015
+ if (!isLocalNode) {
2016
+ const peerSnapshot = components.getMeshPeerConnectionStatus?.(nodeDaemonId);
2017
+ if (peerSnapshot && String(peerSnapshot.state) !== 'connected') return node;
2018
+ }
1992
2019
  let statusResult: unknown;
1993
2020
  try {
1994
2021
  if (isLocalNode) {
@@ -1,7 +1,8 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'fs';
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync } from 'fs';
2
2
  import { dirname, join } from 'path';
3
3
  import { LOG } from '../logging/logger.js';
4
4
  import { loadBetterSqlite3 } from '../system/load-better-sqlite3.js';
5
+ import { getConfigDir } from '../config/config.js';
5
6
  import { getLedgerDir } from './mesh-ledger.js';
6
7
  import { nodeSatisfiesRequiredTags, isTaskReadonly, taskDependenciesSatisfied } from './mesh-work-queue.js';
7
8
  import { meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, sessionIdsEquivalent } from '@adhdev/mesh-shared';
@@ -39,10 +40,43 @@ function legacyQueuePath(meshId: string): string {
39
40
  }
40
41
 
41
42
  let loggedMigrationFailure = false;
43
+ let loggedStrayCleanup = false;
44
+
45
+ /**
46
+ * MESH-COMPLEXITY-AUDIT Part 8-1: one-shot hygiene for a stray root mesh-runtime.db.
47
+ *
48
+ * The store lives at `~/.adhdev/mesh-ledger/mesh-runtime.db` (getLedgerDir()). An older
49
+ * build path could create a 0-byte `mesh-runtime.db` directly under `~/.adhdev/` — a
50
+ * dead file that is never opened or read (the canonical path is the only one used) but
51
+ * lingers. Remove it if and only if it is provably that stray: (a) exists, (b) is NOT the
52
+ * canonical store path, and (c) is empty (0 bytes). The size gate is the safety belt — we
53
+ * never unlink a non-empty file, so a real DB that somehow landed here is left untouched
54
+ * and surfaces as data rather than being silently deleted. Best-effort: any error is
55
+ * swallowed (with one diagnostic warn), never blocking store init.
56
+ */
57
+ function cleanupStrayRootRuntimeDb(canonicalPath: string): void {
58
+ try {
59
+ const strayPath = join(getConfigDir(), 'mesh-runtime.db');
60
+ if (strayPath === canonicalPath) return; // canonical dir IS the config dir — never touch
61
+ if (!existsSync(strayPath)) return;
62
+ if (statSync(strayPath).size !== 0) return; // non-empty → not the known 0-byte stray; leave it
63
+ unlinkSync(strayPath);
64
+ if (!loggedStrayCleanup) {
65
+ loggedStrayCleanup = true;
66
+ LOG.info('MeshRuntimeStore', `Removed stray 0-byte root mesh-runtime.db at ${strayPath}`);
67
+ }
68
+ } catch (err: any) {
69
+ if (!loggedStrayCleanup) {
70
+ loggedStrayCleanup = true;
71
+ LOG.warn('MeshRuntimeStore', `Stray root mesh-runtime.db cleanup failed (ignored): ${err?.message || err}`);
72
+ }
73
+ }
74
+ }
42
75
 
43
76
  function meshRuntimeStorePath(): string {
44
77
  const dir = getLedgerDir();
45
78
  const nextPath = join(dir, 'mesh-runtime.db');
79
+ cleanupStrayRootRuntimeDb(nextPath);
46
80
  if (existsSync(nextPath)) return nextPath;
47
81
 
48
82
  const legacyPath = join(dir, 'beads.db');
@@ -459,6 +493,17 @@ export class MeshRuntimeStore {
459
493
  ON mesh_pending_events(mesh_id, event_id)
460
494
  WHERE event_id IS NOT NULL
461
495
  `);
496
+
497
+ // 5. MESH-COMPLEXITY-AUDIT Part 8-1: drop the legacy mesh_direct_delivered_events
498
+ // table. It backed the retired R3 "direct-delivered" dedup marker
499
+ // (markMeshCoordinatorEventDirectDelivered / wasDirectDeliveredToCoordinator,
500
+ // removed when spontaneous PTY direct-inject was retired — see the NOTE in
501
+ // mesh-events-pending.ts). No live code CREATEs, reads, or writes it anymore,
502
+ // so this is a pure runtime-residue cleanup with no behavior change: a store
503
+ // that never had the table just no-ops (IF EXISTS), an old install carrying
504
+ // the dormant table has it removed once. Idempotent — DROP TABLE IF EXISTS is
505
+ // a no-op on every subsequent boot.
506
+ this.db.exec(`DROP TABLE IF EXISTS mesh_direct_delivered_events`);
462
507
  } catch (err: any) {
463
508
  // Best-effort: a failed isolation migration must not brick the store. The
464
509
  // CREATE-TABLE definitions above already carry the new schema for fresh DBs;
@@ -0,0 +1,53 @@
1
+ // Provider-effect dedup and message-formatting helpers extracted from
2
+ // cli-provider-instance.ts. Pure move — no behavior change. None of these used
3
+ // any instance state; they were private methods invoked only within the class.
4
+
5
+ export function getEffectDedupKey(effect: { id?: string; type: string; message?: { content?: unknown }; toast?: { message?: string }; notification?: { title?: string; body?: string } }): string {
6
+ if (effect.id) return `provider_effect:${effect.id}`;
7
+ if (effect.type === 'message') {
8
+ const content = typeof effect.message?.content === 'string'
9
+ ? effect.message.content
10
+ : JSON.stringify(effect.message?.content || '');
11
+ return `provider_effect:message:${content}`;
12
+ }
13
+ if (effect.type === 'notification') {
14
+ return `provider_effect:notification:${effect.notification?.title || ''}:${effect.notification?.body || ''}`;
15
+ }
16
+ return `provider_effect:toast:${effect.toast?.message || ''}`;
17
+ }
18
+
19
+ export function getPersistedEffectContent(effect: { type: string; message?: { content?: unknown }; toast?: { message?: string }; notification?: { title?: string; body?: string; bubbleContent?: unknown } }): string | null {
20
+ if (effect.type === 'message') {
21
+ return typeof effect.message?.content === 'string'
22
+ ? effect.message.content
23
+ : JSON.stringify(effect.message?.content || '');
24
+ }
25
+ if (effect.type === 'toast') {
26
+ return effect.toast?.message || null;
27
+ }
28
+ if (effect.type === 'notification') {
29
+ if (typeof effect.notification?.bubbleContent === 'string') return effect.notification.bubbleContent;
30
+ if (typeof effect.notification?.title === 'string' && effect.notification.title.trim()) {
31
+ return `${effect.notification.title}\n${effect.notification.body || ''}`.trim();
32
+ }
33
+ return effect.notification?.body || null;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ export function formatApprovalRequestMessage(modalMessage?: string, buttons?: string[]): string {
39
+ const lines = ['Approval requested'];
40
+ const cleanMessage = String(modalMessage || '').trim();
41
+ if (cleanMessage) lines.push(cleanMessage);
42
+ const labels = (buttons || []).map((button) => String(button || '').trim()).filter(Boolean);
43
+ if (labels.length > 0) {
44
+ lines.push(labels.map((label) => `[${label}]`).join(' '));
45
+ }
46
+ return lines.join('\n');
47
+ }
48
+
49
+ export function formatMarkerTimestamp(timestamp: number): string {
50
+ const date = new Date(timestamp);
51
+ const pad = (value: number) => String(value).padStart(2, '0');
52
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
53
+ }
@@ -0,0 +1,131 @@
1
+ // Module-level types and constants extracted from cli-provider-instance.ts.
2
+ // Pure move — no behavior change. These were module-private in the original
3
+ // file; they are re-imported by cli-provider-instance.ts and used identically.
4
+
5
+ // Status snapshots only ever surface the newest messages: the cloud 'live'
6
+ // profile drops chat messages entirely (loaded lazily via read_chat on
7
+ // subscribe) and the 'full' profile caps activeChat.messages to the last 60
8
+ // (see status/normalize.ts). Unread/completion markers walk only the tail.
9
+ // So getState()'s saved-history hydration — which runs once per resume/manual
10
+ // CLI session on every status report — must read only a bounded tail, not the
11
+ // entire transcript. A full MAX_SAFE_INTEGER read here makes the initial
12
+ // status report O(transcript) × N(sessions), which is the real cold first-
13
+ // connection bottleneck on chat-heavy machines. The window comfortably exceeds
14
+ // the 60-message snapshot cap so dedup/collapse at the boundary stays stable.
15
+ export const STATUS_HYDRATION_TAIL_LIMIT = 200;
16
+
17
+ export type CompletedDebouncePending = {
18
+ chatTitle: string;
19
+ duration: number;
20
+ timestamp: number;
21
+ firstObservedAt: number;
22
+ previousStatus: string;
23
+ loggedBlockReason?: string;
24
+ loggedTranscriptProbe?: boolean;
25
+ transcriptProbeHistory?: ExternalTranscriptProbe[];
26
+ // ARCH-REFACTOR R1: the taskId of the turn that produced this (debounced) completion,
27
+ // captured SYNCHRONOUSLY at the generating→idle transition. The actual completion
28
+ // event is emitted later by the debounce flush, by which point a follow-up task may
29
+ // already have started its own turn and overwritten engine.currentTurnTaskId — so the
30
+ // id must be snapshotted here, not re-read at flush time.
31
+ taskId?: string;
32
+ // NOTIF Defect-B: the wall-clock start of the turn that produced this (debounced)
33
+ // completion, snapshotted SYNCHRONOUSLY at the generating→idle transition (same
34
+ // reason as taskId — a follow-up turn moves engine.currentTurnStartedAt). The
35
+ // completion's finalSummary is turn-scoped to bubbles at/after this instant so a
36
+ // debounce that flushes before the producing turn's final assistant bubble lands
37
+ // in the native transcript never echoes the PRIOR task's last bubble.
38
+ turnStartedAt?: number;
39
+ // FALSE-IDLE continuity: the busyEpoch value at the instant this pending was armed.
40
+ // The flush guard requires this.busyEpoch to still equal this — proving no busy
41
+ // phase (generating/waiting_approval) opened since arming. A momentary busy→idle
42
+ // blip in an inter-approval valley bumps busyEpoch, so a completion armed before
43
+ // the blip is cancelled at flush instead of emitting a stale mid-turn summary.
44
+ busyEpochAtArm?: number;
45
+ // FALSE-IDLE continuity: the adapter's raw PTY lastOutputAt at arm time. New PTY
46
+ // output after arming means the session was not continuously idle through the
47
+ // settle window (the agent kept printing), so the completion is cancelled.
48
+ lastOutputAtArm?: number;
49
+ };
50
+
51
+ export type CompletedFinalizationBlock = {
52
+ reason: string;
53
+ terminal?: boolean;
54
+ allowTimeout?: boolean;
55
+ // (SETTLE-VALLEY) When set, suppress the CANON-C decoupled-immediate emit for this
56
+ // missing_final_assistant block and HOLD (retry up to COMPLETED_FINALIZATION_MAX_WAIT_MS)
57
+ // until the native transcript's final assistant turn arrives (block clears → genuine emit)
58
+ // or the worker resumes (resume guard cancels). Set only for the inter-approval idle valley
59
+ // of a native-history mesh worker, where an immediate weak emit would freeze a truncated
60
+ // preamble summary (evidenceLevel=insufficient) into the append-only ledger before the
61
+ // worker's next approval turn resumes. Independent of valley length.
62
+ holdForTranscript?: boolean;
63
+ };
64
+
65
+ export type CompletionFinalAssistantEvidence = {
66
+ present: boolean;
67
+ messages: unknown[];
68
+ source: 'parsed' | 'external-native' | 'unavailable';
69
+ };
70
+
71
+ export type ExternalTranscriptProbe = {
72
+ readAt: number;
73
+ msgCount: number;
74
+ lastRole: string | null;
75
+ lastKind: string | null;
76
+ contentLen: number;
77
+ sourcePath: string | null;
78
+ sourceMtimeMs: number | null;
79
+ mtimeAgeMs: number | null;
80
+ };
81
+
82
+ export const COMPLETED_FINALIZATION_RETRY_MS = 1000;
83
+ export const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
84
+ // (FALSEIDLE-BGCHILD-a) Minimum generating→idle settle window for native-history mesh worker
85
+ // sessions. Native-history providers (e.g. claude-cli) normally flush the completion with
86
+ // flushDelay=0 — the transcript is authoritative, so there is no reason to wait. But a worker
87
+ // turn that spawns a BACKGROUND child (e.g. `npm test &`, a backgrounded Bash tool) can paint
88
+ // a burst of child output, fall quiet, and have the screen parser read a PRIOR/intermediate
89
+ // standard assistant as if the turn were done — firing a false idle while the agent is in fact
90
+ // still generating (e.g. mid-commit). With flushDelay=0 there is no window for the resume guard
91
+ // in flushCompletedDebounceIfFinalized (latestVisibleStatus !== 'idle' → cancel) to observe the
92
+ // agent picking the turn back up. A short non-zero settle window restores that resume guard for
93
+ // mesh workers without delaying genuinely-finished turns beyond this bound. Scoped to mesh
94
+ // worker sessions so interactive native-history sessions keep the immediate flush.
95
+ // 4000ms (was 1500): live measurement showed the completion event can fire 1.6–3s
96
+ // BEFORE the worker's final-assistant turn lands in the transcript on a natural
97
+ // generating→idle completion (no approval modal), freezing a prior intermediate
98
+ // bubble as finalSummary (evidenceLevel=insufficient). The 68a3c324 waiting_approval
99
+ // hold only covers the approval-resolved valley; widening this settle window to 4000ms
100
+ // covers that race AND the ~3s waiting_approval valley within the settle bound.
101
+ export const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
102
+ // TASKBUBBLE-DUP: window during which an identical user-input ack (same trimmed
103
+ // content on the same instance) is treated as a redelivery of one dispatch and
104
+ // suppressed from the chat transcript. Matches the coordinator-side
105
+ // DUPLICATE_DISPATCH_WINDOW_MS (mesh-tools) so the daemon's bubble-level guard
106
+ // covers the same retry horizon as the MCP-level dispatch dedup.
107
+ export const USER_INPUT_ACK_DEDUP_WINDOW_MS = 60_000;
108
+ // GENERATING-BOUNDARY (R4c): window — measured from the startup-grace COLLAPSE moment
109
+ // (startupGraceCollapseAt), not boot — inside which a "first turn that ran+completed
110
+ // without the FSM ever observing a 'generating' frame" is attributed to the startup-grace
111
+ // collapse and synthesized (reason 'startup_grace_idle_turn_collapse').
112
+ // The spec's startup-grace exit is elapsed_ms=8000, so the FSM spends ~8s in 'starting'
113
+ // before collapsing to idle; a turn can be dispatched a few seconds AFTER that collapse
114
+ // (the live R4b miss: collapse at boot+8s, dispatch at boot+12.4s — already past a 12s
115
+ // boot-anchored window before the turn even started). Anchoring on the collapse moment
116
+ // covers dispatch-delay; R4d additionally anchors on the turn-START moment
117
+ // (engine.currentTurnStartedAt) so a non-trivial turn-DURATION cannot push the completion
118
+ // past a now-anchored window (the live rc.405 Probe2 miss). The strong discriminator is
119
+ // generatingStartedAt===0 (generating was never observed) AND a started-but-finished turn
120
+ // — the window only keeps the synthesized reason honest and scopes the synthesis to the
121
+ // boot collapse, so a much-later unobservably-fast turn is not mislabelled a startup collapse.
122
+ export const STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS = 12_000;
123
+
124
+ /** Events that signal a dispatched mesh task has reached a terminal state.
125
+ * Detach the mesh assignment after emitting one of these so the worker's
126
+ * next unrelated turn doesn't impersonate another completion. */
127
+ export const TERMINAL_MESH_EVENTS = new Set([
128
+ 'agent:generating_completed',
129
+ 'agent:stopped',
130
+ 'agent:ready',
131
+ ]);