@adhdev/daemon-core 1.0.8 → 1.0.10-rc.1

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.
@@ -122,6 +122,8 @@ export declare function getMeshV2DrainCounters(): Readonly<typeof meshV2DrainCou
122
122
  export declare function __resetMeshV2DrainCountersForTests(): void;
123
123
  /** Test helper: clear the one-shot WARN dedup set. */
124
124
  export declare function __resetMeshV2WarnDedupForTests(): void;
125
+ /** Test helper: reset the one-shot SQLite-drain-failure WARN guard. */
126
+ export declare function __resetSqlitePendingDrainWarnForTests(): void;
125
127
  export declare function readRefineJobId(event: {
126
128
  metadataEvent?: Record<string, unknown>;
127
129
  } | Record<string, unknown>): string;
@@ -304,6 +304,27 @@ export interface RepoMeshPolicy {
304
304
  * Set to false to suppress the reminder entirely.
305
305
  */
306
306
  idleActiveMissionReminder?: boolean;
307
+ /**
308
+ * Whether a coordinator-dispatched worker's routine idle/completion push
309
+ * notification is delivered to the owner every time ('always', the default —
310
+ * historical behavior, byte-for-byte unchanged), or auto-silenced for the single
311
+ * completion that follows a coordinator dispatch ('auto_silent_on_dispatch').
312
+ *
313
+ * When 'auto_silent_on_dispatch', dispatching a task to a worker arms a ONE-SHOT
314
+ * transient mute (settings.silentNextIdlePush) on that worker session. The next
315
+ * agent:generating_completed rides a muted status snapshot, so the server push
316
+ * gate (which already honors session.muted) suppresses ONLY that routine
317
+ * completion push; the flag auto-clears afterward so subsequent turns notify
318
+ * normally, and it is status-gated to `idle` so an approval-needed / long-running
319
+ * / failure notification in the SAME turn is NEVER suppressed. A stale one-shot
320
+ * self-expires (SILENT_IDLE_PUSH_TTL_MS) so a worker that never completes cannot
321
+ * strand its session permanently muted.
322
+ *
323
+ * Coordinator-spawned HIDDEN workers already default muted (they emit no owner
324
+ * push), so this only changes behavior for VISIBLE / manually-unmuted delegated
325
+ * workers. Defaults to 'always' — zero behavior change unless the mesh opts in.
326
+ */
327
+ coordinatorIdlePushPolicy?: 'always' | 'auto_silent_on_dispatch';
307
328
  }
