@adhdev/daemon-core 1.0.18-rc.2 β†’ 1.0.18-rc.20

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 (82) hide show
  1. package/dist/cli-adapters/cli-state-engine.d.ts +77 -0
  2. package/dist/cli-adapters/provider-cli-shared.d.ts +33 -0
  3. package/dist/cli-adapters/pty-transport.d.ts +2 -1
  4. package/dist/commands/cli-manager.d.ts +9 -0
  5. package/dist/commands/process-lifecycle.d.ts +43 -0
  6. package/dist/commands/upgrade-helper.d.ts +1 -0
  7. package/dist/commands/windows-atomic-upgrade.d.ts +17 -1
  8. package/dist/config/mesh-json-config.d.ts +62 -0
  9. package/dist/index.d.ts +4 -2
  10. package/dist/index.js +6674 -4237
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +6361 -3927
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  15. package/dist/mesh/mesh-active-work.d.ts +1 -1
  16. package/dist/mesh/mesh-disk-retention.d.ts +105 -0
  17. package/dist/mesh/mesh-ledger.d.ts +28 -1
  18. package/dist/mesh/mesh-node-identity.d.ts +23 -0
  19. package/dist/mesh/mesh-refine-gates.d.ts +89 -0
  20. package/dist/mesh/mesh-remote-event-pull.d.ts +3 -0
  21. package/dist/mesh/mesh-runtime-store.d.ts +11 -0
  22. package/dist/mesh/mesh-work-queue.d.ts +24 -1
  23. package/dist/providers/auto-approve-modes.d.ts +14 -0
  24. package/dist/providers/chat-message-normalization.d.ts +22 -0
  25. package/dist/providers/cli-provider-instance-types.d.ts +4 -0
  26. package/dist/providers/cli-provider-instance.d.ts +78 -0
  27. package/dist/providers/contracts.d.ts +17 -0
  28. package/dist/providers/provider-schema.d.ts +1 -0
  29. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
  30. package/dist/providers/types/interactive-prompt.d.ts +18 -0
  31. package/dist/repo-mesh-types.d.ts +38 -6
  32. package/dist/shared-types.d.ts +4 -2
  33. package/package.json +3 -3
  34. package/src/boot/daemon-lifecycle.ts +10 -0
  35. package/src/cli-adapters/cli-state-engine.ts +220 -3
  36. package/src/cli-adapters/provider-cli-adapter.ts +41 -11
  37. package/src/cli-adapters/provider-cli-shared.ts +48 -0
  38. package/src/cli-adapters/pty-transport.d.ts +2 -1
  39. package/src/cli-adapters/pty-transport.ts +1 -1
  40. package/src/cli-adapters/session-host-transport.ts +8 -3
  41. package/src/commands/cli-manager.ts +72 -8
  42. package/src/commands/high-family/mesh-coordinator-launch.ts +17 -2
  43. package/src/commands/high-family/mesh-events.ts +13 -2
  44. package/src/commands/med-family/mesh-crud.ts +155 -0
  45. package/src/commands/med-family/mesh-queue.ts +74 -1
  46. package/src/commands/process-lifecycle.ts +248 -0
  47. package/src/commands/router-refine.ts +71 -4
  48. package/src/commands/router.ts +8 -0
  49. package/src/commands/upgrade-helper.ts +106 -82
  50. package/src/commands/windows-atomic-upgrade.ts +281 -35
  51. package/src/config/mesh-json-config.ts +111 -0
  52. package/src/index.ts +21 -1
  53. package/src/mesh/coordinator-prompt.ts +258 -11
  54. package/src/mesh/mesh-active-work.ts +11 -1
  55. package/src/mesh/mesh-completion-synthesis.ts +12 -1
  56. package/src/mesh/mesh-disk-retention.ts +370 -0
  57. package/src/mesh/mesh-event-classify.ts +19 -0
  58. package/src/mesh/mesh-event-forwarding.ts +64 -6
  59. package/src/mesh/mesh-events-utils.ts +42 -0
  60. package/src/mesh/mesh-ledger.ts +77 -0
  61. package/src/mesh/mesh-node-identity.ts +49 -0
  62. package/src/mesh/mesh-queue-assignment.ts +210 -11
  63. package/src/mesh/mesh-reconcile-loop.ts +306 -3
  64. package/src/mesh/mesh-refine-gates.ts +300 -0
  65. package/src/mesh/mesh-remote-event-pull.ts +68 -47
  66. package/src/mesh/mesh-runtime-store.ts +21 -0
  67. package/src/mesh/mesh-work-queue.ts +85 -2
  68. package/src/providers/auto-approve-modes.ts +103 -0
  69. package/src/providers/chat-message-normalization.ts +53 -0
  70. package/src/providers/cli-provider-instance-types.ts +53 -0
  71. package/src/providers/cli-provider-instance.ts +497 -15
  72. package/src/providers/contracts.ts +25 -1
  73. package/src/providers/provider-schema.ts +83 -0
  74. package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
  75. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
  76. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +44 -0
  77. package/src/providers/types/interactive-prompt.ts +77 -0
  78. package/src/repo-mesh-types.ts +112 -12
  79. package/src/session-host/managed-host.ts +34 -0
  80. package/src/shared-types.ts +9 -1
  81. package/src/status/reporter.ts +1 -0
  82. package/src/status/snapshot.ts +2 -0
