@adhdev/daemon-core 1.0.18-rc.1 → 1.0.18-rc.10

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.
Files changed (36) hide show
  1. package/dist/cli-adapters/cli-state-engine.d.ts +77 -0
  2. package/dist/cli-adapters/pty-transport.d.ts +2 -1
  3. package/dist/commands/process-lifecycle.d.ts +43 -0
  4. package/dist/commands/upgrade-helper.d.ts +7 -0
  5. package/dist/commands/windows-atomic-upgrade.d.ts +63 -0
  6. package/dist/index.js +5818 -4496
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +5853 -4525
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  11. package/dist/mesh/mesh-disk-retention.d.ts +105 -0
  12. package/dist/mesh/mesh-ledger.d.ts +28 -1
  13. package/dist/mesh/mesh-runtime-store.d.ts +11 -0
  14. package/dist/providers/cli-provider-instance-types.d.ts +2 -0
  15. package/dist/providers/cli-provider-instance.d.ts +2 -0
  16. package/package.json +3 -3
  17. package/src/boot/daemon-lifecycle.ts +10 -0
  18. package/src/cli-adapters/cli-state-engine.ts +204 -3
  19. package/src/cli-adapters/provider-cli-adapter.ts +39 -5
  20. package/src/cli-adapters/pty-transport.d.ts +2 -1
  21. package/src/cli-adapters/pty-transport.ts +1 -1
  22. package/src/cli-adapters/session-host-transport.ts +8 -3
  23. package/src/commands/high-family/mesh-coordinator-launch.ts +17 -2
  24. package/src/commands/process-lifecycle.ts +248 -0
  25. package/src/commands/upgrade-helper.ts +146 -79
  26. package/src/commands/windows-atomic-upgrade.ts +631 -0
  27. package/src/config/mesh-json-config.ts +8 -0
  28. package/src/mesh/coordinator-prompt.ts +256 -10
  29. package/src/mesh/mesh-disk-retention.ts +370 -0
  30. package/src/mesh/mesh-ledger.ts +72 -0
  31. package/src/mesh/mesh-queue-assignment.ts +126 -1
  32. package/src/mesh/mesh-reconcile-loop.ts +49 -0
  33. package/src/mesh/mesh-runtime-store.ts +21 -0
  34. package/src/providers/cli-provider-instance-types.ts +25 -0
  35. package/src/providers/cli-provider-instance.ts +116 -0
  36. package/src/session-host/managed-host.ts +34 -0
@@ -88,6 +88,14 @@ export type MeshLedgerKind =
88
88
  // payload: { keys: string[], hasDestructive: boolean, result: 'injected'|'refused'|'error',
89
89
  // refused?: string, submits?: boolean, confirmDestructive?: boolean }
90
90
  | 'key_injection'
91
+ // Disk/worktree retention (mission 86def38d): DETECTION-ONLY signal that a git
92
+ // worktree present on disk has no matching live mesh node — an orphan cleanup
93
+ // candidate. The reconcile loop emits this so the coordinator can decide whether
94
+ // to remove it; retention NEVER auto-deletes a worktree (manual/coordinator-driven).
95
+ // Keyed by worktreePath so a re-emit for the same orphan is idempotent (a prior
96
+ // unresolved entry within the dedupe window suppresses the repeat).
97
+ // payload: { worktreePath, branch?, head?, reason: 'no_matching_live_node', state: 'cleanup_candidate' }
98
+ | 'worktree_cleanup_candidate'
91
99
  ;
92
100
 