308
329
  export interface RepoMeshRelatedRepo {
309
330
  /** Stable display label for an explicitly configured associated checkout. */
@@ -413,6 +434,21 @@ export interface RepoMeshNodePolicy {
413
434
  initSubmodulesOnClone?: boolean;
414
435
  }
415
436
  export declare const DEFAULT_MESH_POLICY: RepoMeshPolicy;
437
+ /**
438
+ * TTL for the one-shot silent-idle-push arm (settings.silentNextIdlePushArmedAt).
439
+ * A dispatched worker whose completion never arrives (crash, offline, dropped event)
440
+ * must not leave its session muted forever — after this window the arm is treated as
441
+ * expired and stops muting, so the session self-heals to normal notification behavior.
442
+ * Ten minutes comfortably covers a long delegated turn while still bounding the leak.
443
+ */
444
+ export declare const SILENT_IDLE_PUSH_TTL_MS: number;
445
+ /**
446
+ * Resolve the effective coordinator idle-push policy from a mesh policy, defaulting
447
+ * to 'always' (notify) for a missing/invalid value so a typo can never silently
448
+ * disable owner completion notifications. Mirrors the other policy resolvers so the
449
+ * arm site and any future consumer read one source of truth.
450
+ */
451
+ export declare function resolveCoordinatorIdlePushPolicy(meshPolicy?: Pick<RepoMeshPolicy, 'coordinatorIdlePushPolicy'> | null): 'always' | 'auto_silent_on_dispatch';
416
452
  /**
417
453
  * Resolve a magiSessionCleanup policy value (string mode, boolean shorthand,
418
454
  * or unset) to a canonical mode. Unset → the default ('stop_and_delete', ON).
@@ -29,10 +29,19 @@ export declare function isCoordinatorSpawnedHiddenWorker(settings: Record<string
29
29
  export declare function resolveSurfaceHidden(settings: Record<string, any> | undefined): boolean;
30
30
  /**
31
31
  * A session is muted (attention side-effects suppressed, but still shown in the
32
- * list) when the user muted it, OR a coordinator-spawned worker defaults muted.
33
- * userMuted === false is an explicit un-mute overriding the worker default.
32
+ * list) when the user muted it, OR a coordinator-spawned worker defaults muted, OR
33
+ * a one-shot silent-idle-push arm is active for this completion snapshot (see
34
+ * isSilentIdlePushArmActive — status-gated + TTL-bounded so approval/failure pushes
35
+ * and never-completing workers are unaffected). userMuted === false is an explicit
36
+ * un-mute overriding the worker/policy default — but NOT the one-shot arm, which is a
37
+ * coordinator-driven per-completion decision that a stale manual un-mute must not
38
+ * defeat.
39
+ *
40
+ * `status` is the session's resolved status for the snapshot being built; omit it
41
+ * (undefined) for callers that only have mesh-attribution fields (the cloud mirror),
42
+ * where the one-shot never applies.
34
43
  */
35
- export declare function resolveMuted(settings: Record<string, any> | undefined): boolean;
44
+ export declare function resolveMuted(settings: Record<string, any> | undefined, status?: string): boolean;
36
45
  /**
37
46
  * Pure resolver for a mesh-spawned worker session's dashboard hide+mute state,
38
47
  * shared by the standalone/local `buildSessionEntries` path (via the wrappers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.8",
3
+ "version": "1.0.10-rc.1",
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": "*",
51
- "@adhdev/session-host-core": "*",
50
+ "@adhdev/mesh-shared": "1.0.10-rc.1",
51
+ "@adhdev/session-host-core": "1.0.10-rc.1",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -1739,6 +1739,26 @@ export class DaemonCliManager {
1739
1739
  if (stampResult && stampResult.stamped === false && stampResult.reason === 'task_already_stamped_on_live_instance') {
1740
1740
  throw new Error(`Refusing duplicate mesh dispatch: task ${meshContext.taskId} is already being worked by a live session on this daemon`);
1741
1741
  }
1742
+ // COORDINATOR-SILENT-IDLE (opt-in): the coordinator's mesh policy is
1743
+ // 'auto_silent_on_dispatch', so arm a ONE-SHOT transient mute on THIS
1744
+ // worker session for the single completion that follows this dispatch.
1745
+ // resolveMuted honors it only for an idle snapshot within
1746
+ // SILENT_IDLE_PUSH_TTL_MS, so the routine completion push is suppressed
1747
+ // while approval/failure/long-running notifications (non-idle status) and
1748
+ // a worker that never completes (TTL expiry) are unaffected. Re-armed on
1749
+ // every dispatch (fresh armedAt) and one-shot-cleared at the completion
1750
+ // emission (emitGeneratingCompleted) so subsequent turns notify normally.
1751
+ if ((meshContext as any).silentIdlePush === true) {
1752
+ try {
1753
+ const workerInst = this.deps.getInstanceManager()?.getInstance(targetInstanceId);
1754
+ if (workerInst && typeof workerInst.updateSettings === 'function') {
1755
+ workerInst.updateSettings({
1756
+ silentNextIdlePush: true,
1757
+ silentNextIdlePushArmedAt: Date.now(),
1758
+ });
1759
+ }
1760
+ } catch { /* best-effort — silent-idle is a notification nicety, never fail the dispatch */ }
1761
+ }
1742
1762
  }
1743
1763
  const input = normalizeInputEnvelope(args?.input ? { input: args.input } : args);
1744
1764
  const provider = this.providerLoader.resolve(agentType) || this.providerLoader.getMeta(agentType);
@@ -724,6 +724,10 @@ function buildPolicySection(policy: RepoMeshPolicy): string {
724
724
 
725
725
  rules.push(`- Maximum **${policy.maxParallelTasks}** tasks running in parallel`);
726
726
 
727
+ if (policy.coordinatorIdlePushPolicy === 'auto_silent_on_dispatch') {
728
+ rules.push('- Delegated-worker completions are **auto-silenced**: the routine idle/completion push for a task you dispatch is suppressed once (approval-needed, failure, and long-running alerts still notify the owner normally)');
729
+ }
730
+
727
731
  return `## Policy\n${rules.join('\n')}`;
728
732
  }
729
733
 
