@adhdev/daemon-core 0.9.82-rc.552 → 0.9.82-rc.554

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 (39) hide show
  1. package/dist/cli-adapter-types.d.ts +29 -0
  2. package/dist/cli-adapters/provider-cli-adapter.d.ts +80 -1
  3. package/dist/cli-adapters/provider-cli-shared.d.ts +55 -0
  4. package/dist/cli-adapters/terminal-screen.d.ts +5 -0
  5. package/dist/commands/stream-commands.d.ts +31 -0
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +789 -15
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +785 -12
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/mesh/mesh-ledger.d.ts +1 -1
  12. package/dist/mesh/mesh-refine-gates.d.ts +86 -0
  13. package/dist/providers/cli-provider-instance.d.ts +97 -0
  14. package/dist/providers/provider-instance.d.ts +9 -0
  15. package/dist/providers/spec/adapter.d.ts +7 -0
  16. package/dist/providers/spec/cli-adapter.d.ts +64 -0
  17. package/dist/providers/spec/fsm-driver.d.ts +11 -0
  18. package/dist/repo-mesh-types.d.ts +23 -0
  19. package/package.json +3 -3
  20. package/src/cli-adapter-types.ts +29 -0
  21. package/src/cli-adapters/provider-cli-adapter.ts +135 -0
  22. package/src/cli-adapters/provider-cli-shared.ts +172 -0
  23. package/src/cli-adapters/terminal-screen.ts +5 -0
  24. package/src/commands/handler.ts +4 -0
  25. package/src/commands/router-refine.ts +40 -1
  26. package/src/commands/router.ts +15 -0
  27. package/src/commands/stream-commands.ts +125 -0
  28. package/src/index.ts +1 -0
  29. package/src/mesh/coordinator-prompt.ts +2 -0
  30. package/src/mesh/mesh-events-utils.ts +15 -0
  31. package/src/mesh/mesh-ledger.ts +6 -0
  32. package/src/mesh/mesh-refine-gates.ts +256 -0
  33. package/src/providers/cli-provider-instance.ts +277 -6
  34. package/src/providers/provider-instance-manager.ts +11 -0
  35. package/src/providers/provider-instance.ts +10 -0
  36. package/src/providers/spec/adapter.ts +7 -0
  37. package/src/providers/spec/cli-adapter.ts +122 -0
  38. package/src/providers/spec/fsm-driver.ts +5 -0
  39. package/src/repo-mesh-types.ts +35 -0
@@ -556,6 +556,262 @@ export async function runMeshRefinePatchEquivalenceGate(
556
556
  }
557
557
  }
558
558
 