93
101
  export interface MeshLedgerEntry {
@@ -317,6 +325,70 @@ export const OPERATING_NOTE_DEDUPE_WINDOW = 40;
317
325
  // a coordinator actually sees while still capping unbounded store growth.
318
326
  export const OPERATING_NOTE_KEEP_LATEST = 100;
319
327
 
328
+ // ─── Operating-note lifecycle: category TTL + expiry (read-side only) ──────────
329
+ // Minimal first cut of the operating-notes lifecycle. Expiry is READ/INJECTION
330
+ // side ONLY — the store prune (keep-latest-100 above) stays purely count-based
331
+ // and NEVER deletes by age, so audit history is preserved. isNoteExpired decides
332
+ // whether an UNPINNED note still rides into a coordinator prompt.
333
+ //
334
+ // Per-category retention (days). A category not listed here — including the
335
+ // uncategorized case — is durable (never expires). provider_quirk is durable
336
+ // because a runtime quirk stays true until the provider changes.
337
+ export const OPERATING_NOTE_CATEGORY_TTL_DAYS: Readonly<Record<string, number>> = {
338
+ recovery_lesson: 14,
339
+ pattern_to_avoid: 30,
340
+ // provider_quirk: durable (intentionally absent → never expires)
341
+ };
342
+
343
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
344
+
345
+ /**
346
+ * Shape isNoteExpired reads. Structural so mesh-ledger stays free of a
347
+ * coordinator-prompt import (CoordinatorOperatingNote satisfies this).
348
+ */
349
+ export interface OperatingNoteExpiryInput {
350
+ category?: string;
351
+ pinned?: boolean;
352
+ createdAt?: string;
353
+ /** Explicit expiry override; wins over the category TTL when parseable. */
354
+ expiresAt?: string;
355
+ /** Fallback creation time (ledger entry timestamp) when createdAt absent. */
356
+ timestamp?: string;
357
+ }
358
+
359
+ /**
360
+ * Pure helper: is this UNPINNED operating note expired as of `now` (epoch ms)?
361
+ *
362
+ * Rules:
363
+ * - pinned notes NEVER expire (always false).
364
+ * - an explicit, parseable `expiresAt` in the past → expired.
365
+ * - otherwise the category TTL applies; a durable category (provider_quirk,
366
+ * uncategorized, or any category not in the TTL map) never expires.
367
+ * - age is measured from createdAt, falling back to `timestamp` (ledger entry
368
+ * time). If neither is a valid date, the note is treated as NOT expired
369
+ * (never silently drop a note we cannot age).
370
+ */
371
+ export function isNoteExpired(note: OperatingNoteExpiryInput, now: number): boolean {
372
+ if (!note || note.pinned) return false;
373
+
374
+ // Explicit expiresAt wins when present and parseable.
375
+ if (typeof note.expiresAt === 'string') {
376
+ const exp = new Date(note.expiresAt).getTime();
377
+ if (!Number.isNaN(exp)) return exp <= now;
378
+ }
379
+
380
+ const ttlDays = note.category ? OPERATING_NOTE_CATEGORY_TTL_DAYS[note.category] : undefined;
381
+ if (typeof ttlDays !== 'number' || !Number.isFinite(ttlDays)) {
382
+ // Durable category (provider_quirk / uncategorized / unknown) → never expires.
383
+ return false;
384
+ }
385
+
386
+ const created = new Date(note.createdAt ?? note.timestamp ?? '').getTime();
387
+ if (Number.isNaN(created)) return false; // cannot age → keep
388
+
389
+ return now - created >= ttlDays * MS_PER_DAY;
390
+ }
391
+
320
392
  // ─── Path Helpers ───────────────────────────────
321
393
 
322
394
  export function getLedgerDir(): string {
@@ -6,7 +6,7 @@ import { getMesh } from '../config/mesh-config.js';
6
6
  import { detectCLI } from '../detection/cli-detector.js';
7
7
  import { LOG } from '../logging/logger.js';
8
8
  import { appendLedgerEntry } from './mesh-ledger.js';
9
- import { buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, claimNextTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, getActiveDirectDispatches, isTaskReadonly, taskDependenciesSatisfied, meshTaskNotBeforeReady, meshTaskPriorityRank } from './mesh-work-queue.js';
9
+ import { buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, claimNextTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, getActiveDirectDispatches, isTaskReadonly, taskDependenciesSatisfied, meshTaskNotBeforeReady, meshTaskPriorityRank, requeueTask } from './mesh-work-queue.js';
10
10
  import type { MeshWorkQueueEntry } from './mesh-work-queue.js';
11
11
  import { fastForwardMeshNode } from './mesh-fast-forward.js';
12
12
  import { createSessionDelivery, updateSessionDeliveryStatus } from './mesh-delivery-policy.js';
@@ -931,6 +931,105 @@ function isTargetNodeTransientlyUnresolved(mesh: any, task: MeshWorkQueueEntry):
931
931
  return isWithinCloneBootstrapGrace(targetNodeId);
932
932
  }
933
933
 
934
+ // ---------------------------------------------------------------------------
935
+ // DEAD-TARGET-SELFHEAL: unpin a queue task hard-pinned to a session/node that has
936
+ // died (absent from the live mesh) so a live idle session can claim it, instead of
937
+ // leaving it stranded 'pending' forever behind the target_session_constraint skip.
938
+ // ---------------------------------------------------------------------------
939
+
940
+ // Conservative age gate before a pinned-but-dead target is unpinned. A target that is
941
+ // merely briefly unassigned or momentarily reconnecting must not be reclaimed on the
942
+ // tick it drops out of view; we require the task to have been idle (no updatedAt bump)
943
+ // for at least this window first. Sized to comfortably outlast a transient P2P blip /
944
+ // reconnect while staying well inside the reclaim cadence of the rest of the file
945
+ // (AUTO_LAUNCH_AWAIT_CLAIM_MS is 90s; the stranded-reclaim watchdog fires on similar
946
+ // scales), so a real reconnect wins the race and the self-heal only fires on a target
947
+ // that is genuinely gone.
948
+ const DEAD_TARGET_GRACE_MS = 60_000;
949
+
950
+ interface DeadTargetVerdict {
951
+ /** The pinned target is confirmed dead and past the grace window → safe to unpin. */
952
+ dead: boolean;
953
+ /** True when the target NODE itself is absent from the live mesh (clear targetNodeId too). */
954
+ nodeDead: boolean;
955
+ /** Short reason string for the ledger/requeue. */
956
+ reason: string;
957
+ }
958
+
959
+ /**
960
+ * Decide whether a task's hard target pin (targetSessionId and/or targetNodeId) points at
961
+ * something that has DIED — i.e. is absent from the live mesh snapshot — and has been so
962
+ * long enough (DEAD_TARGET_GRACE_MS since the task's last update) that unpinning is safe.
963
+ *
964
+ * Two definitive death signals, deliberately conservative to never race a reconnecting node:
965
+ *
966
+ * (1) NODE dead — the task pins a targetNodeId that matches NO node in the live mesh
967
+ * (the same `meshNodeIdMatches`-over-mesh.nodes signal the targetPinUnmatched relabel
968
+ * uses at the empty-candidate site). A pinned session on an absent node is unreachable
969
+ * regardless, so the session pin is dead too. `nodeDead` ⇒ clear targetNodeId as well.
970
+ * Excluded: a target that is only TRANSIENTLY unresolved (a freshly-cloned worktree
971
+ * still propagating / bootstrapping) — isTargetNodeTransientlyUnresolved gates it out.
972
+ *
973
+ * (2) SESSION dead on a LIVE LOCAL node — the target node IS present and is THIS daemon's
974
+ * node, but the pinned session is absent from the local instance manager
975
+ * (resolveSessionBusyVerdict === 'UNKNOWN'). Local session visibility is complete, so
976
+ * absence here is genuine death, not a busy/generating flip. We KEEP targetNodeId (only
977
+ * the session died; the node is healthy and can host a replacement claim).
978
+ *
979
+ * A live REMOTE node whose session is not in our idle view is NOT treated as dead: absence
980
+ * from the remote-idle mirror is explicitly UNKNOWN liveness (the session may be busy or its
981
+ * agent:ready pull merely lost), so unpinning it could race healthy in-flight work. Returns
982
+ * dead=false in that case, leaving the existing skip in place.
983
+ */
984
+ function resolveDeadTargetVerdict(components: DaemonComponents, meshId: string, mesh: any, task: MeshWorkQueueEntry): DeadTargetVerdict {
985
+ const NOT_DEAD: DeadTargetVerdict = { dead: false, nodeDead: false, reason: '' };
986
+ const targetSessionId = readNonEmptyString(task.targetSessionId);
987
+ const targetNodeId = readNonEmptyString(task.targetNodeId);
988
+ if (!targetSessionId && !targetNodeId) return NOT_DEAD;
989
+
990
+ // Age gate: never reclaim a pin younger than the grace window (guards against a target
991
+ // that has only just dropped out of view for a momentary reconnect).
992
+ const lastUpdateMs = Date.parse(task.updatedAt || task.createdAt || '');
993
+ if (Number.isFinite(lastUpdateMs) && Date.now() - lastUpdateMs < DEAD_TARGET_GRACE_MS) return NOT_DEAD;
994
+
995
+ const nodes: any[] = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
996
+
997
+ // (1) NODE-dead — a pinned node absent from the live mesh, and NOT merely transiently
998
+ // unresolved (a propagating/bootstrapping clone). This is a permanent routing miss.
999
+ if (targetNodeId) {
1000
+ const nodePresent = nodes.some(n => meshNodeIdMatches(n, targetNodeId));
1001
+ if (!nodePresent) {
1002
+ if (isTargetNodeTransientlyUnresolved(mesh, task)) return NOT_DEAD;
1003
+ return { dead: true, nodeDead: true, reason: 'dead_target_node_absent' };
1004
+ }
1005
+ }
1006
+
1007
+ // (2) SESSION-dead on a LIVE LOCAL node. Only meaningful when a session is pinned.
1008
+ if (targetSessionId) {
1009
+ // Resolve the pinned target's node (if any) to decide whether we can trust local
1010
+ // absence. Without a targetNodeId, fall back to whichever live node hosts the session
1011
+ // is unknowable here; treat that as a LOCAL check only (a session id we cannot see
1012
+ // locally on a node we cannot resolve remotely stays UNKNOWN → not dead).
1013
+ const node = targetNodeId
1014
+ ? nodes.find(n => meshNodeIdMatches(n, targetNodeId))
1015
+ : undefined;
1016
+ // A pinned session on a REMOTE live node: absence from our view is UNKNOWN, not death.
1017
+ // Only a LOCAL node (or no node pin at all — same-daemon assumption) lets us conclude
1018
+ // death from local instance-manager absence.
1019
+ const nodeIsLocal = node ? isLocalAutoLaunchNode(node) : true;
1020
+ if (nodeIsLocal) {
1021
+ const verdict = resolveSessionBusyVerdict(components, targetSessionId);
1022
+ if (verdict === 'UNKNOWN') {
1023
+ // Session absent from the complete local session view → genuinely gone.
1024
+ return { dead: true, nodeDead: false, reason: 'dead_target_session_absent' };
1025
+ }
1026
+ // GENERATING / IDLE_CONFIRMED → the session is alive (possibly busy). Never disturb.
1027
+ }
1028
+ }
1029
+
1030
+ return NOT_DEAD;
1031
+ }
1032
+
934
1033
  /**
935
1034
  * FALSE-BLOCKER-CLONE-QUEUE (stale-event clear): once a task whose actionable blocker we
936
1035
  * previously paged either gets claimed or transitions to a self-resolving state, re-arm the
@@ -1929,6 +2028,32 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1929
2028
  continue;
1930
2029
  }
1931
2030
  if (task.targetSessionId) {
2031
+ // DEAD-TARGET-SELFHEAL: before the unconditional target_session_constraint skip,
2032
+ // check whether the pinned session/node has DIED (absent from the live mesh). A
2033
+ // hard-pinned task whose target is gone can NEVER re-enter 'assigned' (the claim
2034
+ // gate refuses every non-matching session) and this skip fires forever with no
2035
+ // liveness check — the triple-walled stranded-pending defect. If the pin is
2036
+ // confirmed dead past the grace window, requeue it (clearing the dead session
2037
+ // pin, and the node pin too when the NODE itself is gone) so a live idle session
2038
+ // can claim it. requeueTask counts toward maxTaskRetries → bounded self-heal that
2039
+ // auto-fails past the cap (the desired terminal state, unblocking dependents).
2040
+ const deadTarget = resolveDeadTargetVerdict(components, meshId, mesh, task);
2041
+ if (deadTarget.dead) {
2042
+ const requeued = requeueTask(meshId, task.id, {
2043
+ reason: deadTarget.reason,
2044
+ clearTargetSession: true,
2045
+ // Keep the node pin if only the SESSION died on a still-live node; clear it
2046
+ // when the NODE itself is absent (nothing to pin to).
2047
+ clearTargetNode: deadTarget.nodeDead,
2048
+ });
2049
+ if (requeued) {
2050
+ LOG.warn('MeshQueue', `DEAD-TARGET-SELFHEAL: task ${task.id} (mesh ${meshId}) was pinned to a dead target (${deadTarget.reason}); requeued${deadTarget.nodeDead ? ' and unpinned node' : ''} (requeueCount=${requeued.requeueCount ?? '?'}, status=${requeued.status}).`);
2051
+ }
2052
+ // Keep the skip for THIS tick (the requeue already flipped the row to
2053
+ // pending/failed); a later tick assigns/launches the now-unpinned task.
2054
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'target_session_dead_requeued' });
2055
+ continue;
2056
+ }
1932
2057
  markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'target_session_constraint' });
1933
2058
  continue;
1934
2059
  }
@@ -76,6 +76,7 @@ import {
76
76
  resolveReconcileIntervalMs,
77
77
  } from './mesh-reconcile-config.js';
78
78
  import { pullRemoteNodeQueues } from './mesh-remote-event-pull.js';
79
+ import { runDiskRetentionSweep, detectAndSignalOrphanWorktrees } from './mesh-disk-retention.js';
79
80
  import {
80
81
  reconcileUnterminatedDirectDispatches,
81
82
  autoPruneStaleDirectDispatches,
@@ -126,6 +127,16 @@ interface LiveCoordinator {
126
127
  // "restart does not clear it" symptom the operator needs visibility into).
127
128
  const coordinatorModalParkState = new Map<string, boolean>();
128
129
 
130
+ // Disk/worktree retention throttle. The reconcile tick runs every ~4s, but the
131
+ // retention sweep (fs walks + git worktree list) is far too heavy for that cadence
132
+ // and its artifacts age in days, so it runs at most once per hour. `undefined`
133
+ // means "never run yet" → runs on the first tick after daemon start so a long-lived
134
+ // backlog is reclaimed promptly rather than an hour later. Per-process; a restart
135
+ // re-runs it immediately, which is the desired behavior (a restart is exactly when
136
+ // stale artifacts from the previous run should be swept).
137
+ const DISK_RETENTION_INTERVAL_MS = 60 * 60 * 1000; // 1h
138
+ let lastDiskRetentionRunAt: number | undefined;
139
+
129
140
  // Find live CLI coordinator instances on THIS daemon, keyed by mesh.
130
141
  function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
131
142
  const out: LiveCoordinator[] = [];
@@ -1354,6 +1365,44 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
1354
1365
  }
1355
1366
  }
1356
1367
 
1368
+ // ── PHASE 6: disk / worktree retention (hourly, mission 86def38d) ──────────
1369
+ // Legacy on-disk artifacts under ~/.adhdev accumulated with NO lifetime and grew
1370
+ // the data volume until a refine bootstrap failed at 98% disk. This throttled pass
1371
+ // (≤1×/hour — the artifacts age in days and the fs/git walk is too heavy for the 4s
1372
+ // tick) reclaims them:
1373
+ // • JSONL ledger files older than 30d (legacy after the SQLite ledger)
1374
+ // • terminated session-host runtime files older than 14d (live never touched)
1375
+ // • mesh-runtime.db.bak-* backups older than 7d
1376
+ // • orphan worktree DETECTION → cleanup_candidate ledger signal (NEVER deleted).
1377
+ // Runs BEFORE PHASE 2's early return so it fires whether or not a live CLI
1378
+ // coordinator exists on this daemon. Isolated in try/catch so it can never kill the
1379
+ // tick. All file deletions are defensive (age + live-runtime guards in the pure
1380
+ // selectors); worktree cleanup stays manual/coordinator-driven.
1381
+ {
1382
+ const nowMs = Date.now();
1383
+ const due = lastDiskRetentionRunAt === undefined
1384
+ || (nowMs - lastDiskRetentionRunAt) >= DISK_RETENTION_INTERVAL_MS;
1385
+ if (due) {
1386
+ lastDiskRetentionRunAt = nowMs;
1387
+ try {
1388
+ runDiskRetentionSweep(nowMs);
1389
+ } catch (e: any) {
1390
+ LOG.warn('MeshReconcile', `Disk retention sweep failed: ${e?.message || e}`);
1391
+ }
1392
+ // Orphan-worktree detection is per-mesh (needs the mesh's base repo + node set)
1393
+ // and only for meshes this daemon hosts (its local base checkout is the git anchor).
1394
+ for (const mesh of listMeshes()) {
1395
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
1396
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
1397
+ try {
1398
+ await detectAndSignalOrphanWorktrees(mesh, nowMs);
1399
+ } catch (e: any) {
1400
+ LOG.warn('MeshReconcile', `Orphan worktree detection failed for mesh ${mesh.id}: ${e?.message || e}`);
1401
+ }
1402
+ }
1403
+ }
1404
+ }
1405
+
1357
1406
  // ── PHASE 2: inject into live CLI coordinators on this daemon ──────────────
1358
1407
  const coordinators = findLiveCoordinators(components);
1359
1408
  if (coordinators.length === 0) {
@@ -174,6 +174,27 @@ export class MeshRuntimeStore {
174
174
  this.instance = undefined;
175
175
  }
176
176
 
177
+ /**
178
+ * VACUUM the SQLite database to reclaim on-disk space. Retention prunes rows
179
+ * with DELETE, which frees pages inside the file but does NOT shrink it — the
180
+ * mesh-runtime.db grew to hundreds of MB (mission 86def38d disk-accumulation
181
+ * bootstrap failure) precisely because the file was never compacted. This
182
+ * rewrites the DB into a minimal footprint. Best-effort: a VACUUM failure (e.g.
183
+ * insufficient temp space, a read lock) is logged and swallowed so it can never
184
+ * block daemon shutdown. Called once on shutdown (see daemon-lifecycle), never
185
+ * on the hot path — VACUUM takes an exclusive lock and rewrites the whole file.
186
+ */
187
+ vacuum(): void {
188
+ try {
189
+ // Fold the WAL back into the main DB first so VACUUM reclaims those pages too.
190
+ try { this.db.pragma('wal_checkpoint(TRUNCATE)'); } catch { /* checkpoint best-effort */ }
191
+ this.db.exec('VACUUM;');
192
+ LOG.info('MeshRuntimeStore', 'VACUUM completed on shutdown');
193
+ } catch (err: any) {
194
+ LOG.warn('MeshRuntimeStore', `VACUUM on shutdown failed (ignored): ${err?.message || err}`);
195
+ }
196
+ }
197
+
177
198
  close(): void {
178
199
  this.db.close();
179
200
  }
@@ -120,6 +120,31 @@ export const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
120
120
  // met. Scoped (at the call site) to autonomous mesh sessions, so interactive sessions are
121
121
  // untouched.
122
122
  export const PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS = 1200;
123
+ // (ANTIGRAVITY-30S-CAP-PREMATURE) Minimum PTY quiet dwell required before the
124
+ // COMPLETED_FINALIZATION_MAX_WAIT_MS (30s) cap may RELEASE an antigravity `holdForTranscript`
125
+ // block into the forced weak completion. Antigravity is a native-source provider: its
126
+ // idle/generating verdict is PTY-screen-derived, but its assistant answer lands in
127
+ // native-history and can legitimately lag past 30s on a long turn (tool phase + slow reply).
128
+ // The 30s cap releases on ELAPSED TIME, not proof-of-idle — so on a long turn it fired a
129
+ // premature completed/idle to the coordinator while the PTY was still generating (live-repro:
130
+ // a routed RCA task emitted "completed" in 10s with no result). Require instead that the PTY
131
+ // has been quiet (no raw output) for at least this long AND the adapter reports no pending
132
+ // response before the cap may force-emit. A genuinely quiescent tool-only turn (no assistant
133
+ // bubble) has a stable screen well past this bound and still finalizes; a turn whose PTY is
134
+ // still producing output keeps holding past 30s (up to ANTIGRAVITY_HOLD_HARD_CAP_MS). Scoped
135
+ // (at the call site) to antigravity-cli holdForTranscript blocks, so claude-cli/codex-cli
136
+ // completion timing is untouched.
137
+ export const ANTIGRAVITY_HOLD_QUIET_DWELL_MS = 3000;
138
+ // (ANTIGRAVITY-30S-CAP-PREMATURE) Absolute upper bound on how long an antigravity
139
+ // `holdForTranscript` block may be held once the 30s cap is reached but the PTY is still
140
+ // active. The quiet-dwell gate above keeps holding while the PTY keeps emitting output, so a
141
+ // truly runaway turn (PTY never falls quiet) must still eventually release rather than wedge
142
+ // the session in generating forever — strictly worse than the original premature-emit bug.
143
+ // Once a pending completion has been held this long we force-emit regardless of PTY activity.
144
+ // Sized generously (5 min) so realistic long antigravity turns (multi-tool + slow reply)
145
+ // finish and let the transcript land / the PTY fall quiet naturally, while still guaranteeing
146
+ // eventual release. Mirrors BACKGROUND_TASK_HOLD_MAX_MS's escape-hatch rationale.
147
+ export const ANTIGRAVITY_HOLD_HARD_CAP_MS = 5 * 60_000;
123
148
  // (FALSE-IDLE-BACKGROUND-CMD) Hard cap on how long a pending completion may be HELD
124
149
  // solely because the claude-cli transcript still shows an unresolved run_in_background
125
150
  // bash job (backgroundTaskActive). The hold is the correct behaviour while the job is
@@ -58,6 +58,8 @@ import {
58
58
  COMPLETED_FINALIZATION_MAX_WAIT_MS,
59
59
  NATIVE_HISTORY_MESH_IDLE_SETTLE_MS,
60
60
  PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS,
61
+ ANTIGRAVITY_HOLD_QUIET_DWELL_MS,
62
+ ANTIGRAVITY_HOLD_HARD_CAP_MS,
61
63
  BACKGROUND_TASK_HOLD_MAX_MS,
62
64
  USER_INPUT_ACK_DEDUP_WINDOW_MS,
63
65
  STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS,
@@ -369,6 +371,16 @@ export class CliProviderInstance implements ProviderInstance {
369
371
  private generatingDebounceTimer: NodeJS.Timeout | null = null;
370
372
  private generatingDebouncePending: { chatTitle: string; timestamp: number } | null = null;
371
373
  private lastApprovalEventFingerprint = '';
374
+ // INTERACTIVE-PROMPT-PUSH: edge-trigger key for the AskUserQuestion / waiting_choice
375
+ // notification. An AskUserQuestion prompt is surfaced only as a display-only
376
+ // `waiting_choice` overlay in getState(); the raw adapter status stays idle/generating,
377
+ // so detectStatusTransition()'s status-keyed arms never fire and no push-worthy
378
+ // agent:* event is emitted — the owner misses the "ACTION REQUIRED" prompt when the
379
+ // app is backgrounded. We emit one agent:waiting_approval on ENTRY into the prompt
380
+ // state (reusing the existing server push path, no cloud change) and dedupe on this
381
+ // key so repeated status ticks with the same prompt do not re-fire. '' means no
382
+ // prompt is currently active (cleared when the prompt is answered/gone).
383
+ private lastInteractivePromptEventKey = '';
372
384
  private autoApproveBusy = false;
373
385
  private autoApproveBusyTimer: NodeJS.Timeout | null = null;
374
386
  private lastAutoApprovalSignature = '';
@@ -1897,6 +1909,33 @@ export class CliProviderInstance implements ProviderInstance {
1897
1909
  return false;
1898
1910
  }
1899
1911
 
1912
+ // (ANTIGRAVITY-30S-CAP-PREMATURE) Discriminator gating the 30s-cap release of an antigravity
1913
+ // `holdForTranscript` block. Antigravity's idle verdict is PTY-screen-derived but its assistant
1914
+ // answer lands in native-history, which can legitimately lag past COMPLETED_FINALIZATION_MAX_WAIT_MS
1915
+ // (30s) on a long turn. The cap releases on elapsed time, not proof-of-idle, so it force-emitted a
1916
+ // premature weak completion WHILE THE PTY WAS STILL GENERATING. Returns true when the PTY is still
1917
+ // active — i.e. the adapter reports a pending response OR raw PTY output arrived within the last
1918
+ // ANTIGRAVITY_HOLD_QUIET_DWELL_MS — meaning the 30s cap must KEEP HOLDING (the turn is not proven
1919
+ // over). Returns false when the PTY is genuinely quiescent (no pending response AND no recent
1920
+ // output), so a real tool-only turn with no assistant bubble still force-emits a weak completion
1921
+ // rather than wedging. The absolute ANTIGRAVITY_HOLD_HARD_CAP_MS bound is enforced at the call site
1922
+ // so a runaway PTY that never falls quiet still eventually releases. Fails OPEN (returns false =
1923
+ // allow release) when lastOutputAt is unreadable, so the gate can never wedge a session.
1924
+ private antigravityHoldPtyStillActive(): boolean {
1925
+ if (this.hasAdapterPendingResponse()) return true;
1926
+ try {
1927
+ const outStatus = this.adapter.getStatus({ allowParse: false }) as any;
1928
+ const lastOutputAt = typeof outStatus?.lastOutputAt === 'number' && Number.isFinite(outStatus.lastOutputAt)
1929
+ ? outStatus.lastOutputAt as number
1930
+ : undefined;
1931
+ if (typeof lastOutputAt === 'number') {
1932
+ const quietMs = Date.now() - lastOutputAt;
1933
+ if (quietMs < ANTIGRAVITY_HOLD_QUIET_DWELL_MS) return true;
1934
+ }
1935
+ } catch { /* defensive: dwell read is best-effort — fall through to allow release */ }
1936
+ return false;
1937
+ }
1938
+
1900
1939
  private shouldSuppressStaleParsedBusyStatus(parsedStatus: any, adapterStatus: any): boolean {
1901
1940
  const parsedRawStatus = typeof parsedStatus?.status === 'string' ? parsedStatus.status.trim() : '';
1902
1941
  const adapterRawStatus = typeof adapterStatus?.status === 'string' ? adapterStatus.status.trim() : '';
@@ -2751,6 +2790,42 @@ export class CliProviderInstance implements ProviderInstance {
2751
2790
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
2752
2791
  return;
2753
2792
  }
2793
+ // (ANTIGRAVITY-30S-CAP-PREMATURE) The 30s cap has been reached for an antigravity
2794
+ // `holdForTranscript` block. The cap releases on ELAPSED TIME, not proof-of-idle — but
2795
+ // antigravity is native-source (assistant answer in native-history) with a PTY-derived
2796
+ // idle verdict, so the transcript's final assistant bubble can legitimately land past 30s
2797
+ // on a long turn. Releasing here would fire a premature weak completed/idle to the
2798
+ // coordinator WHILE THE PTY IS STILL GENERATING (the live-reproduced defect). Gate the
2799
+ // release on the PTY being genuinely quiet: if it is still active (adapter pending
2800
+ // response OR raw output within ANTIGRAVITY_HOLD_QUIET_DWELL_MS) and we are under the
2801
+ // absolute ANTIGRAVITY_HOLD_HARD_CAP_MS bound, KEEP HOLDING (re-probe next retry — the
2802
+ // transcript branch above clears the block for a genuine emit once the answer lands).
2803
+ // A genuinely quiescent tool-only turn (no assistant bubble, PTY stable) falls through
2804
+ // to the existing weak force-emit; a runaway PTY that never quiets releases at the hard
2805
+ // cap so it cannot wedge forever. Scoped to antigravity holdForTranscript so
2806
+ // claude-cli/codex-cli completion timing is unchanged.
2807
+ if (this.type === 'antigravity-cli'
2808
+ && block.holdForTranscript === true
2809
+ && waitedMs < ANTIGRAVITY_HOLD_HARD_CAP_MS
2810
+ && this.antigravityHoldPtyStillActive()) {
2811
+ if (pending.loggedBlockReason !== 'antigravity_hold_pty_active') {
2812
+ LOG.info('CLI', `[${this.type}] 30s cap reached but PTY still generating; holding antigravity completion past cap (waitedMs=${waitedMs} hardCap=${ANTIGRAVITY_HOLD_HARD_CAP_MS}) (${blockReason})`);
2813
+ if (this.isMeshWorkerSession()) {
2814
+ traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `antigravity_hold_pty_active waited=${waitedMs}ms`);
2815
+ }
2816
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('hold', {
2817
+ blockReason,
2818
+ latestVisibleStatus,
2819
+ terminal: block.terminal === true,
2820
+ holdForTranscript: true,
2821
+ antigravityPtyStillActive: true,
2822
+ waitedMs,
2823
+ });
2824
+ pending.loggedBlockReason = 'antigravity_hold_pty_active';
2825
+ }
2826
+ this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
2827
+ return;
2828
+ }
2754
2829
  const emittedAfterFinalizationTimeout = waitedMs >= COMPLETED_FINALIZATION_MAX_WAIT_MS;
2755
2830
  const completionDiagnostic = this.buildCompletedFinalizationDiagnostic({
2756
2831
  blockReason,
@@ -3885,6 +3960,47 @@ export class CliProviderInstance implements ProviderInstance {
3885
3960
  this.lastStatus = newStatus;
3886
3961
  }
3887
3962
 
3963
+ // INTERACTIVE-PROMPT-PUSH: fire a push-worthy notification when the session
3964
+ // ENTERS an AskUserQuestion / waiting_choice state. This is orthogonal to the
3965
+ // status-change block above: an AskUserQuestion prompt is surfaced only as a
3966
+ // display-only `waiting_choice` overlay in getState() (mirrored here off
3967
+ // adapterStatus.activeInteractivePrompt, the exact signal getState uses), while
3968
+ // the raw adapter status stays idle/generating — so none of the status-keyed
3969
+ // arms above ever emit an agent:* event for it and the owner gets no web-push.
3970
+ //
3971
+ // We REUSE agent:waiting_approval (the question message as modalMessage, the
3972
+ // choice labels as modalButtons) so the existing server push path fires with
3973
+ // zero cloud change — the semantics ("the agent needs your input") match.
3974
+ //
3975
+ // Edge-triggered: emit exactly once on entry, keyed on promptId + question text,
3976
+ // and reset the key when the prompt clears so a fresh prompt re-fires and a later
3977
+ // real completion still flows through the idle arm normally. We do NOT emit when
3978
+ // the session is ALSO in a genuine approval state (newStatus === 'waiting_approval'):
3979
+ // that arm already emits agent:waiting_approval, and firing here too would double
3980
+ // up on the same modal.
3981
+ const interactivePrompt = adapterStatus.activeInteractivePrompt ?? null;
3982
+ if (interactivePrompt && newStatus !== 'waiting_approval') {
3983
+ const firstQuestion = interactivePrompt.questions?.[0];
3984
+ const promptKey = `${interactivePrompt.promptId}::${firstQuestion?.question ?? ''}`;
3985
+ if (promptKey !== this.lastInteractivePromptEventKey) {
3986
+ this.lastInteractivePromptEventKey = promptKey;
3987
+ const modalMessage = firstQuestion
3988
+ ? (firstQuestion.header
3989
+ ? `${firstQuestion.header}: ${firstQuestion.question}`
3990
+ : firstQuestion.question)
3991
+ : undefined;
3992
+ const modalButtons = firstQuestion?.options?.map((option: { label: string }) => option.label);
3993
+ this.pushEvent({
3994
+ event: 'agent:waiting_approval', chatTitle, timestamp: now,
3995
+ modalMessage,
3996
+ modalButtons,
3997
+ });
3998
+ }
3999
+ } else if (!interactivePrompt && this.lastInteractivePromptEventKey) {
4000
+ // Prompt answered / gone — reset so the next AskUserQuestion re-fires.
4001
+ this.lastInteractivePromptEventKey = '';
4002
+ }
4003
+
3888
4004
  // GENERATING-BOUNDARY idle-stayed collapse (R4b): the starting→idle
3889
4005
  // fast-collapse arm above only fires when the FIRST turn itself drives the
3890
4006
  // starting→idle transition. When the launch settle already drained
@@ -8,6 +8,8 @@ import {
8
8
  type SessionHostEndpoint,
9
9
  type SessionHostRequestType,
10
10
  } from '@adhdev/session-host-core';
11
+ import { getProcessCommandLine, parseNodeScriptPath } from '../commands/process-lifecycle.js';
12
+ import { LOG } from '../logging/logger.js';
11
13
  import { ensureSessionHostReady as ensureSharedSessionHostReady } from './runtime-support.js';
12
14
  import { DEFAULT_SESSION_HOST_READY_TIMEOUT_MS } from '../runtime-defaults.js';
13
15
 
@@ -97,6 +99,16 @@ export function createManagedSessionHost(options: ManagedSessionHostOptions): Ma
97
99
  return require.resolve('@adhdev/session-host-daemon');
98
100
  }
99
101
 
102
+ function pathsEquivalent(left: string, right: string): boolean {
103
+ return path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase();
104
+ }
105
+
106
+ function getRunningSessionHostScriptPath(pid: number): string | null {
107
+ const commandLine = getProcessCommandLine(pid);
108
+ if (!commandLine || !/session-host-daemon/i.test(commandLine)) return null;
109
+ return parseNodeScriptPath(commandLine);
110
+ }
111
+
100
112
  function getPidFile(): string {
101
113
  return path.join(os.homedir(), '.adhdev', `${appName}-session-host.pid`);
102
114
  }
@@ -178,6 +190,28 @@ export function createManagedSessionHost(options: ManagedSessionHostOptions): Ma
178
190
 
179
191
  async function ensureReady(): Promise<SessionHostEndpoint> {
180
192
  options.beforeEnsureReady?.();
193
+
194
+ // Defensive guard: on Windows the daemon may have been restarted from a
195
+ // new versioned prefix while a session-host from the old prefix is still
196
+ // running. Because the old host is reachable on the same socket endpoint,
197
+ // `ensureSharedSessionHostReady` would reuse it and lazy requires would
198
+ // resolve against the deleted tree. Detect the mismatch and stop the stale
199
+ // host before it can be reused; a matching or unreadable host is left alone.
200
+ if (process.platform === 'win32') {
201
+ const existingPid = getPid();
202
+ if (existingPid !== null) {
203
+ const runningPath = getRunningSessionHostScriptPath(existingPid);
204
+ const currentEntry = resolveEntry();
205
+ if (runningPath && !pathsEquivalent(runningPath, currentEntry)) {
206
+ LOG.warn(
207
+ 'SessionHost',
208
+ `Detected stale host pid ${existingPid} running from ${runningPath}; restarting from ${currentEntry}`,
209
+ );
210
+ stopManagedSessionHostProcess();
211
+ }
212
+ }
213
+ }
214
+
181
215
  try {
182
216
  return await ensureSharedSessionHostReady({
183
217
  appName,