@@ -272,6 +272,22 @@ export function __resetMeshV2WarnDedupForTests(): void {
272
272
  warnedV2Violations.clear();
273
273
  }
274
274
 
275
+ // Log-once guard for the SQLite pending-event drain failure. When better-sqlite3 is
276
+ // unavailable (native load failure on a clean npx install with no build tools), the
277
+ // SQLite drain throws every drain call — once per mesh, every reconcile tick (~4s) —
278
+ // while the JSONL fallback below keeps delivering events correctly. That is the
279
+ // intended degraded path, so the operator only needs to be told ONCE that SQLite is
280
+ // down; repeating the WARN every cycle floods the logs (mirrors MeshRuntimeStore's
281
+ // loggedGetInstanceFailure guard). New/different drain errors still surface: the
282
+ // guard keys on nothing beyond "already warned", but the first occurrence is always
283
+ // shown at WARN and every occurrence stays at debug.
284
+ let loggedSqlitePendingDrainFailure = false;
285
+
286
+ /** Test helper: reset the one-shot SQLite-drain-failure WARN guard. */
287
+ export function __resetSqlitePendingDrainWarnForTests(): void {
288
+ loggedSqlitePendingDrainFailure = false;
289
+ }
290
+
275
291
  /**
276
292
  * Resolve the drainer's CoordinatorIdentity for v2 routing from the daemon-id
277
293
  * argument the (untouchable) reconcile-loop already passes. The daemon ids are the
@@ -1216,7 +1232,16 @@ export function drainPendingMeshCoordinatorEvents(
1216
1232
  // failure here means the JSONL copy is emptied while the SQLite rows
1217
1233
  // survive undrained, so the next drain re-delivers the same events to the
1218
1234
  // coordinator (duplicate refine:completed etc.) with no diagnostic trail.
1219
- LOG.warn('MeshEvents', `SQLite pending-event drain failed for mesh ${meshId}; JSONL fallback only: ${e?.message || e}`);
1235
+ // Log-once (then debug): when better-sqlite3 is unavailable this throws every
1236
+ // tick × mesh count, and the JSONL fallback below is the intended degraded
1237
+ // path — so warn ONCE, then keep the per-cycle repetition at debug to stop the
1238
+ // log flood without hiding the diagnosis (see loggedSqlitePendingDrainFailure).
1239
+ if (!loggedSqlitePendingDrainFailure) {
1240
+ loggedSqlitePendingDrainFailure = true;
1241
+ LOG.warn('MeshEvents', `SQLite pending-event drain failed for mesh ${meshId}; JSONL fallback only (further occurrences at debug): ${e?.message || e}`);
1242
+ } else {
1243
+ LOG.debug('MeshEvents', `SQLite pending-event drain failed for mesh ${meshId}; JSONL fallback only: ${e?.message || e}`);
1244
+ }
1220
1245
  }
1221
1246
 
1222
1247
  // JSONL (legacy / migration path) — always drained alongside SQLite.
@@ -13,7 +13,7 @@ import { createSessionDelivery, updateSessionDeliveryStatus } from './mesh-deliv
13
13
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
14
14
  import { traceMeshEventDrop } from './mesh-event-trace.js';
15
15
  import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
16
- import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks } from '../repo-mesh-types.js';
16
+ import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks, resolveCoordinatorIdlePushPolicy } from '../repo-mesh-types.js';
17
17
  import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
18
18
  import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, expandDaemonIdForms, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, withStatusProbeMarker, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
19
19
  import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
@@ -612,6 +612,17 @@ export function tryAssignQueueTask(
612
612
  // failure / cancel / reclaim).
613
613
  beginTaskDispatchInFlight(meshId, task.id);
614
614
 
615
+ // COORDINATOR-SILENT-IDLE (opt-in): when the mesh policy is
616
+ // 'auto_silent_on_dispatch', carry a one-shot silent-idle-push signal in the
617
+ // dispatch meshContext. The worker stamps settings.silentNextIdlePush on its own
618
+ // live session (cli-manager send_chat), so the SINGLE completion that follows this
619
+ // dispatch rides a muted status snapshot and the server suppresses ONLY that
620
+ // routine idle push. Status-gated + TTL-bounded downstream (see resolveMuted), so
621
+ // approval/failure/long-running notifications and never-completing workers are
622
+ // unaffected. Default 'always' → this stays undefined and nothing changes.
623
+ const silentIdlePushOnDispatch =
624
+ resolveCoordinatorIdlePushPolicy(mesh?.policy) === 'auto_silent_on_dispatch';
625
+
615
626
  // CANON-IDENTITY: read the remote daemon id through the normalizing helper so a node
616
627
  // whose daemonId arrives in a non-top-level-camelCase serialization form (daemon_id /
617
628
  // machine.daemonId / lastProbe.machine.daemon_id / …) is still recognized as remote.
@@ -648,6 +659,7 @@ export function tryAssignQueueTask(
648
659
  ...(typeof task.dispatchNonce === 'number' ? { dispatchNonce: task.dispatchNonce } : {}),
649
660
  ...(localDaemonIdForDispatch ? { coordinatorDaemonId: localDaemonIdForDispatch } : {}),
650
661
  ...(sourceCoordinatorSessionId ? { coordinatorSessionId: sourceCoordinatorSessionId } : {}),
662
+ ...(silentIdlePushOnDispatch ? { silentIdlePush: true } : {}),
651
663
  },
652
664
  }),
653
665
  {
@@ -730,6 +742,7 @@ export function tryAssignQueueTask(
730
742
  ...(typeof task.dispatchNonce === 'number' ? { dispatchNonce: task.dispatchNonce } : {}),
731
743
  ...(localCoordinatorDaemonId() ? { coordinatorDaemonId: localCoordinatorDaemonId() } : {}),
732
744
  ...(readNonEmptyString(task.sourceCoordinatorSessionId) ? { coordinatorSessionId: readNonEmptyString(task.sourceCoordinatorSessionId) } : {}),
745
+ ...(silentIdlePushOnDispatch ? { silentIdlePush: true } : {}),
733
746
  },
734
747
  }),
735
748
  {
@@ -1277,18 +1277,25 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
1277
1277
  // indexed status column, so an idle mesh costs one cheap query per tick.
1278
1278
  // claimNextQueueTask is atomic, so racing the event-driven path can only have
1279
1279
  // one winner; double-claiming is impossible.
1280
- for (const mesh of listMeshes()) {
1281
- const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
1282
- if (!daemonHostsMesh(mesh, selfIds)) continue;
1283
- if (store) {
1280
+ // The mesh work-queue is SQLite-only (claimNextQueueTask/countQueueStatus all go
1281
+ // through MeshRuntimeStore — there is no JSONL fallback for the QUEUE, only for
1282
+ // pending EVENTS drained in PHASE 1/2). So when SQLite is unavailable (store
1283
+ // undefined, e.g. better-sqlite3 native load failure on a clean install),
1284
+ // triggerMeshQueue can do no useful work — it would only re-throw inside the
1285
+ // store and emit the WARN below every tick × mesh count, flooding the logs. Skip
1286
+ // the phase entirely in that case; the JSONL event-delivery path is unaffected.
1287
+ if (store) {
1288
+ for (const mesh of listMeshes()) {
1289
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
1290
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
1284
1291
  try {
1285
1292
  if (store.pendingQueueTaskCount(mesh.id) === 0) continue;
1286
1293
  } catch { /* fall through and let triggerMeshQueue decide */ }