@@ -34,6 +34,7 @@ import type {
34
34
  import { mergeAndNormalizePolicy, resolveProviderMaxParallel } from '../repo-mesh-types.js';
35
35
  import { getDifficultyBrains } from '../config/mesh-config.js';
36
36
  import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
37
+ import { isNoteExpired, OPERATING_NOTE_CATEGORY_TTL_DAYS } from './mesh-ledger.js';
37
38
  import { MESH_TASK_DIFFICULTIES } from '@adhdev/mesh-shared';
38
39
  import type { MagiKindPanelMap, MagiSlot, MagiTaskKind } from '@adhdev/mesh-shared';
39
40
 
@@ -84,6 +85,41 @@ export interface CoordinatorOperatingNote {
84
85
  category?: 'provider_quirk' | 'pattern_to_avoid' | 'recovery_lesson';
85
86
  createdAt?: string;
86
87
  sourceCoordinator?: string;
88
+ /**
89
+ * Operating-notes lifecycle (minimal first cut). When true the note ALWAYS
90
+ * rides into the coordinator prompt β€” it is never dropped by TTL expiry and
91
+ * survives the injection cap ahead of unpinned notes. Legacy notes without
92
+ * this field default to false.
93
+ */
94
+ pinned?: boolean;
95
+ /**
96
+ * Optional explicit expiry ISO timestamp. When set and in the past, an
97
+ * UNPINNED note is dropped from the injected prompt (read-side only β€” the
98
+ * ledger entry is never pruned by age). When absent, the category→TTL map
99
+ * (see isNoteExpired) governs expiry; pinned notes never expire regardless.
100
+ */
101
+ expiresAt?: string;
102
+ /**
103
+ * Phase 2 β€” the ledger id of this note, when known. Threaded from the ledger
104
+ * entry id at launch so version-supersede can target a specific note and
105
+ * same-class folding can list the subsumed ids. Repo-declared notes may lack
106
+ * one; absent is fine (folding/supersede fall back to the subject key).
107
+ */
108
+ noteId?: string;
109
+ /**
110
+ * Phase 2 (b) version-supersede β€” an optional note-id or stable subject-key
111
+ * this note replaces. At injection, any earlier LIVE note whose `noteId` or
112
+ * `subjectKey` matches this value is hidden from the prompt (the ledger entry
113
+ * is retained for audit). Optional and lossless: absent = supersedes nothing.
114
+ */
115
+ supersedes?: string;
116
+ /**
117
+ * Phase 2 (b)/(c) β€” an optional stable subject-key grouping notes about the
118
+ * same subject. Drives version-supersede targeting and same-class folding.
119
+ * When absent, folding derives a key from a leading `[tag]` bracket instead,
120
+ * so legacy notes still collapse by their conventional tag prefix.
121
+ */
122
+ subjectKey?: string;
87
123
  }
88
124
 
89
125
  // ─── Prompt Builder ─────────────────────────────
@@ -582,33 +618,243 @@ function buildRecentActivitySection(activity?: CoordinatorRecentActivity): strin
582
618
  const OPERATING_NOTES_PROMPT_CAP = 20;
583
619
  const OPERATING_NOTE_MAX_CHARS = 300;
584
620
 
585
- function buildOperatingNotesSection(notes?: CoordinatorOperatingNote[]): string {
621
+ /**
622
+ * Phase 2 (d) β€” byte budget for the injected Operating Notes list. The count cap
623
+ * above (20) is still a ceiling, but selection now fills up to this UTF-8 byte
624
+ * budget on the RENDERED note lines, ranked pinned > durable > recency, so a few
625
+ * long notes don't crowd out many short ones and vice-versa. Pinned notes are
626
+ * ALWAYS kept even if they alone exceed the budget (pinned is author-opt-in and
627
+ * bounded by the author); the budget only bounds the unpinned tail.
628
+ *
629
+ * ~6KB β‰ˆ the 20-note cap at typical note length, so on ordinary meshes this
630
+ * changes nothing β€” it only kicks in when notes run long. It sits well under the
631
+ * whole-prompt PROMPT_SOFT_CAP_BYTES (60KB) so the two caps don't fight.
632
+ */
633
+ const OPERATING_NOTE_INJECTION_BYTE_BUDGET = 6 * 1024;
634
+
635
+ /**
636
+ * A category is "durable" (survives the cap ahead of recency) when it has no
637
+ * TTL entry β€” provider_quirk, uncategorized, or any unknown category. This
638
+ * mirrors isNoteExpired's durability rule so ranking and expiry agree.
639
+ */
640
+ function isDurableCategory(category?: string): boolean {
641
+ if (!category) return true;
642
+ return !(category in OPERATING_NOTE_CATEGORY_TTL_DAYS);
643
+ }
644
+
645
+ /**
646
+ * Phase 2 (b)/(c) β€” derive a note's subject key for supersede targeting and
647
+ * same-class folding. Precedence:
648
+ * 1. an explicit `subjectKey` (trimmed, lowercased) when present, else
649
+ * 2. a leading `[tag]` bracket, so legacy notes that follow the conventional
650
+ * `[some-tag] …` prefix still group/supersede by that tag, else
651
+ * 3. undefined β€” the note has no derivable subject and never folds/supersedes
652
+ * by key (it can still be targeted by exact noteId).
653
+ * Pure. Lowercased so `[Foo]` and `[foo]` collapse together.
654
+ */
655
+ function deriveSubjectKey(note: CoordinatorOperatingNote): string | undefined {
656
+ const explicit = typeof note.subjectKey === 'string' ? note.subjectKey.trim().toLowerCase() : '';
657
+ if (explicit) return explicit;
658
+ const text = typeof note.text === 'string' ? note.text.trimStart() : '';
659
+ const m = /^\[([^\]]{1,80})\]/.exec(text);
660
+ const tag = m ? m[1].trim().toLowerCase() : '';
661
+ return tag || undefined;
662
+ }
663
+
664
+ /**
665
+ * Phase 2 (c) β€” deterministic same-class fold. Given ranked notes (already
666
+ * highest-priority first), collapse runs of UNPINNED notes that share the same
667
+ * category AND subject key into a single injected entry: keep the highest-ranked
668
+ * (first-seen) note and record the note-ids/count it subsumes so the rendered
669
+ * line can say "(+N earlier)". Pinned notes never fold β€” each pinned note is
670
+ * author-opted-in and shown verbatim. The store is untouched; this is a pure,
671
+ * model-free text fold at injection time.
672
+ *
673
+ * Returns the folded note list (order preserved) with per-entry subsumed info.
674
+ */
675
+ interface FoldedNote {
676
+ note: CoordinatorOperatingNote;
677
+ /** note-ids of same-class notes this entry subsumes (may be empty). */
678
+ subsumedIds: string[];
679
+ /** count of subsumed notes (== subsumedIds.length, but counts id-less ones too). */
680
+ subsumedCount: number;
681
+ }
682
+
683
+ function foldSameClassNotes(ranked: CoordinatorOperatingNote[]): FoldedNote[] {
684
+ const out: FoldedNote[] = [];
685
+ // group key β†’ index into `out` of the surviving (highest-ranked) entry.
686
+ const survivorByKey = new Map<string, number>();
687
+ for (const note of ranked) {
688
+ const subject = deriveSubjectKey(note);
689
+ // Only unpinned notes with a category AND a subject key are foldable β€”
690
+ // without both there is no reliable "same class/subject" signal, so we
691
+ // keep the note standalone (lossless for legacy/uncategorized notes).
692
+ const foldable = !note.pinned && !!note.category && !!subject;
693
+ if (foldable) {
694
+ const key = `${note.category}${subject}`;
695
+ const existing = survivorByKey.get(key);
696
+ if (existing !== undefined) {
697
+ const survivor = out[existing];
698
+ survivor.subsumedCount += 1;
699
+ if (typeof note.noteId === 'string' && note.noteId) survivor.subsumedIds.push(note.noteId);
700
+ continue;
701
+ }
702
+ survivorByKey.set(key, out.length);
703
+ }
704
+ out.push({ note, subsumedIds: [], subsumedCount: 0 });
705
+ }
706
+ return out;
707
+ }
708
+
709
+ /** Estimate the UTF-8 byte cost of one rendered operating-note line (Phase 2 d). */
710
+ function renderedNoteBytes(folded: FoldedNote): number {
711
+ return byteLength(renderOperatingNoteLine(folded));
712
+ }
713
+
714
+ /**
715
+ * Operating-notes lifecycle selection (read-side). Given the effective notes
716
+ * (oldest-first, ledger order) and the current time, produce the ordered list
717
+ * that rides into the prompt:
718
+ * (i) ALWAYS include pinned notes.
719
+ * (ii) drop expired UNPINNED notes (per category TTL / explicit expiresAt).
720
+ * (iii) Phase 2 (b) drop any note SUPERSEDED by a later live note (by noteId
721
+ * or subjectKey); pinned notes are never dropped by supersede.
722
+ * (iv) rank pinned-first, then durable-category, then recency (newest first).
723
+ * (v) Phase 2 (c) fold same-category/same-subject unpinned runs into one.
724
+ * (vi) Phase 2 (d) fill up to a byte budget AND a count cap; pinned always
725
+ * kept even if they alone exceed the byte budget.
726
+ * Pure + deterministic given `now`. Returns { shown, omittedCount } where `shown`
727
+ * carries per-entry fold info for the renderer.
728
+ *
729
+ * Ranking is stable on the original ledger index so, within a tier, newest notes
730
+ * lead. The caps keep the leading (highest-priority) entries.
731
+ */
732
+ export function selectOperatingNotesForPrompt(
733
+ notes: CoordinatorOperatingNote[],
734
+ now: number,
735
+ cap: number = OPERATING_NOTES_PROMPT_CAP,
736
+ byteBudget: number = OPERATING_NOTE_INJECTION_BYTE_BUDGET,
737
+ ): { shown: FoldedNote[]; omittedCount: number } {
586
738
  const valid = Array.isArray(notes)
587
739
  ? notes.filter(n => n && typeof n.text === 'string' && n.text.trim())
588
740
  : [];
589
- if (valid.length === 0) return '';
590
741
 
742
+ // (i)+(ii): keep pinned always; drop expired unpinned. Retain original index
743
+ // for a stable recency tiebreak (later index == newer, ledger is oldest-first).
744
+ let kept = valid
745
+ .map((note, index) => ({ note, index }))
746
+ .filter(({ note }) => note.pinned || !isNoteExpired(note as any, now));
747
+
748
+ // (iii) Phase 2 (b) version-supersede: a LATER live note may name (via
749
+ // `supersedes`) an earlier note's noteId or subjectKey; the earlier one is
750
+ // then hidden. "Later" == higher ledger index. Collect every supersede target
751
+ // paired with the max index that supersedes it, then drop any UNPINNED note
752
+ // whose id/subjectKey is superseded by a strictly-later note. Pinned notes are
753
+ // never dropped by supersede (pinned always wins, mirroring TTL).
754
+ const supersededAtIndex = new Map<string, number>();
755
+ for (const { note, index } of kept) {
756
+ const target = typeof note.supersedes === 'string' ? note.supersedes.trim().toLowerCase() : '';
757
+ if (!target) continue;
758
+ const prev = supersededAtIndex.get(target);
759
+ if (prev === undefined || index > prev) supersededAtIndex.set(target, index);
760
+ }
761
+ if (supersededAtIndex.size > 0) {
762
+ kept = kept.filter(({ note, index }) => {
763
+ if (note.pinned) return true;
764
+ const byId = typeof note.noteId === 'string' ? note.noteId.trim().toLowerCase() : '';
765
+ const bySubject = deriveSubjectKey(note);
766
+ const supIdx = Math.max(
767
+ byId ? (supersededAtIndex.get(byId) ?? -1) : -1,
768
+ bySubject ? (supersededAtIndex.get(bySubject) ?? -1) : -1,
769
+ );
770
+ return !(supIdx > index); // superseded by a strictly-later note β†’ drop
771
+ });
772
+ }
773
+
774
+ // (iv): rank pinned > durable > recency (newest first within a tier).
775
+ const rank = (n: CoordinatorOperatingNote): number =>
776
+ n.pinned ? 0 : isDurableCategory(n.category) ? 1 : 2;
777
+ kept.sort((a, b) => {
778
+ const r = rank(a.note) - rank(b.note);
779
+ if (r !== 0) return r;
780
+ return b.index - a.index; // newer (higher index) first
781
+ });
782
+
783
+ // (v) Phase 2 (c): fold same-class/same-subject unpinned runs into one entry.
784
+ const folded = foldSameClassNotes(kept.map(k => k.note));
785
+
786
+ // (vi) Phase 2 (d): fill up to the byte budget AND the count cap. Pinned
787
+ // entries are always kept (never dropped to fit the budget); the budget/cap
788
+ // bound the unpinned tail. Entries are already highest-priority first, so we
789
+ // walk in order and stop once an UNPINNED entry would break a bound.
790
+ const shown: FoldedNote[] = [];
791
+ let usedBytes = 0;
792
+ let usedCount = 0;
793
+ for (const entry of folded) {
794
+ if (entry.note.pinned) {
795
+ shown.push(entry);
796
+ usedBytes += renderedNoteBytes(entry);
797
+ usedCount += 1;
798
+ continue;
799
+ }
800
+ if (usedCount >= cap) break;
801
+ const bytes = renderedNoteBytes(entry);
802
+ if (usedCount > 0 && usedBytes + bytes > byteBudget) break;
803
+ shown.push(entry);
804
+ usedBytes += bytes;
805
+ usedCount += 1;
806
+ }
807
+
808
+ // omittedCount counts eligible (non-expired, non-superseded, post-fold) entries
809
+ // that did not make the cut. Folded-away duplicates are not "omitted" β€” they are
810
+ // represented by their survivor's "(+N earlier)" marker, so exclude them here.
811
+ const omittedCount = Math.max(0, folded.length - shown.length);
812
+ return { shown, omittedCount };
813
+ }
814
+
815
+ /**
816
+ * Render one operating-note bullet line (Phase 2 c fold-aware). Shared by the
817
+ * byte-budget estimator and the section renderer so the budget is measured on
818
+ * the exact bytes that ship.
819
+ */
820
+ function renderOperatingNoteLine(folded: FoldedNote): string {
821
+ const n = folded.note;
591
822
  const categoryLabel: Record<string, string> = {
592
823
  provider_quirk: 'provider quirk',
593
824
  pattern_to_avoid: 'pattern to avoid',
594
825
  recovery_lesson: 'recovery lesson',
595
826
  };
827
+ const pin = n.pinned ? 'πŸ“Œ ' : '';
828
+ const cat = n.category && categoryLabel[n.category] ? `[${categoryLabel[n.category]}] ` : '';
829
+ const fold = folded.subsumedCount > 0
830
+ ? ` _(+${folded.subsumedCount} earlier same-subject note${folded.subsumedCount === 1 ? '' : 's'} folded${folded.subsumedIds.length ? `: ${folded.subsumedIds.join(', ')}` : ''})_`
831
+ : '';
832
+ return `- ${pin}${cat}${truncateNote(n.text.trim())}${fold}`;
833
+ }
834
+
835
+ function buildOperatingNotesSection(notes?: CoordinatorOperatingNote[], now: number = Date.now()): string {
836
+ const hasAny = Array.isArray(notes)
837
+ ? notes.some(n => n && typeof n.text === 'string' && n.text.trim())
838
+ : false;
839
+ if (!hasAny) return '';
596
840
 
597
- // Keep only the most recent OPERATING_NOTES_PROMPT_CAP notes in the prompt.
598
- // `valid` is oldest-first (ledger order), so the newest are at the tail.
599
- const omittedCount = Math.max(0, valid.length - OPERATING_NOTES_PROMPT_CAP);
600
- const shown = omittedCount > 0 ? valid.slice(-OPERATING_NOTES_PROMPT_CAP) : valid;
841
+ // Lifecycle selection: pinned-always + expired-unpinned-dropped +
842
+ // superseded-dropped + rank (pinned > durable > recency) + same-class fold +
843
+ // byte-budget/count cap. `shown` is already highest-priority-first and carries
844
+ // per-entry fold info; renderOperatingNoteLine emits the exact bytes the
845
+ // budget was measured against.
846
+ const { shown, omittedCount } = selectOperatingNotesForPrompt(notes ?? [], now);
847
+ if (shown.length === 0) return '';
601
848
 
602
849
  const lines: string[] = ['## Operating Notes', ''];
603
850
  lines.push('Lessons earlier coordinators on this mesh recorded via `mesh_record_note`. Treat them as accumulated operating knowledge β€” apply them. When you learn a durable lesson (a provider quirk, a pattern to avoid, a recovery lesson), record it with `mesh_record_note` so future coordinators inherit it.');
604
851
  lines.push('');
605
- for (const n of shown) {
606
- const cat = n.category && categoryLabel[n.category] ? `[${categoryLabel[n.category]}] ` : '';
607
- lines.push(`- ${cat}${truncateNote(n.text.trim())}`);
852
+ for (const entry of shown) {
853
+ lines.push(renderOperatingNoteLine(entry));
608
854
  }
609
855
  if (omittedCount > 0) {
610
856
  lines.push('');
611
- lines.push(`_${omittedCount} older note${omittedCount === 1 ? '' : 's'} omitted (kept in ledger; prune with \`mesh_forget_note\`)._`);
857
+ lines.push(`_${omittedCount} lower-priority note${omittedCount === 1 ? '' : 's'} omitted to fit the injection cap/byte-budget (kept in ledger; expired, superseded, and same-subject-folded notes are also hidden from this list but retained for audit; prune with \`mesh_forget_note\`)._`);
612
858
  }
613
859
  return lines.join('\n');
614
860
  }
@@ -761,7 +1007,8 @@ const TOOLS_SECTION = `## Available Tools
761
1007
  | \`mesh_fast_forward_node\` | Safely dry-run or explicitly execute an obvious clean fast-forward without launching an agent session |
762
1008
  | \`mesh_restart_daemon\` | Update a node's daemon to the latest published version on its channel and restart it (the dashboard "preview update" path, as a mesh command) |
763
1009
  | \`mesh_checkpoint\` | Create a git checkpoint on a node |
764
- | \`mesh_approve\` | Approve/reject a pending agent action |
1010
+ | \`mesh_approve\` | Approve/reject a pending agent action (a yes/no tool-consent modal) |
1011
+ | \`mesh_answer_question\` | Answer a delegated session's multi-choice QUESTION (AskUserQuestion / status awaiting_choice). NOT an approval β€” it offers labelled options (possibly multi-select or freeform). Pass the promptId from the agent:waiting_choice event + one answer per question (select by option label or 1-based index). Use this, never mesh_approve, for a question |
765
1012
  | \`mesh_list_pending_approvals\` | List every session across the mesh awaiting an approval decision (the approval inbox) β€” read-only; enumerate all blocked sessions at once, then drive a mesh_approve for each |
766
1013
  | \`mesh_clone_node\` | Create a worktree node for isolated parallel branch work |
767
1014
  | \`mesh_refine_node\` | Validate and merge a completed worktree node back into its base branch |
@@ -5,7 +5,7 @@ import { deleteDirectDispatchesByTaskId } from './mesh-work-queue.js';
5
5
  import { meshNodeIdMatches, daemonIdsEquivalent, sessionIdsEquivalent } from '@adhdev/mesh-shared';
6
6
 
7
7
  export type MeshActiveWorkSource = 'queue' | 'direct';
8
- export type MeshActiveWorkStatus = 'pending' | 'assigned' | 'generating' | 'idle' | 'failed' | 'awaiting_approval';
8
+ export type MeshActiveWorkStatus = 'pending' | 'assigned' | 'generating' | 'idle' | 'failed' | 'awaiting_approval' | 'awaiting_choice';
9
9
 
10
10
  export interface MeshActiveWorkRecord {
11
11
  taskId: string;
@@ -135,6 +135,10 @@ export function sessionStatusFromNodes(nodes: any[] | undefined, nodeId?: string
135
135
  if (!session) return { staleReason: 'direct task session is not present in live session records' };
136
136
  if (typeof session === 'string') return {};
137
137
  const raw = `${readString(session.status) || ''} ${readString(session.lifecycle) || ''} ${readString(session.state) || ''} ${readString(session.activeChat?.status) || ''}`.toLowerCase();
138
+ // A question picker surfaces as waiting_choice β€” distinct from an approval modal.
139
+ // Check it first so a question worker is not mislabeled awaiting_approval and
140
+ // pulled into the approval inbox (mission f1d25e11).
141
+ if (raw.includes('waiting_choice') || raw.includes('choice')) return { status: 'awaiting_choice' };
138
142
  if (raw.includes('approval')) return { status: 'awaiting_approval' };
139
143
  if (raw.includes('generating') || raw.includes('running') || raw.includes('busy')) return { status: 'generating' };
140
144
  if (raw.includes('failed') || raw.includes('stopped') || raw.includes('terminated') || raw.includes('exited')) return { status: 'failed' };
@@ -166,6 +170,10 @@ function terminalMatchesDispatch(terminal: MeshLedgerEntry, dispatch: MeshLedger
166
170
 
167
171
  function statusFromTerminal(entry: MeshLedgerEntry): MeshActiveWorkStatus {
168
172
  if (entry.kind === 'task_approval_needed') return 'awaiting_approval';
173
+ // A question (waiting_choice) is a distinct blocked state β€” kept OUT of
174
+ // awaiting_approval so it is not surfaced in the approval inbox / mesh_approve
175
+ // flow (mission f1d25e11). Answered via mesh_answer_question.
176
+ if (entry.kind === 'task_question_pending') return 'awaiting_choice';
169
177
  if (entry.kind === 'task_completed') return 'idle';
170
178
  return 'failed';
171
179
  }
@@ -331,6 +339,7 @@ export function buildMeshActiveWorkSummary(activeWork: MeshActiveWorkRecord[]):
331
339
  idle: 0,
332
340
  failed: 0,
333
341
  awaiting_approval: 0,
342
+ awaiting_choice: 0,
334
343
  };
335
344
  const sourceCounts: Record<MeshActiveWorkSource, number> = { queue: 0, direct: 0 };
336
345
  for (const item of activeWork) {
@@ -380,6 +389,7 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
380
389
  ? sessionStatusFromNodes(opts.nodes, queueNodeId ?? undefined, queueSessionId ?? undefined)
381
390
  : {};
382
391
  const queueStatus: MeshActiveWorkStatus = queueLive.status === 'awaiting_approval'
392
+ || queueLive.status === 'awaiting_choice'
383
393
  || queueLive.status === 'generating'
384
394
  ? queueLive.status
385
395
  : task.status;
@@ -26,7 +26,7 @@ import { getActiveDirectDispatches, getQueue } from './mesh-work-queue.js';
26
26
  import { readLedgerEntries } from './mesh-ledger.js';
27
27
  import { pruneStaleDirectDispatches } from './mesh-active-work.js';
28
28
  import { reconcileDirectDispatchCompletionFromTranscript } from './mesh-events-stale.js';
29
- import { extractFinalAssistantSummaryEvidence } from '../providers/chat-message-normalization.js';
29
+ import { extractFinalAssistantSummaryEvidence, hasTrailingToolActivityAfterFinalAssistant } from '../providers/chat-message-normalization.js';
30
30
  import type { ChatMessage } from '../types.js';
31
31
  import {
32
32
  getMeshV2BackstopCounters,
@@ -483,6 +483,17 @@ export async function pollAssignedTaskTerminalEvidence(
483
483
  const evidence = extractFinalAssistantSummaryEvidence(messages);
484
484
  if (!evidence.finalSummary) return null; // idle but no assistant result yet β†’ not a turn-end
485
485
 
486
+ // EARLY-IDLE-COMPLETION-FALSE-POSITIVE (poll defense-in-depth): a momentary idle read
487
+ // (startup-grace, or the sliver between an assistant preamble and the tool it fires) can
488
+ // show an assistant "Let me explore…" bubble FOLLOWED by trailing Read/Grep tool_use β€” a
489
+ // turn that is still executing, not a turn-end. selectFinalAssistantTurnEndMessage skips
490
+ // those trailing tool bubbles and would promote the preamble, so guard here: if a
491
+ // tool/terminal activity bubble trails the final assistant message, the worker is mid-turn
492
+ // β€” refuse the completion (fall through to the caller's reclaim/grace path). A genuinely
493
+ // finished pure-PTY worker ends on its final assistant with no trailing tool activity, so
494
+ // the kimi rescue is preserved.
495
+ if (hasTrailingToolActivityAfterFinalAssistant(messages)) return null;
496
+
486
497
  // Stale-summary guard (same bar as PHASE 4): a reused session's transcript tail may hold a
487
498
  // PRIOR task's summary. Require the final assistant message to be dated at/after THIS task's
488
499
  // dispatch. When either timestamp is unusable, do NOT short-circuit β€” fall through so we never