559
+ /**
560
+ * Machine-readable sub-classification of a `patch_equivalence_failed` (and the
561
+ * related submodule-gitlink preflight blocks). The opaque top-level
562
+ * `patch_equivalence_failed` code is preserved for backward compatibility; this
563
+ * detailed reason is added ALONGSIDE it so coordinators no longer have to guess
564
+ * WHY the preflight blocked (the 2026-07-17 hidden-spinner convergence incident:
565
+ * the real cause was a diverged base + an unreachable submodule gitlink artifact,
566
+ * not a real patch conflict, but Refinery only returned the opaque code and the
567
+ * coordinator mis-attributed it to a stale daemon version).
568
+ */
569
+ export type MeshRefinePatchEquivalenceDetailedReasonCode =
570
+ /** Worktree base diverged from target base (HEAD is not a descendant of origin/main). */
571
+ | 'base_divergence'
572
+ /** Submodule gitlink commit is not reachable from the submodule's remote main branch (publish needed). */
573
+ | 'submodule_unreachable'
574
+ /** Genuine non-equivalent content: expected tree vs actual merge diff differ. */
575
+ | 'actual_patch_diff'
576
+ /** Submodule gitlink trivial fast-forward mis-judged as non-equivalent (HEAD descends origin/main, patch-id equal, blocked only by the gitlink). */
577
+ | 'trivial_ff_misjudgment'
578
+ /** Already identical to origin/main (ahead 0 / behind 0, no diff) — should be treated as success/no-op. */
579
+ | 'already_converged'
580
+ /** Fallback when the classifier itself could not run (git error); keep the opaque code, note the reason. */
581
+ | 'unclassified';
582
+
583
+ export type MeshRefinePatchEquivalenceFailureClassification = {
584
+ detailedReason: MeshRefinePatchEquivalenceDetailedReasonCode;
585
+ /** Human-readable one-line description of the sub-cause. */
586
+ detailedReasonDescription: string;
587
+ /** Suggested next action for the coordinator/owner (free-form, actionable). */
588
+ recommendedAction: string;
589
+ /** Structured supporting evidence: SHAs, ahead/behind, submodule reachability, patch-id comparison, diff stat. */
590
+ evidence: {
591
+ baseHead?: string;
592
+ branchHead?: string;
593
+ mergeBase?: string;
594
+ /** How many commits base (origin/main) is ahead of the branch's merge-base (branch is behind). */
595
+ behind?: number;
596
+ /** How many commits the branch is ahead of the merge-base. */
597
+ ahead?: number;
598
+ /** True when HEAD is NOT a descendant of the target base (diverged). */
599
+ baseDiverged?: boolean;
600
+ expectedPatchId?: string;
601
+ actualPatchId?: string;
602
+ patchIdEqual?: boolean;
603
+ /** Compact one-line diff stat summary of the residual/actual merge diff (best-effort). */
604
+ diffStat?: string;
605
+ /** Per-submodule gitlink reachability against submodule origin/main (best-effort). */
606
+ submoduleGitlinks?: Array<{
607
+ path: string;
608
+ baseCommit?: string;
609
+ branchCommit?: string;
610
+ /** True when branchCommit descends baseCommit (a strict fast-forward advance). */
611
+ fastForward?: boolean;
612
+ /** True when branchCommit is reachable from the submodule's local origin/main. */
613
+ reachableFromOriginMain?: boolean;
614
+ }>;
615
+ /** Effective auto-publish-submodule-main-commits policy value at classification time. */
616
+ autoPublishSubmoduleMainCommits?: boolean;
617
+ /** Set when the classifier itself errored (detailedReason === 'unclassified'). */
618
+ classifierError?: string;
619
+ };
620
+ };
621
+
622
+ /**
623
+ * Classify WHY a patch-equivalence preflight blocked, turning the opaque
624
+ * `patch_equivalence_failed` code into a machine-readable {@link
625
+ * MeshRefinePatchEquivalenceDetailedReasonCode} plus a recommended action and
626
+ * structured evidence. Read-only: runs only `git` inspection commands (rev-list,
627
+ * merge-base, diff --stat, submodule reachability probes) against the already-set
628
+ * worktree — it never mutates the repo.
629
+ *
630
+ * Priority of classification (first match wins):
631
+ * 1. already_converged — ahead 0 & behind 0 & no residual diff
632
+ * 2. submodule_unreachable — a changed gitlink commit is not reachable from the
633
+ * submodule's origin/main (publish needed)
634
+ * 3. trivial_ff_misjudgment — HEAD descends origin/main AND (excl. gitlinks) the
635
+ * patch-ids match — blocked only by a ff gitlink
636
+ * 4. base_divergence — HEAD is not a descendant of the target base
637
+ * 5. actual_patch_diff — genuine content divergence (the residual case)
638
+ *
639
+ * `targetBaseRef` is the ref the branch is meant to land on (e.g. 'origin/main'
640
+ * or the pinned baseHead SHA). `autoPublishSubmoduleMainCommits` is threaded in so
641
+ * the submodule_unreachable recommendation can name the current policy value.
642
+ */
643
+ export async function classifyPatchEquivalenceFailure(
644
+ repoRoot: string,
645
+ baseHead: string,
646
+ branchHead: string,
647
+ summary: MeshRefinePatchEquivalenceSummary,
648
+ options: { targetBaseRef?: string; autoPublishSubmoduleMainCommits?: boolean } = {},
649
+ ): Promise<MeshRefinePatchEquivalenceFailureClassification> {
650
+ const targetBaseRef = options.targetBaseRef || baseHead;
651
+ const autoPublish = options.autoPublishSubmoduleMainCommits;
652
+ const evidence: MeshRefinePatchEquivalenceFailureClassification['evidence'] = {
653
+ baseHead,
654
+ branchHead,
655
+ mergeBase: summary.mergeBase,
656
+ expectedPatchId: summary.expectedPatchId,
657
+ actualPatchId: summary.actualPatchId,
658
+ patchIdEqual: !!summary.expectedPatchId && summary.expectedPatchId === summary.actualPatchId,
659
+ ...(autoPublish !== undefined ? { autoPublishSubmoduleMainCommits: autoPublish } : {}),
660
+ };
661
+ try {
662
+ const git = (args: string[]): string => execFileSync(GIT, args, {
663
+ cwd: repoRoot,
664
+ encoding: 'utf8',
665
+ maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
666
+ windowsHide: true,
667
+ });
668
+ const gitOk = (args: string[]): boolean => {
669
+ try { git(args); return true; } catch { return false; }
670
+ };
671
+
672
+ // ahead/behind of branch vs the target base ref. left = base-only (behind),
673
+ // right = branch-only (ahead).
674
+ let ahead = 0;
675
+ let behind = 0;
676
+ try {
677
+ const out = git(['rev-list', '--left-right', '--count', `${targetBaseRef}...${branchHead}`]).trim();
678
+ const [left, right] = out.split(/\s+/).map(n => Number.parseInt(n, 10));
679
+ behind = Number.isFinite(left) ? left : 0;
680
+ ahead = Number.isFinite(right) ? right : 0;
681
+ } catch { /* keep zeros */ }
682
+ evidence.ahead = ahead;
683
+ evidence.behind = behind;
684
+ // HEAD (branchHead) diverged from the target base = base is NOT an ancestor
685
+ // of the branch. behind>0 with the base ref not reachable from HEAD.
686
+ const baseIsAncestor = gitOk(['merge-base', '--is-ancestor', targetBaseRef, branchHead]);
687
+ evidence.baseDiverged = !baseIsAncestor;
688
+
689
+ // Residual/actual diff stat (best-effort): what the merge would still introduce.
690
+ let diffStat = '';
691
+ try {
692
+ if (summary.mergedTree) {
693
+ diffStat = git(['diff', '--stat', baseHead, summary.mergedTree]).trim().split('\n').filter(Boolean).slice(-1)[0] || '';
694
+ } else {
695
+ diffStat = git(['diff', '--stat', baseHead, branchHead]).trim().split('\n').filter(Boolean).slice(-1)[0] || '';
696
+ }
697
+ } catch { /* diff stat is best-effort */ }
698
+ if (diffStat) evidence.diffStat = diffStat;
699
+
700
+ // Changed gitlink reachability against each submodule's local origin/main.
701
+ const submoduleGitlinks: NonNullable<MeshRefinePatchEquivalenceFailureClassification['evidence']['submoduleGitlinks']> = [];
702
+ try {
703
+ const nameStatus = git(['diff', '--name-only', '--diff-filter=d', baseHead, branchHead]).trim();
704
+ const changedPaths = nameStatus ? nameStatus.split('\n').map(p => p.trim()).filter(Boolean) : [];
705
+ for (const p of changedPaths) {
706
+ // Only submodule (gitlink, mode 160000) entries.
707
+ let baseCommit: string | undefined;
708
+ let branchCommit: string | undefined;
709
+ try {
710
+ const baseLs = git(['ls-tree', baseHead, '--', p]).trim();
711
+ const branchLs = git(['ls-tree', branchHead, '--', p]).trim();
712
+ const isGitlink = /(^|\s)160000\s/.test(baseLs) || /(^|\s)160000\s/.test(branchLs);
713
+ if (!isGitlink) continue;
714
+ baseCommit = baseLs.split(/\s+/)[2];
715
+ branchCommit = branchLs.split(/\s+/)[2];
716
+ } catch { continue; }
717
+ const submoduleRepo = pathJoin(repoRoot, p);
718
+ let fastForward: boolean | undefined;
719
+ let reachableFromOriginMain: boolean | undefined;
720
+ if (branchCommit) {
721
+ if (baseCommit) {
722
+ fastForward = execGitOk(submoduleRepo, ['merge-base', '--is-ancestor', baseCommit, branchCommit]);
723
+ }
724
+ reachableFromOriginMain = execGitOk(submoduleRepo, ['merge-base', '--is-ancestor', branchCommit, 'refs/remotes/origin/main']);
725
+ }
726
+ submoduleGitlinks.push({ path: p, baseCommit, branchCommit, fastForward, reachableFromOriginMain });
727
+ }
728
+ } catch { /* submodule inspection is best-effort */ }
729
+ if (submoduleGitlinks.length) evidence.submoduleGitlinks = submoduleGitlinks;
730
+
731
+ // Existing gate signal: the merge-tree trivial-ff evaluation, if the gate
732
+ // captured it (a genuine non-trivial submodule conflict lands here too).
733
+ const gitlinkFf = summary.gitlinkTrivialFastForward;
734
+
735
+ // ── Classification (first match wins) ────────────────────────────────
736
+ const noResidualDiff = !evidence.diffStat && (!summary.actualPatchId || summary.actualPatchId === '');
737
+
738
+ // 1. already_converged: nothing ahead, nothing behind, no residual diff.
739
+ if (ahead === 0 && behind === 0 && noResidualDiff) {
740
+ return {
741
+ detailedReason: 'already_converged',
742
+ detailedReasonDescription: 'Branch is already identical to the target base (ahead 0, behind 0, no residual diff); the merge would be a no-op.',
743
+ recommendedAction: 'Treat as already converged — no merge needed. Verify with `git range-diff` / patch-id, then mark the branch merged (or clean up the worktree).',
744
+ evidence,
745
+ };
746
+ }
747
+
748
+ // 2. submodule_unreachable: a changed gitlink is not reachable from the
749
+ // submodule's origin/main. This is the publish-needed artifact.
750
+ const unreachable = submoduleGitlinks.filter(g => g.reachableFromOriginMain === false);
751
+ if (unreachable.length > 0) {
752
+ const paths = unreachable.map(g => g.path).join(', ');
753
+ return {
754
+ detailedReason: 'submodule_unreachable',
755
+ detailedReasonDescription: `Submodule gitlink commit(s) not reachable from submodule origin/main (publish needed): ${paths}.`,
756
+ recommendedAction: `Publish the submodule commit(s) to submodule origin/main, then retry mesh_refine_node (policy allowAutoPublishSubmoduleMainCommits=${autoPublish === undefined ? 'unknown' : autoPublish}).`,
757
+ evidence,
758
+ };
759
+ }
760
+
761
+ // 3. trivial_ff_misjudgment: HEAD descends the target base AND the non-gitlink
762
+ // patch-ids are equal, so the ONLY thing blocking is a fast-forward gitlink
763
+ // that merge-tree refused. (Either the gate flagged an unresolved gitlink
764
+ // ff, or every changed gitlink is a proven ff.)
765
+ const changedGitlinks = submoduleGitlinks.length > 0;
766
+ const allGitlinksFf = changedGitlinks && submoduleGitlinks.every(g => g.fastForward === true);
767
+ const gateSawUnresolvedGitlinkFf = gitlinkFf?.resolved === false && Array.isArray(gitlinkFf.gitlinks) && gitlinkFf.gitlinks.some(g => g.fastForward);
768
+ if (baseIsAncestor && (evidence.patchIdEqual || allGitlinksFf || gateSawUnresolvedGitlinkFf)) {
769
+ return {
770
+ detailedReason: 'trivial_ff_misjudgment',
771
+ detailedReasonDescription: 'HEAD descends the target base and the patch content matches; the block is a submodule gitlink trivial fast-forward that merge-tree refused, not a real divergence.',
772
+ recommendedAction: 'Converge via the strict fast-forward-only bypass (verify HEAD descends origin/main and patch-id equality, then merge --ff-only) instead of the refine gate.',
773
+ evidence,
774
+ };
775
+ }
776
+
777
+ // 4. base_divergence: HEAD is not a descendant of the target base.
778
+ if (!baseIsAncestor) {
779
+ return {
780
+ detailedReason: 'base_divergence',
781
+ detailedReasonDescription: `Worktree base has diverged from ${targetBaseRef} (HEAD is not a descendant; ahead ${ahead}, behind ${behind}).`,
782
+ recommendedAction: `Rebase the branch onto ${targetBaseRef}, then retry mesh_refine_node.`,
783
+ evidence,
784
+ };
785
+ }
786
+
787
+ // 5. actual_patch_diff: genuine non-equivalent content.
788
+ return {
789
+ detailedReason: 'actual_patch_diff',
790
+ detailedReasonDescription: 'The merge introduces content not equivalent to the branch\'s cumulative patch (expected tree vs actual merge diff differ).',
791
+ recommendedAction: 'Manual review required — inspect the residual diff; the branch content is not patch-equivalent to a clean merge onto the base.',
792
+ evidence,
793
+ };
794
+ } catch (e: any) {
795
+ evidence.classifierError = e?.message || String(e);
796
+ return {
797
+ detailedReason: 'unclassified',
798
+ detailedReasonDescription: 'Patch-equivalence sub-cause could not be classified (git inspection failed); see classifierError.',
799
+ recommendedAction: 'Inspect the refineStages and patchEquivalence summary manually to determine the cause.',
800
+ evidence,
801
+ };
802
+ }
803
+ }
804
+
805
+ /** Small helper: run a git command in `cwd` and return whether it exited 0. */
806
+ function execGitOk(cwd: string, args: string[]): boolean {
807
+ try {
808
+ execFileSync(GIT, args, { cwd, encoding: 'utf8', maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES, windowsHide: true });
809
+ return true;
810
+ } catch {
811
+ return false;
812
+ }
813
+ }
814
+
559
815
  export type MeshWorktreePatchContainmentSummary = {
560
816
  /** True only when merging worktreeHead into ref introduces no new patch. */
561
817
  contained: boolean;
@@ -16,6 +16,7 @@ import { normalizeInteractivePrompt, normalizeInteractivePromptResponse, type In
16
16
  import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
17
17
  import { shortHash } from '../system/hash.js';
18
18
  import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
19
+ import type { MeshSendKeyItem, MeshSendKeyName } from '../cli-adapters/provider-cli-shared.js';
19
20
  import { createCliAdapter } from './spec/route.js';
20
21
  import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
21
22
  import { StatusMonitor } from './status-monitor.js';
@@ -252,6 +253,16 @@ export class CliProviderInstance implements ProviderInstance {
252
253
  */
253
254
  private static readonly APPROVAL_RESUME_GRACE_MS = 18_000;
254
255
 
256
+ // MESH-STALL-WATCH (feature 1: STALL detection): how long a coordinator-spawned
257
+ // mesh worker's raw PTY output (lastOutputAt) may stay unchanged before the
258
+ // status-agnostic stall watchdog fires ONE informational monitor:no_progress
259
+ // event. Unlike the StatusMonitor no-progress watchdog (which only runs while a
260
+ // turn is generating), this observes pure screen stasis regardless of the
261
+ // reported status — a worker parked idle, wedged mid-turn, or spawned with no
262
+ // output at all. 180s matches DEFAULT_MONITOR_CONFIG.noProgressThresholdSec so
263
+ // the two watchdogs agree on the same "long interval" bound.
264
+ private static readonly MESH_WORKER_STALL_THRESHOLD_MS = 180_000;
265
+
255
266
  private adapter: ProviderCliAdapter;
256
267
  private context: InstanceContext | null = null;
257
268
  private events: ProviderEvent[] = [];
@@ -265,6 +276,16 @@ export class CliProviderInstance implements ProviderInstance {
265
276
  // first sets it; the other becomes a no-op.
266
277
  private agentReadyEmitted = false;
267
278
  private generatingStartedAt: number = 0;
279
+ // MESH-STALL-WATCH (feature 1): the lastOutputAt value the stall episode is
280
+ // currently armed against. A stall episode is "the raw PTY output has not
281
+ // advanced past this anchor". When the adapter has never emitted output
282
+ // (lastOutputAt === 0) the anchor is the spawn time (this.startedAt) so a
283
+ // worker that produced NOTHING is still caught. On any new output the anchor
284
+ // re-arms to the fresh lastOutputAt and meshStallEmittedForAnchor resets, so a
285
+ // single continuous stall fires AT MOST ONCE and a later stall re-arms cleanly.
286
+ // -1 = not yet initialised for this session.
287
+ private meshStallAnchorAt = -1;
288
+ private meshStallEmittedForAnchor = false;
268
289
  // FALSE-IDLE continuity epoch: monotonically bumped on EVERY entry into a busy
269
290
  // phase (→generating or →waiting_approval). The completedDebouncePending snapshots
270
291
  // this value at arm time (busyEpochAtArm); the flush guard requires it UNCHANGED
@@ -1506,6 +1527,26 @@ export class CliProviderInstance implements ProviderInstance {
1506
1527
  return '';
1507
1528
  }
1508
1529
 
1530
+ /**
1531
+ * NOTIF Defect-B: the final assistant summary this instance ALREADY parsed and
1532
+ * cached for the current turn (lastCompletionSummary), if any. The evidence
1533
+ * probe (completionFinalAssistantEvidence) is a POINT-SAMPLE: on a native-source
1534
+ * provider (antigravity) the parsed screen and the native transcript can both
1535
+ * momentarily yield no in-turn final assistant at the exact instant the
1536
+ * completion gate fires — source='unavailable', missingEvidence=true — even
1537
+ * though a prior poll already read the real answer off native-history and cached
1538
+ * it here (the same value mesh_read_chat.summary shows). Consulting the cache at
1539
+ * emit time lets that already-secured summary count as evidence, so the completion
1540
+ * notification carries the answer instead of completion_diagnostic=missing_final_assistant
1541
+ * with an empty summary. Returns '' when the cache is empty or was reset by the
1542
+ * next turn (see lastCompletionSummary = null on onTurnStarted).
1543
+ */
1544
+ private cachedCompletionSummaryContent(): string {
1545
+ const cached = this.lastCompletionSummary;
1546
+ const content = typeof cached?.content === 'string' ? cached.content.trim() : '';
1547
+ return content;
1548
+ }
1549
+
1509
1550
  private completionFinalAssistantEvidence(parsedMessages: unknown, turnStartedAt?: number): CompletionFinalAssistantEvidence {
1510
1551
  // (FALSEIDLE FixB) UPPER-BOUND turn-end evidence. completionHasFinalAssistantMessage is a
1511
1552
  // pure message-content check ("does the last visible bubble read as a finalized assistant
@@ -1658,12 +1699,39 @@ export class CliProviderInstance implements ProviderInstance {
1658
1699
  const lastVisibleKind = typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null;
1659
1700
  const lastVisibleContentLength = lastVisible ? flattenContent(lastVisible.content).trim().length : 0;
1660
1701
 
1702
+ // NOTIF Defect-B: when the live evidence probe momentarily yields no in-turn
1703
+ // final assistant (source='unavailable'/external-native with present=false) but
1704
+ // a prior poll already parsed and CACHED the real answer for this turn
1705
+ // (lastCompletionSummary — the same value mesh_read_chat.summary surfaces),
1706
+ // credit the cache as evidence. This flips finalAssistantPresent to true and
1707
+ // records the cached source so the completion notification carries
1708
+ // completion_diagnostic=present with the summary, instead of
1709
+ // missing_final_assistant with an empty payload. Only ever UPGRADES a
1710
+ // point-sample miss — a genuine present=true is unchanged, and an empty cache
1711
+ // leaves the missing-evidence diagnostic exactly as before.
1712
+ const cachedSummary = evidence.present ? '' : this.cachedCompletionSummaryContent();
1713
+ const creditedFromCache = !evidence.present && cachedSummary.length > 0;
1714
+ const finalAssistantPresent = evidence.present || creditedFromCache;
1715
+ const finalAssistantEvidenceSource = evidence.present
1716
+ ? evidence.source
1717
+ : (creditedFromCache ? 'cached-summary' : evidence.source);
1718
+ // When the cached summary rescues the evidence, the turn is no longer
1719
+ // "missing final assistant" — clear that blockReason so isMissingFinalAssistant‑
1720
+ // Diagnostic()/isWeakCompletionEvidence() no longer flag it (both key off
1721
+ // blockReason='missing_final_assistant' independently of finalAssistantPresent)
1722
+ // and the coordinator log's formatCompletionMetadata reads
1723
+ // completion_diagnostic=present (empty blockReason → 'present'). The ORIGINAL
1724
+ // reason is preserved under originalBlockReason for diagnostics.
1725
+ const clearMissingBlock = creditedFromCache && args.blockReason === 'missing_final_assistant';
1726
+ const effectiveBlockReason = clearMissingBlock ? undefined : args.blockReason;
1727
+
1661
1728
  return {
1662
1729
  providerType: this.type,
1663
1730
  sessionId: this.instanceId,
1664
1731
  providerSessionId: this.providerSessionId || null,
1665
1732
  workspace: this.workingDir,
1666
- blockReason: args.blockReason,
1733
+ ...(effectiveBlockReason ? { blockReason: effectiveBlockReason } : {}),
1734
+ ...(clearMissingBlock ? { originalBlockReason: args.blockReason } : {}),
1667
1735
  emittedAfterFinalizationTimeout: args.emittedAfterFinalizationTimeout,
1668
1736
  waitedMs: args.waitedMs,
1669
1737
  maxWaitMs: COMPLETED_FINALIZATION_MAX_WAIT_MS,
@@ -1671,8 +1739,9 @@ export class CliProviderInstance implements ProviderInstance {
1671
1739
  latestVisibleStatus: args.latestVisibleStatus,
1672
1740
  parsedStatus: typeof parsed?.status === 'string' ? parsed.status : (parseError ? 'parse_error' : 'unknown'),
1673
1741
  parseError: parseError || undefined,
1674
- finalAssistantPresent: evidence.present,
1675
- finalAssistantEvidenceSource: evidence.source,
1742
+ finalAssistantPresent,
1743
+ finalAssistantFromCachedSummary: !evidence.present && cachedSummary.length > 0,
1744
+ finalAssistantEvidenceSource,
1676
1745
  visibleMessageCount: visibleMessages.length,
1677
1746
  lastVisibleRole,
1678
1747
  lastVisibleKind,
@@ -1966,6 +2035,188 @@ export class CliProviderInstance implements ProviderInstance {
1966
2035
  || this.settings.meshNodeId || this.settings.launchedByCoordinator);
1967
2036
  }
1968
2037
 
2038
+ /**
2039
+ * MESH-READ-TERMINAL (feature 2: RAW terminal read). Public read of the
2040
+ * CURRENT rendered PTY viewport for the mesh_read_terminal tool, delegating to
2041
+ * the adapter's narrow getTerminalScreenSnapshot() (viewport + cursor + size
2042
+ * only; no debug buffers / parser state / history; byte-bounded, bottom-tail
2043
+ * preserved).
2044
+ *
2045
+ * Gated on isMeshWorkerSession(): this raw viewport can expose tokens /
2046
+ * command args / env / user data, so only a coordinator-spawned worker session
2047
+ * is readable. The MCP layer ALSO cross-checks mesh/session/node ownership
2048
+ * (isMeshOwnedDelegateSession) — isMeshWorkerSession alone is a broad
2049
+ * "delegated" gate, so the two together block cross-mesh access. Returns null
2050
+ * for a non-mesh session so the daemon command surfaces a clean refusal.
2051
+ */
2052
+ getTerminalScreenSnapshot(maxBytes?: number): {
2053
+ text: string;
2054
+ cursor: { col: number; row: number };
2055
+ cols: number;
2056
+ rows: number;
2057
+ truncated: boolean;
2058
+ originalBytes: number;
2059
+ returnedBytes: number;
2060
+ hash: string;
2061
+ } | null {
2062
+ if (!this.isMeshWorkerSession()) return null;
2063
+ // Defensive: not every CliAdapter implementation exposes the raw-terminal
2064
+ // read (the surface is declared optional on CliAdapter). Returning null
2065
+ // for an adapter that lacks it surfaces a clean unsupported refusal at
2066
+ // the daemon command instead of "getTerminalScreenSnapshot is not a
2067
+ // function" — the failure mode that broke mesh_read_terminal on the
2068
+ // spec-driven path before SpecCliAdapter implemented it.
2069
+ if (typeof this.adapter.getTerminalScreenSnapshot !== 'function') return null;
2070
+ return this.adapter.getTerminalScreenSnapshot(maxBytes);
2071
+ }
2072
+
2073
+ /**
2074
+ * MESH-SEND-KEYS (feature 3: key injection). Public entry for the
2075
+ * mesh_send_keys tool, delegating to the adapter's injectKeys() (structured
2076
+ * key encoding + atomic write + submit-race recheck + modal fail-closed).
2077
+ *
2078
+ * Gated on isMeshWorkerSession(): PTY input into a worker is a
2079
+ * coordinator-only capability. The MCP layer ALSO cross-checks mesh/session/
2080
+ * node ownership (isMeshOwnedDelegateSession) and owns the destructive-key
2081
+ * double gate (confirm_destructive + policy) and the audit ledger. Returns a
2082
+ * refusal object for a non-mesh session so the daemon command surfaces a clean
2083
+ * error (never silently writes to a non-worker PTY).
2084
+ */
2085
+ async injectKeys(
2086
+ items: MeshSendKeyItem[],
2087
+ opts: { allowModalOverride?: boolean } = {},
2088
+ ): Promise<
2089
+ | { ok: true; keys: MeshSendKeyName[]; hasDestructive: boolean; submits: boolean; bytes: number }
2090
+ | { ok: false; refused: 'submit_race' | 'actionable_modal' | 'not_mesh_worker' | 'unsupported'; keys: MeshSendKeyName[]; hasDestructive: boolean }
2091
+ > {
2092
+ if (!this.isMeshWorkerSession()) {
2093
+ return { ok: false, refused: 'not_mesh_worker', keys: [], hasDestructive: false };
2094
+ }
2095
+ // Defensive: injectKeys is optional on CliAdapter. An adapter without it
2096
+ // yields a clean 'unsupported' refusal instead of throwing
2097
+ // "injectKeys is not a function" — the failure that broke mesh_send_keys
2098
+ // on the spec-driven path before SpecCliAdapter implemented it.
2099
+ if (typeof this.adapter.injectKeys !== 'function') {
2100
+ return { ok: false, refused: 'unsupported', keys: [], hasDestructive: false };
2101
+ }
2102
+ return this.adapter.injectKeys(items, opts);
2103
+ }
2104
+
2105
+ /**
2106
+ * MESH-STALL-WATCH (feature 1: STALL detection). Status-agnostic stall
2107
+ * watchdog for coordinator-spawned mesh worker sessions. Driven by the
2108
+ * ProviderInstanceManager's existing 5s onTick loop (NO new timer) — see
2109
+ * ProviderInstanceManager.startTicking. Reuses the adapter's raw-PTY-output
2110
+ * clock (lastOutputAt, bumped on every output chunk) as the sole signal: if a
2111
+ * live worker's screen has been byte-for-byte unchanged for
2112
+ * MESH_WORKER_STALL_THRESHOLD_MS (180s), fire ONE informational
2113
+ * monitor:no_progress event down the existing task_stalled ledger +
2114
+ * pendingCoordinatorEvent path.
2115
+ *
2116
+ * Deliberately status-agnostic: it does NOT read getStatus()'s reported status
2117
+ * (which would couple it to the generating-only StatusMonitor and the
2118
+ * idle-timeout FSM). A normally-idle worker CAN trip this after 3 quiet
2119
+ * minutes; that is accepted and surfaced as an informational stall (NOT a
2120
+ * failure/auto-restart) so the coordinator judges. getStatus/getState are NOT
2121
+ * called here, so status heartbeats never move the stall anchor.
2122
+ *
2123
+ * Anchoring: the episode arms against the current lastOutputAt; a worker that
2124
+ * has emitted nothing yet (lastOutputAt === 0) anchors on this.startedAt (spawn
2125
+ * time) so a silent spawn is still caught. Any new output re-arms the anchor
2126
+ * and clears the emitted flag, so one continuous stall emits at most once and a
2127
+ * later stall re-arms cleanly.
2128
+ */
2129
+ checkMeshWorkerStall(now: number = Date.now()): void {
2130
+ if (!this.isMeshWorkerSession()) {
2131
+ // Not (or no longer) a mesh worker — drop any armed episode so a session
2132
+ // whose mesh markers were detached does not carry a stale anchor.
2133
+ this.meshStallAnchorAt = -1;
2134
+ this.meshStallEmittedForAnchor = false;
2135
+ return;
2136
+ }
2137
+ // Defensive: not every CliAdapter implementation exposes isAlive() — the
2138
+ // spec-driven adapter (SpecCliAdapter, used by native-source providers like
2139
+ // antigravity-cli) historically had none, so an unguarded call threw
2140
+ // `this.adapter.isAlive is not a function` on EVERY 5s tick, disabling stall
2141
+ // detection for those sessions entirely. A missing method is treated as alive
2142
+ // (the session lifecycle drops the anchor via isMeshWorkerSession()/exit paths),
2143
+ // never as a throw. When present, a dead process drops the armed episode.
2144
+ if (typeof this.adapter.isAlive === 'function' && !this.adapter.isAlive()) {
2145
+ // Dead PTY: nothing to watch. agent:stopped covers the exit; re-arm on
2146
+ // the next live session so a restart starts a fresh episode.
2147
+ this.meshStallAnchorAt = -1;
2148
+ this.meshStallEmittedForAnchor = false;
2149
+ return;
2150
+ }
2151
+
2152
+ let lastOutputAt: number;
2153
+ try {
2154
+ // allowParse:false — a cheap status read that must NOT trigger parsing
2155
+ // and must NOT mutate lastOutputAt (getState/getStatus never bump it).
2156
+ const status = this.adapter.getStatus({ allowParse: false }) as { lastOutputAt?: unknown };
2157
+ lastOutputAt = typeof status?.lastOutputAt === 'number' && Number.isFinite(status.lastOutputAt)
2158
+ ? status.lastOutputAt
2159
+ : 0;
2160
+ } catch {
2161
+ return; // defensive: a failed status read just skips this tick
2162
+ }
2163
+
2164
+ // The anchor is the last raw output; before any output, the spawn time so a
2165
+ // silent worker is still caught.
2166
+ const anchor = lastOutputAt > 0 ? lastOutputAt : this.startedAt;
2167
+
2168
+ if (this.meshStallAnchorAt === -1) {
2169
+ // First observation this session — arm against the current anchor.
2170
+ this.meshStallAnchorAt = anchor;
2171
+ this.meshStallEmittedForAnchor = false;
2172
+ return;
2173
+ }
2174
+
2175
+ if (anchor > this.meshStallAnchorAt) {
2176
+ // New output advanced the clock — re-arm the episode against it.
2177
+ this.meshStallAnchorAt = anchor;
2178
+ this.meshStallEmittedForAnchor = false;
2179
+ return;
2180
+ }
2181
+
2182
+ if (this.meshStallEmittedForAnchor) return; // already fired for this stall
2183
+
2184
+ const stalledMs = now - this.meshStallAnchorAt;
2185
+ if (stalledMs < CliProviderInstance.MESH_WORKER_STALL_THRESHOLD_MS) return;
2186
+
2187
+ this.meshStallEmittedForAnchor = true;
2188
+
2189
+ // observedStatus is surfaced as context only — deliberately NOT stamped as
2190
+ // the reconciliation-triggering `status` field (which would let
2191
+ // buildNoProgressCompletionReconciliation mistake an idle stall for a
2192
+ // completion). See mesh-events-stale.buildNoProgressCompletionReconciliation.
2193
+ let observedStatus = 'unknown';
2194
+ try {
2195
+ const s = (this.adapter.getStatus({ allowParse: false }) as { status?: unknown })?.status;
2196
+ if (typeof s === 'string' && s) observedStatus = s;
2197
+ } catch { /* best-effort context only */ }
2198
+
2199
+ if (this.isMeshWorkerSession()) {
2200
+ traceMeshEventStage('fired', this.meshTraceCtx('monitor:no_progress'), 'mesh_worker_stall_watchdog');
2201
+ }
2202
+
2203
+ const stalledSec = Math.round(stalledMs / 1000);
2204
+ this.pushEvent({
2205
+ event: 'monitor:no_progress',
2206
+ agentKey: `${this.type}:cli`,
2207
+ elapsedSec: stalledSec,
2208
+ timestamp: now,
2209
+ // MESH-STALL-WATCH marker: buildMeshSystemMessage generalizes the
2210
+ // coordinator message (generating-specific → "PTY output unchanged")
2211
+ // when this is set, since this watchdog fires status-agnostically.
2212
+ meshWorkerStall: true,
2213
+ lastOutputAt: this.meshStallAnchorAt,
2214
+ stalledMs,
2215
+ observedStatus,
2216
+ taskId: this.completingTurnTaskId(),
2217
+ });
2218
+ }
2219
+
1969
2220
  /**
1970
2221
  * AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
1971
2222
  * persist before the in-progress settle gate is torn down. For a delegated
@@ -2359,9 +2610,15 @@ export class CliProviderInstance implements ProviderInstance {
2359
2610
  // delegated session's inbox preview blank — or, for a LOCAL worktree session,
2360
2611
  // stuck on the dispatched user task. If the parser DID surface assistant text,
2361
2612
  // prefer it; only fall back to '' when no assistant summary can be derived.
2362
- finalSummary: blockReason.startsWith('parsed_status:')
2363
- ? (this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt) ?? '')
2364
- : this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt),
2613
+ // NOTIF Defect-B: completionFinalSummary is a point-sample of native-history/
2614
+ // screen at THIS instant; on a native-source provider (antigravity) it can be
2615
+ // empty at the forced-emit instant even though a prior poll already cached the
2616
+ // real answer (lastCompletionSummary). Fall back to the cache so the notification
2617
+ // carries the summary that mesh_read_chat.summary already shows — consistent with
2618
+ // completionDiagnostic.finalAssistantPresent being credited from the same cache.
2619
+ finalSummary: (this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt)
2620
+ || this.cachedCompletionSummaryContent()
2621
+ || (blockReason.startsWith('parsed_status:') ? '' : undefined)),
2365
2622
  completionDiagnostic,
2366
2623
  });
2367
2624
  this.completedDebouncePending = null;
@@ -3527,6 +3784,20 @@ export class CliProviderInstance implements ProviderInstance {
3527
3784
  this.completedDebouncePending = null;
3528
3785
  continue;
3529
3786
  }
3787
+ // MESH-STALL-WATCH dedupe: for a coordinator-spawned mesh worker the
3788
+ // status-agnostic stall watchdog (checkMeshWorkerStall) owns the
3789
+ // monitor:no_progress alert. The StatusMonitor here fires its OWN
3790
+ // generating-only no-progress on the SAME 180s bound, which would
3791
+ // double-emit into the task_stalled ledger + coordinator inbox for the
3792
+ // one stall. Suppress the StatusMonitor's copy for mesh workers only —
3793
+ // the completion-reconciliation branch above (which turns a no-progress
3794
+ // WITH final-assistant into a real completion) still runs, so genuine
3795
+ // idle-reconciled completions are unaffected. Non-mesh sessions keep the
3796
+ // original StatusMonitor behavior untouched.
3797
+ if (me.type === 'monitor:no_progress' && this.isMeshWorkerSession()) {
3798
+ traceMeshEventDrop('mesh_worker_stall_watchdog_owns_no_progress', this.meshTraceCtx('monitor:no_progress'));
3799
+ continue;
3800
+ }
3530
3801
  this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
3531
3802
  }
3532
3803
  }
@@ -211,7 +211,18 @@ export class ProviderInstanceManager {
211
211
  this.tickInterval = intervalMs || this.tickInterval;
212
212
 
213
213
  this.tickTimer = setInterval(async () => {
214
+ const now = Date.now();
214
215
  for (const [id, instance] of this.instances) {
216
+ // MESH-STALL-WATCH (feature 1: STALL detection): a status-agnostic
217
+ // watchdog for coordinator-spawned mesh worker sessions, driven by
218
+ // THIS existing 5s tick (no separate timer). Cheap (reads the
219
+ // adapter's raw-output clock only) and a no-op for non-mesh /
220
+ // non-CLI instances, so it runs before the per-instance onTick.
221
+ try {
222
+ instance.checkMeshWorkerStall?.(now);
223
+ } catch (e) {
224
+ LOG.warn('InstanceMgr', `[InstanceManager] Mesh stall check failed for ${id}: ${(e as Error).message}`);
225
+ }
215
226
  try {
216
227
  await instance.onTick();
217
228
  } catch (e) {
@@ -195,6 +195,16 @@ export interface ProviderInstance {
195
195
  /** Tick — periodic status refresh (IDE: readChat, Extension: stream collection) */
196
196
  onTick(): Promise<void>;
197
197
 
198
+ /**
199
+ * MESH-STALL-WATCH (feature 1: STALL detection). Status-agnostic stall
200
+ * watchdog for coordinator-spawned mesh worker sessions, invoked from the
201
+ * ProviderInstanceManager's existing tick loop (no separate timer). Fires ONE
202
+ * informational monitor:no_progress event when a live worker's raw PTY output
203
+ * has been unchanged past the stall threshold. Optional — only CLI instances
204
+ * (which own a PTY / lastOutputAt clock) implement it; a no-op elsewhere.
205
+ */
206
+ checkMeshWorkerStall?(now?: number): void;
207
+
198
208
  /** Return current status */
199
209
  getState(): ProviderState;
200
210