1287
- }
1288
- try {
1289
- await triggerMeshQueue(components, mesh.id);
1290
- } catch (e: any) {
1291
- LOG.warn('MeshReconcile', `Pending-claim recovery trigger failed for mesh ${mesh.id}: ${e?.message || e}`);
1294
+ try {
1295
+ await triggerMeshQueue(components, mesh.id);
1296
+ } catch (e: any) {
1297
+ LOG.warn('MeshReconcile', `Pending-claim recovery trigger failed for mesh ${mesh.id}: ${e?.message || e}`);
1298
+ }
1292
1299
  }
1293
1300
  }
1294
1301
 
@@ -2881,6 +2881,15 @@ export class CliProviderInstance implements ProviderInstance {
2881
2881
  ...(opts.evidenceLevel !== undefined ? { evidenceLevel: opts.evidenceLevel } : {}),
2882
2882
  ...(opts.completionDiagnostic !== undefined ? { completionDiagnostic: opts.completionDiagnostic } : {}),
2883
2883
  });
2884
+ // COORDINATOR-SILENT-IDLE one-shot consume: this completion's snapshot rides the
2885
+ // armed mute (resolveMuted honors settings.silentNextIdlePush for the idle status
2886
+ // above), so the routine idle push is suppressed for THIS completion only. Clear
2887
+ // the arm now — AFTER the completion event was pushed — so the NEXT turn notifies
2888
+ // normally. Redundant with the TTL leak-guard, but the deterministic clear is the
2889
+ // primary one-shot mechanism; the TTL only covers a worker that never completes.
2890
+ if (this.settings?.silentNextIdlePush === true) {
2891
+ this.updateSettings({ silentNextIdlePush: undefined, silentNextIdlePushArmedAt: undefined });
2892
+ }
2884
2893
  }
2885
2894
 
2886
2895
  /**
@@ -399,6 +399,27 @@ export interface RepoMeshPolicy {
399
399
  * Set to false to suppress the reminder entirely.
400
400
  */
401
401
  idleActiveMissionReminder?: boolean;
402
+ /**
403
+ * Whether a coordinator-dispatched worker's routine idle/completion push
404
+ * notification is delivered to the owner every time ('always', the default —
405
+ * historical behavior, byte-for-byte unchanged), or auto-silenced for the single
406
+ * completion that follows a coordinator dispatch ('auto_silent_on_dispatch').
407
+ *
408
+ * When 'auto_silent_on_dispatch', dispatching a task to a worker arms a ONE-SHOT
409
+ * transient mute (settings.silentNextIdlePush) on that worker session. The next
410
+ * agent:generating_completed rides a muted status snapshot, so the server push
411
+ * gate (which already honors session.muted) suppresses ONLY that routine
412
+ * completion push; the flag auto-clears afterward so subsequent turns notify
413
+ * normally, and it is status-gated to `idle` so an approval-needed / long-running
414
+ * / failure notification in the SAME turn is NEVER suppressed. A stale one-shot
415
+ * self-expires (SILENT_IDLE_PUSH_TTL_MS) so a worker that never completes cannot
416
+ * strand its session permanently muted.
417
+ *
418
+ * Coordinator-spawned HIDDEN workers already default muted (they emit no owner
419
+ * push), so this only changes behavior for VISIBLE / manually-unmuted delegated
420
+ * workers. Defaults to 'always' — zero behavior change unless the mesh opts in.
421
+ */
422
+ coordinatorIdlePushPolicy?: 'always' | 'auto_silent_on_dispatch';
402
423
  }
403
424
 
404
425
  export interface RepoMeshRelatedRepo {
@@ -540,8 +561,36 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
540
561
  // Nudge the coordinator when the mesh is fully idle but active missions linger,
541
562
  // so a mission is never left drifting in `active` after its work is really done.
542
563
  idleActiveMissionReminder: true,
564
+ // Conservative default: every coordinator-dispatched worker completion still
565
+ // notifies the owner. Opt into 'auto_silent_on_dispatch' to one-shot-silence the
566
+ // routine idle push for a coordinator-driven task (approval/failure notifications
567
+ // are never affected — see coordinatorIdlePushPolicy).
568
+ coordinatorIdlePushPolicy: 'always',
543
569
  };
544
570
 
571
+ /**
572
+ * TTL for the one-shot silent-idle-push arm (settings.silentNextIdlePushArmedAt).
573
+ * A dispatched worker whose completion never arrives (crash, offline, dropped event)
574
+ * must not leave its session muted forever — after this window the arm is treated as
575
+ * expired and stops muting, so the session self-heals to normal notification behavior.
576
+ * Ten minutes comfortably covers a long delegated turn while still bounding the leak.
577
+ */
578
+ export const SILENT_IDLE_PUSH_TTL_MS = 10 * 60 * 1000;
579
+
580
+ /**
581
+ * Resolve the effective coordinator idle-push policy from a mesh policy, defaulting
582
+ * to 'always' (notify) for a missing/invalid value so a typo can never silently
583
+ * disable owner completion notifications. Mirrors the other policy resolvers so the
584
+ * arm site and any future consumer read one source of truth.
585
+ */
586
+ export function resolveCoordinatorIdlePushPolicy(
587
+ meshPolicy?: Pick<RepoMeshPolicy, 'coordinatorIdlePushPolicy'> | null,
588
+ ): 'always' | 'auto_silent_on_dispatch' {
589
+ return meshPolicy?.coordinatorIdlePushPolicy === 'auto_silent_on_dispatch'
590
+ ? 'auto_silent_on_dispatch'
591
+ : 'always';
592
+ }
593
+
545
594
  // ─── Policy normalization (single source of truth) ──────────────────────────
546
595
  //
547
596
  // Every mesh policy passes through mergeAndNormalizePolicy exactly once on write
@@ -727,6 +776,15 @@ export function mergeAndNormalizePolicy(
727
776
  } else {
728
777
  delete policy.autoConvergeCodeChange;
729
778
  }
779
+ // Coordinator idle-push policy: strict opt-in. Only persist the explicit
780
+ // 'auto_silent_on_dispatch' value; any other/invalid value normalizes to the
781
+ // 'always' default and is dropped so existing meshes.json stays byte-for-byte
782
+ // untouched (a typo cannot silently disable owner completion notifications).
783
+ if (policy.coordinatorIdlePushPolicy === 'auto_silent_on_dispatch') {
784
+ policy.coordinatorIdlePushPolicy = 'auto_silent_on_dispatch';
785
+ } else {
786
+ delete policy.coordinatorIdlePushPolicy;
787
+ }
730
788
  return policy;
731
789
  }
732
790
 
@@ -25,6 +25,7 @@ import {
25
25
  type NormalizeActiveChatOptions,
26
26
  } from './normalize.js';
27
27
  import { getMeshQueueStats } from '../mesh/mesh-work-queue.js';
28
+ import { SILENT_IDLE_PUSH_TTL_MS } from '../repo-mesh-types.js';
28
29
  import { getCoordinatorForSession } from '../mesh/coordinator-registry.js';
29
30
  import { normalizeProviderStateControlValues } from '../providers/provider-patch-state.js';
30
31
  import { normalizeProviderSummaryMetadata } from '../providers/summary-metadata.js';
@@ -63,13 +64,51 @@ export function resolveSurfaceHidden(settings: Record<string, any> | undefined):
63
64
  return settings.spawnedSessionVisibility === 'hidden' || isCoordinatorSpawnedHiddenWorker(settings);
64
65
  }
65
66
 
67
+ /**
68
+ * Whether a one-shot silent-idle-push arm is currently ACTIVE for this session's
69
+ * completion snapshot. Set on a coordinator-dispatched worker when the mesh policy
70
+ * is `coordinatorIdlePushPolicy: 'auto_silent_on_dispatch'` (see arm sites in
71
+ * mesh-queue-assignment). Guardrails baked in here:
72
+ * - status-gated to `idle`: the arm mutes ONLY the routine completion snapshot, so
73
+ * an approval-needed / failure / long-running notification in the SAME turn (which
74
+ * the worker emits with a non-idle status) is NEVER suppressed.
75
+ * - TTL leak-guard: an arm older than SILENT_IDLE_PUSH_TTL_MS is treated as expired,
76
+ * so a worker whose completion never arrives cannot strand its session muted.
77
+ * The arm itself is one-shot: emitGeneratingCompleted clears it after the completion
78
+ * so the following turn notifies normally.
79
+ */
80
+ function isSilentIdlePushArmActive(
81
+ settings: Record<string, any> | undefined,
82
+ status: string | undefined,
83
+ ): boolean {
84
+ if (!settings || settings.silentNextIdlePush !== true) return false;
85
+ if (status !== 'idle') return false;
86
+ const armedAt = Number(settings.silentNextIdlePushArmedAt);
87
+ if (!Number.isFinite(armedAt) || armedAt <= 0) {
88
+ // No/invalid arm timestamp: honor the flag (fail-safe toward the explicit
89
+ // arm) but it will be one-shot-cleared at the completion emission.
90
+ return true;
91
+ }
92
+ return (Date.now() - armedAt) <= SILENT_IDLE_PUSH_TTL_MS;
93
+ }
94
+
66
95
  /**
67
96
  * A session is muted (attention side-effects suppressed, but still shown in the
68
- * list) when the user muted it, OR a coordinator-spawned worker defaults muted.
69
- * userMuted === false is an explicit un-mute overriding the worker default.
97
+ * list) when the user muted it, OR a coordinator-spawned worker defaults muted, OR
98
+ * a one-shot silent-idle-push arm is active for this completion snapshot (see
99
+ * isSilentIdlePushArmActive — status-gated + TTL-bounded so approval/failure pushes
100
+ * and never-completing workers are unaffected). userMuted === false is an explicit
101
+ * un-mute overriding the worker/policy default — but NOT the one-shot arm, which is a
102
+ * coordinator-driven per-completion decision that a stale manual un-mute must not
103
+ * defeat.
104
+ *
105
+ * `status` is the session's resolved status for the snapshot being built; omit it
106
+ * (undefined) for callers that only have mesh-attribution fields (the cloud mirror),
107
+ * where the one-shot never applies.
70
108
  */
71
- export function resolveMuted(settings: Record<string, any> | undefined): boolean {
109
+ export function resolveMuted(settings: Record<string, any> | undefined, status?: string): boolean {
72
110
  if (!settings) return false;
111
+ if (isSilentIdlePushArmActive(settings, status)) return true;
73
112
  if (settings.userMuted === true) return true;
74
113
  if (settings.userMuted === false) return false;
75
114
  return isCoordinatorSpawnedHiddenWorker(settings);
@@ -365,6 +404,7 @@ function buildCliSession(state: CliProviderState, options: SessionEntryBuildOpti
365
404
  const effectiveMeshId = meshCoordinatorFor || registryEntry?.meshId;
366
405
  const coordinator = effectiveMeshId ? { meshId: effectiveMeshId, role: 'coordinator' as const } : undefined;
367
406
  const meshQueueStats = effectiveMeshId ? getMeshQueueStats(effectiveMeshId) : undefined;
407
+ const resolvedStatus = resolveSessionStatus(activeChat, state.status);
368
408
  return {
369
409
  id: state.instanceId,
370
410
  parentId: null,
@@ -373,7 +413,7 @@ function buildCliSession(state: CliProviderState, options: SessionEntryBuildOpti
373
413
  providerSessionId: state.providerSessionId,
374
414
  kind: 'agent',
375
415
  transport: 'pty',
376
- status: resolveSessionStatus(activeChat, state.status),
416
+ status: resolvedStatus,
377
417
  title: activeChat?.title || state.name,
378
418
  workspace,
379
419
  ...(git && { git }),
@@ -412,7 +452,9 @@ function buildCliSession(state: CliProviderState, options: SessionEntryBuildOpti
412
452
  // `!== undefined` fields, so an absent field on false never overwrote a prior true —
413
453
  // the toggle-off direction silently stuck. See session-entry-merge.ts.
414
454
  surfaceHidden: resolveSurfaceHidden(state.settings),
415
- muted: resolveMuted(state.settings),
455
+ // status-gated so a one-shot silent-idle arm mutes ONLY the idle/completion
456
+ // snapshot, never an approval/generating frame in the same turn.
457
+ muted: resolveMuted(state.settings, resolvedStatus),
416
458
  };
417
459
  }
418
460
 
@@ -430,6 +472,7 @@ function buildAcpSession(state: AcpProviderState, options: SessionEntryBuildOpti
430
472
  const effectiveMeshId = meshCoordinatorFor || registryEntry?.meshId;
431
473
  const coordinator = effectiveMeshId ? { meshId: effectiveMeshId, role: 'coordinator' as const } : undefined;
432
474
  const meshQueueStats = effectiveMeshId ? getMeshQueueStats(effectiveMeshId) : undefined;
475
+ const resolvedStatus = resolveSessionStatus(activeChat, state.status);
433
476
  return {
434
477
  id: state.instanceId,
435
478
  parentId: null,
@@ -437,7 +480,7 @@ function buildAcpSession(state: AcpProviderState, options: SessionEntryBuildOpti
437
480
  providerName: state.name,
438
481
  kind: 'agent',
439
482
  transport: 'acp',
440
- status: resolveSessionStatus(activeChat, state.status),
483
+ status: resolvedStatus,
441
484
  title: activeChat?.title || state.name,
442
485
  workspace,
443
486
  ...(git && { git }),
@@ -457,7 +500,8 @@ function buildAcpSession(state: AcpProviderState, options: SessionEntryBuildOpti
457
500
  // Emit explicitly (including false) so un-hide/un-mute clears a prior true downstream —
458
501
  // see buildCliSession above and session-entry-merge.ts.
459
502
  surfaceHidden: resolveSurfaceHidden(state.settings),
460
- muted: resolveMuted(state.settings),
503
+ // status-gated one-shot silent-idle arm — see buildCliSession above.
504
+ muted: resolveMuted(state.settings, resolvedStatus),
461
505
  };
462
506
  }
463
507