@adhdev/daemon-core 0.9.82-rc.414 → 0.9.82-rc.416

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.
@@ -1,4 +1,4 @@
1
1
  export { isMeshCoordinatorEvent, MESH_FORCE_INJECT_EVENTS, shouldForceInjectMeshEvent, } from './mesh-event-classify.js';
2
- export { __orderEligibleNodesForTests, __resolveSchedulingStrategyForTests, __resetIdleAutoFastForwardForTests, activeReadonlyAssignedCount, activeWriteAssignedCount, triggerMeshQueue, tryAssignQueueTask, } from './mesh-queue-assignment.js';
2
+ export { __orderEligibleNodesForTests, __resolveSchedulingStrategyForTests, __resetIdleAutoFastForwardForTests, activeReadonlyAssignedCount, activeWriteAssignedCount, isSessionActivelyGenerating, triggerMeshQueue, tryAssignQueueTask, } from './mesh-queue-assignment.js';
3
3
  export type { MeshQueueTriggerResult } from './mesh-queue-assignment.js';
4
4
  export { __resetMeshWorkspaceCacheForTests, buildRelayMetadataEvent, handleMeshForwardEvent, resolveForwardEventMeshId, setupMeshEventForwarding, } from './mesh-event-forwarding.js';
@@ -3,4 +3,4 @@ export { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, ge
3
3
  export { reconcileDirectDispatchCompletionFromTranscript, } from './mesh-events-stale.js';
4
4
  export { setupMeshReconcileLoop, runMeshReconcileTick, resolveCoordinatorDrainDeliverability, shouldHoldPendingDrainForBusyLocalCoordinator, } from './mesh-reconcile-loop.js';
5
5
  export type { MeshQueueTriggerResult } from './mesh-events-coordinator.js';
6
- export { tryAssignQueueTask, triggerMeshQueue, handleMeshForwardEvent, setupMeshEventForwarding, isMeshCoordinatorEvent, __resetIdleAutoFastForwardForTests, __resetMeshWorkspaceCacheForTests, } from './mesh-events-coordinator.js';
6
+ export { tryAssignQueueTask, isSessionActivelyGenerating, triggerMeshQueue, handleMeshForwardEvent, setupMeshEventForwarding, isMeshCoordinatorEvent, __resetIdleAutoFastForwardForTests, __resetMeshWorkspaceCacheForTests, } from './mesh-events-coordinator.js';
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { EventEmitter } from 'events';
16
16
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
17
- export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'task_reclaimed' | 'coordinator_operating_note' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated';
17
+ export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'task_reclaimed' | 'coordinator_operating_note' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated' | 'magi_dispatched' | 'magi_synthesis';
18
18
  export interface MeshLedgerEntry {
19
19
  id: string;
20
20
  meshId: string;
@@ -0,0 +1,63 @@
1
+ import type { MeshLedgerEntry } from './mesh-ledger.js';
2
+ import type { MagiGitSkew } from '@adhdev/mesh-shared';
3
+ export type MeshMagiActivityStatus = 'running' | 'synthesized';
4
+ /** A bounded needs_verification preview item (claim text + category only). */
5
+ export interface MeshMagiNeedsVerificationItem {
6
+ claim: string;
7
+ category: string;
8
+ }
9
+ export interface MeshMagiActivitySummary {
10
+ consensusGroupId: string;
11
+ status: MeshMagiActivityStatus;
12
+ missionId?: string;
13
+ panel?: string;
14
+ question?: string;
15
+ replicaCount?: number;
16
+ answered?: number;
17
+ missing?: number;
18
+ staleReplicas?: number;
19
+ needsVerificationCount?: number;
20
+ agreedCount?: number;
21
+ independenceBanner?: string | null;
22
+ gitSkew?: MagiGitSkew;
23
+ /** Bounded sample of the needs_verification clusters (claim + category). */
24
+ needsVerification?: MeshMagiNeedsVerificationItem[];
25
+ openQuestions?: string[];
26
+ lastLedgerKind?: string;
27
+ lastUpdatedAt?: string;
28
+ }
29
+ /** Cap on inlined needs_verification preview items per group (keeps the payload bounded). */
30
+ export declare const MAGI_NEEDS_VERIFICATION_PREVIEW_CAP = 8;
31
+ /**
32
+ * Reconstruct per-consensusGroup MAGI activity from a ledger window. The newest
33
+ * `magi_synthesis` for a group supplies the synthesis fields; a `magi_dispatched`
34
+ * with no later synthesis stays `running`. Deduped by consensusGroupId, newest first.
35
+ */
36
+ export declare function buildMeshMagiActivity(args: {
37
+ meshId?: string;
38
+ ledgerEntries?: MeshLedgerEntry[];
39
+ }): MeshMagiActivitySummary[];
40
+ /** Synthesized groups older than this (relative to the newest activity in the set) are
41
+ * folded out of the active list — already-resolved historical runs that should not keep
42
+ * inflating mesh_status. 6h covers a long working session. */
43
+ export declare const STALE_MAGI_WINDOW_MS: number;
44
+ /** Cap on recent synthesized groups kept in the active list even if all are fresh. */
45
+ export declare const RECENT_MAGI_CAP = 6;
46
+ export interface MeshMagiActivitySummaryFold {
47
+ total: number;
48
+ byStatus: Record<string, number>;
49
+ /** Synthesized groups dropped from the active list as stale historical residue. */
50
+ staleSynthesized: number;
51
+ /** Bounded set of recent/active groups (running first, then recent synthesized). */
52
+ groups: MeshMagiActivitySummary[];
53
+ }
54
+ /**
55
+ * Bound the MAGI activity list for mesh_status: running groups are always kept; synthesized
56
+ * groups are kept only when recent (within STALE_MAGI_WINDOW_MS of the newest activity AND
57
+ * among the RECENT_MAGI_CAP most-recent). Freshness is measured relative to the newest group
58
+ * (not wall-clock) so the result is deterministic for a given input. Mirrors
59
+ * summarizeMeshAsyncRefineJobs.
60
+ */
61
+ export declare function summarizeMeshMagiActivity(activity: MeshMagiActivitySummary[]): MeshMagiActivitySummaryFold;
62
+ /** Latest persisted synthesis activity for one consensusGroupId, or undefined. */
63
+ export declare function getMeshMagiActivityByGroup(ledgerEntries: MeshLedgerEntry[], consensusGroupId: string): MeshMagiActivitySummary | undefined;
@@ -47,6 +47,24 @@ export declare function __orderEligibleNodesForTests(meshId: string, strategy: R
47
47
  bumpCursor?: boolean;
48
48
  }): RankableNode[];
49
49
  export declare function sessionHasActiveAssignment(meshId: string, sessionId: string): boolean;
50
+ /**
51
+ * CANON-IDENTITY single-flight hardening (restart-safe, observation-based).
52
+ *
53
+ * The in-memory single-flight Set (mesh-task-inflight) is process-local and is LOST on a
54
+ * daemon restart — after a restart, a task still being generated by a live local worker is
55
+ * no longer marked in-flight, so requeueTask's Set check passes and would re-open the task
56
+ * for a duplicate second dispatch. This recovers the "still generating" signal from
57
+ * observable runtime state instead of the in-memory mark: a session is actively generating
58
+ * when its live local CLI instance reports an active (generating/streaming/…) status — the
59
+ * same predicate the dispatch active-work gate uses (sessionStateLooksActive).
60
+ *
61
+ * Local-only by design: it inspects THIS daemon's instanceManager. The primary cross-process
62
+ * fix (IpcTransport requeue delegating to the mesh-host daemon) keeps begin (dispatch) and
63
+ * check (requeue guard) co-located so the in-memory mark stays authoritative in the common
64
+ * path; this is the restart-safety net for sessions hosted on this daemon. A genuinely
65
+ * dead/stale session is not generating → returns false → the requeue proceeds as before.
66
+ */
67
+ export declare function isSessionActivelyGenerating(components: DaemonComponents, sessionId: string): boolean;
50
68
  export interface MeshQueueTriggerResult {
51
69
  success: true;
52
70
  meshId: string;
@@ -65,6 +65,13 @@ export interface MeshWorkQueueEntry {
65
65
  dependsOn?: string[];
66
66
  /** M1/M3: mission this task belongs to (joins mesh_missions). */
67
67
  missionId?: string;
68
+ /**
69
+ * MAGI: consensus group id shared by every replica of one mesh_magi_review
70
+ * fan-out. Marks the task as part of an INTENTIONAL same-prompt quorum so the
71
+ * completion-event dedup (mesh-events-pending) never collapses grouped
72
+ * replicas. Absent on ordinary tasks. Rides in the payload JSON (no column).
73
+ */
74
+ consensusGroupId?: string;
68
75
  /**
69
76
  * M1: why this task is held back (e.g. "dependency_failed:<taskId>").
70
77
  * Only set by the system on dependency failure under the 'block' policy;
@@ -177,6 +184,8 @@ export declare function enqueueTask(meshId: string, message: string, opts?: {
177
184
  dependsOn?: string[];
178
185
  /** M1/M3: mission this task belongs to. */
179
186
  missionId?: string;
187
+ /** MAGI: consensus group id shared by every replica of a mesh_magi_review fan-out. */
188
+ consensusGroupId?: string;
180
189
  /** Explicit task id for batch/template flows (M5). Random UUID when omitted. */
181
190
  id?: string;
182
191
  /** (3) Originating coordinator session id (for session-anchored completion routing). */
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import type { GitRepoStatus, GitCompactSummary } from './git/git-types.js';
14
14
  import type { MeshMissionSummary, MeshMissionSlimSummary } from './mesh/mesh-missions.js';
15
+ import type { MagiPanelMap } from '@adhdev/mesh-shared';
15
16
  export interface RepoMesh {
16
17
  id: string;
17
18
  name: string;
@@ -466,6 +467,13 @@ export interface RepoMeshCoordinatorConfig {
466
467
  */
467
468
  export interface LocalMeshConfig {
468
469
  meshes: LocalMeshEntry[];
470
+ /**
471
+ * MAGI cross-verification panels (machine-local). Keyed by panel name; each
472
+ * binds concrete `(node × provider)` members — machine-dependent facts — so
473
+ * panels live here in meshes.json, never in the repo-shared .adhdev/mesh.json.
474
+ * Optional: absent on configs written before MAGI existed.
475
+ */
476
+ magiPanels?: MagiPanelMap;
469
477
  }
470
478
  export interface LocalMeshEntry {
471
479
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.414",
3
+ "version": "0.9.82-rc.416",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.414",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.416",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -6,6 +6,12 @@ import * as path from 'path';
6
6
  // need a cmd.exe wrapper).
7
7
  const DIRECT_EXEC_EXT = new Set(['.exe', '.com']);
8
8
 
9
+ // Batch-style shims: absolute and launchable by node-pty's ConPTY, but NOT by
10
+ // child_process.execFile/spawn without a cmd.exe wrapper (Node ≥18.20/20.12/22/24
11
+ // refuse to exec a .cmd/.bat directly — CVE-2024-27980 mitigation). Preferred
12
+ // over extensionless Unix wrappers, which are not win32-executable at all.
13
+ const SHIM_EXEC_EXT = new Set(['.cmd', '.bat']);
14
+
9
15
  // Executable extensions to probe when scanning a directory ourselves, ordered
10
16
  // most-directly-launchable first. node-pty's ConPTY backend launches an
11
17
  // absolute `.cmd`/`.bat` shim fine (verified) — it only fails to *resolve* a
@@ -42,6 +48,29 @@ function resolveWin32GlobalBin(trimmed: string): string | null {
42
48
  return null;
43
49
  }
44
50
 
51
+ /**
52
+ * Pick the best launch target from `where`'s match list on win32.
53
+ *
54
+ * `where npm` on a typical install returns BOTH the extensionless Unix wrapper
55
+ * (e.g. `C:\Program Files\nodejs\npm`, a bash shell script) AND the `npm.cmd`
56
+ * shim. The extensionless wrapper is NOT a win32 executable — handing it to a
57
+ * spawn boundary ENOENTs (errno -4058). So:
58
+ * 1. Prefer a directly-launchable `.exe`/`.com`.
59
+ * 2. Otherwise take a `.cmd`/`.bat` shim (absolute → works for ConPTY, and for
60
+ * execFile once wrapped via buildWin32ExecFileSpawn).
61
+ * 3. NEVER fall back to an extensionless match — return null so the caller can
62
+ * try other resolution strategies (global-bin scan) rather than emit a
63
+ * path that cannot be exec'd.
64
+ */
65
+ export function selectWin32ExecutableMatch(matches: string[]): string | null {
66
+ const cleaned = matches.map((m) => m.trim()).filter(Boolean);
67
+ const direct = cleaned.find((m) => DIRECT_EXEC_EXT.has(path.extname(m).toLowerCase()));
68
+ if (direct) return direct;
69
+ const shim = cleaned.find((m) => SHIM_EXEC_EXT.has(path.extname(m).toLowerCase()));
70
+ if (shim) return shim;
71
+ return null;
72
+ }
73
+
45
74
  /**
46
75
  * Resolve a launch command to an absolute executable path on Windows.
47
76
  *
@@ -70,9 +99,12 @@ export function resolveWin32Executable(command: string): string {
70
99
  }).trim();
71
100
  if (out) {
72
101
  const matches = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
73
- // Prefer a directly-launchable executable (.exe/.com) over .cmd/.bat shims.
74
- const direct = matches.find((m) => DIRECT_EXEC_EXT.has(path.extname(m).toLowerCase()));
75
- return direct || matches[0] || command;
102
+ // Prefer .exe/.com, then a .cmd/.bat shim; never an extensionless Unix
103
+ // wrapper (the old `matches[0]` fallback returned exactly that and made
104
+ // the spawn boundary ENOENT). On no usable match, fall through to the
105
+ // off-PATH global-bin scan below rather than returning a dead path.
106
+ const selected = selectWin32ExecutableMatch(matches);
107
+ if (selected) return selected;
76
108
  }
77
109
  } catch {
78
110
  // `where` not found / non-zero exit — fall through to the global-bin scan.
@@ -88,3 +120,85 @@ export function resolveWin32Executable(command: string): string {
88
120
 
89
121
  return command;
90
122
  }
123
+
124
+ /**
125
+ * Quote one argument for a cmd.exe command line using the standard
126
+ * CommandLineToArgvW rules (the same algorithm Node uses internally): wrap in
127
+ * double quotes only when needed, double up the backslashes that precede a
128
+ * quote, and escape embedded quotes. We do per-argument quoting ourselves
129
+ * (rather than `{ shell: true }`) because Node's shell mode joins argv with bare
130
+ * spaces and applies NO quoting — any argument containing a space (a path, a
131
+ * test name) would split. Inputs here are repo-mesh validation/bootstrap command
132
+ * tokens (trusted config, not network data), so argv-quoting for spaces/quotes
133
+ * is sufficient; we deliberately do not attempt full cmd.exe metacharacter
134
+ * (& | < > ^ %) escaping.
135
+ */
136
+ export function quoteWin32CmdArg(arg: string): string {
137
+ if (arg.length > 0 && !/[ \t"]/.test(arg)) return arg;
138
+ let result = '"';
139
+ let backslashes = 0;
140
+ for (const ch of arg) {
141
+ if (ch === '\\') {
142
+ backslashes += 1;
143
+ continue;
144
+ }
145
+ if (ch === '"') {
146
+ // Escape every pending backslash (they precede a quote) plus the quote.
147
+ result += '\\'.repeat(backslashes * 2 + 1) + '"';
148
+ backslashes = 0;
149
+ continue;
150
+ }
151
+ result += '\\'.repeat(backslashes) + ch;
152
+ backslashes = 0;
153
+ }
154
+ // Trailing backslashes precede the closing quote → must be doubled.
155
+ result += '\\'.repeat(backslashes * 2) + '"';
156
+ return result;
157
+ }
158
+
159
+ export interface Win32ExecFileSpawn {
160
+ file: string;
161
+ args: string[];
162
+ /** Set when the args are pre-quoted for cmd.exe and must not be re-quoted. */
163
+ windowsVerbatimArguments?: boolean;
164
+ }
165
+
166
+ /**
167
+ * Build child_process.execFile/spawn parameters for an already-resolved command.
168
+ *
169
+ * On win32 a `.cmd`/`.bat` shim (what `npm`/`npx`/`tsc`/`vitest` resolve to)
170
+ * cannot be launched by execFile directly — modern Node refuses it (CVE-2024-27980
171
+ * mitigation) and CreateProcess cannot exec a batch file. So wrap it in
172
+ * `cmd.exe /d /s /c "<quoted command line>"` with `windowsVerbatimArguments` so
173
+ * our own per-argument quoting is preserved. `.exe`/`.com` (and every non-win32
174
+ * platform, and any already-cmd.exe target) pass through unchanged — this is a
175
+ * strict no-op off win32, guarding against regressions on linux/macOS.
176
+ */
177
+ export function buildWin32ExecFileSpawn(resolvedCommand: string, args: string[]): Win32ExecFileSpawn {
178
+ if (process.platform !== 'win32') return { file: resolvedCommand, args };
179
+ const ext = path.extname(resolvedCommand).toLowerCase();
180
+ if (!SHIM_EXEC_EXT.has(ext)) return { file: resolvedCommand, args };
181
+ // cmd.exe /d (skip AutoRun) /s (treat the rest, between the outer quotes, as
182
+ // the verbatim command) /c (run then exit). Mirrors Node's internal shell
183
+ // wrapping but with each token individually quoted.
184
+ const commandLine = [resolvedCommand, ...args].map(quoteWin32CmdArg).join(' ');
185
+ return {
186
+ file: process.env.ComSpec || 'cmd.exe',
187
+ args: ['/d', '/s', '/c', `"${commandLine}"`],
188
+ windowsVerbatimArguments: true,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Convenience: resolve a bare command to an absolute win32 path AND build the
194
+ * execFile spawn parameters (cmd.exe-wrapping a .cmd/.bat shim). Returns the
195
+ * resolved command alongside the spawn spec so callers can still surface the
196
+ * resolved path in diagnostics.
197
+ */
198
+ export function resolveWin32ExecFileSpawn(
199
+ command: string,
200
+ args: string[],
201
+ ): Win32ExecFileSpawn & { resolvedCommand: string } {
202
+ const resolvedCommand = resolveWin32Executable(command);
203
+ return { resolvedCommand, ...buildWin32ExecFileSpawn(resolvedCommand, args) };
204
+ }
@@ -67,7 +67,29 @@ export const meshQueueHandlers: Record<string, MedFamilyHandler> = {
67
67
  const ownerFailure = await ctx.requireMeshHostMutationOwner(meshId, args?.inlineMesh, 'queue requeue');
68
68
  if (ownerFailure) return ownerFailure;
69
69
  try {
70
- const { requeueTask } = await import('../../mesh/mesh-work-queue.js');
70
+ const { requeueTask, getQueue } = await import('../../mesh/mesh-work-queue.js');
71
+ // CANON-IDENTITY single-flight hardening (restart-safe): the in-memory in-flight
72
+ // mark requeueTask consults is process-local and is lost across a daemon restart.
73
+ // Independently of that mark, if the row is still 'assigned' to a session this
74
+ // daemon hosts that is actively generating, requeueing would flip it back to
75
+ // pending and let a SECOND session claim the SAME task (the duplicate dispatch).
76
+ // Refuse unless force. A genuinely dead/stale session is not generating, so this
77
+ // observation passes and a legitimate requeue proceeds. force=true (operator
78
+ // override) bypasses BOTH this and the in-memory guard.
79
+ // MAGI-NOTE: a future consensus group fan-out (separate mission) will exempt
80
+ // group-tagged tasks from this guard; the exemption hook belongs here.
81
+ if (args?.force !== true) {
82
+ const { isSessionActivelyGenerating } = await import('../../mesh/mesh-events.js');
83
+ const existing = getQueue(meshId).find((t: any) => t?.id === taskId) as { status?: string; assignedSessionId?: string } | undefined;
84
+ if (existing?.status === 'assigned' && existing.assignedSessionId
85
+ && isSessionActivelyGenerating(ctx.deps as any, existing.assignedSessionId)) {
86
+ return {
87
+ success: false,
88
+ error: `Task '${taskId}' is actively dispatched/generating (live session ${existing.assignedSessionId}); requeue refused to avoid a duplicate second dispatch. Pass force:true to override, or cancel and re-enqueue.`,
89
+ task: existing,
90
+ };
91
+ }
92
+ }
71
93
  const task = requeueTask(meshId, taskId, {
72
94
  reason: typeof args?.reason === 'string' ? args.reason : undefined,
73
95
  targetNodeId: typeof args?.targetNodeId === 'string' ? args.targetNodeId.trim() : undefined,
@@ -22,6 +22,7 @@ import type {
22
22
  RepoMeshHostMetadata,
23
23
  RepoMeshDaemonRole,
24
24
  } from '../repo-mesh-types.js';
25
+ import type { MagiPanel, MagiPanelMember } from '@adhdev/mesh-shared';
25
26
  import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
26
27
  import { createDefaultMeshHostMetadata } from '../mesh/mesh-host-ownership.js';
27
28
 
@@ -584,3 +585,118 @@ export function updateNode(
584
585
  saveMeshConfig(config);
585
586
  return node;
586
587
  }
588
+
589
+ // ─── MAGI Panels (machine-local cross-verification quorums) ──
590
+
591
+ /** Hard cap on members per panel — a sanity bound, not the per-invocation replica cap. */
592
+ const MAX_MAGI_PANEL_MEMBERS = 24;
593
+
594
+ function normalizeReplicaCount(value: unknown): number | undefined {
595
+ if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
596
+ const n = Math.floor(value);
597
+ return n >= 1 ? n : undefined;
598
+ }
599
+
600
+ /**
601
+ * Validate + normalize a panel config before persisting. Mirrors the node-config
602
+ * normalization style (mesh-config addNode/updateNode): trims strings, drops
603
+ * empties, requires a provider per member, clamps replica counts. Throws on
604
+ * structurally invalid input so the calling tool returns a clear error rather than
605
+ * writing a malformed panel.
606
+ */
607
+ export function normalizeMagiPanel(config: unknown): MagiPanel {
608
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
609
+ throw new Error('invalid_magi_panel: config must be an object');
610
+ }
611
+ const raw = config as Record<string, unknown>;
612
+ const rawMembers = raw.members;
613
+ if (!Array.isArray(rawMembers) || rawMembers.length === 0) {
614
+ throw new Error('invalid_magi_panel: members must be a non-empty array');
615
+ }
616
+ if (rawMembers.length > MAX_MAGI_PANEL_MEMBERS) {
617
+ throw new Error(`invalid_magi_panel: too many members (max ${MAX_MAGI_PANEL_MEMBERS})`);
618
+ }
619
+ const members: MagiPanelMember[] = rawMembers.map((entry, idx) => {
620
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
621
+ throw new Error(`invalid_magi_panel: member[${idx}] must be an object`);
622
+ }
623
+ const m = entry as Record<string, unknown>;
624
+ const provider = typeof m.provider === 'string' ? m.provider.trim() : '';
625
+ if (!provider) {
626
+ throw new Error(`invalid_magi_panel: member[${idx}].provider is required`);
627
+ }
628
+ const nodeId = typeof m.nodeId === 'string' && m.nodeId.trim() ? m.nodeId.trim() : undefined;
629
+ const capabilityTags = normalizeCapabilityTags(m.capabilityTags);
630
+ const n = normalizeReplicaCount(m.n);
631
+ return {
632
+ provider,
633
+ ...(nodeId ? { nodeId } : {}),
634
+ ...(capabilityTags ? { capabilityTags } : {}),
635
+ ...(n !== undefined ? { n } : {}),
636
+ };
637
+ });
638
+ const description = typeof raw.description === 'string' && raw.description.trim()
639
+ ? raw.description.trim().slice(0, 200)
640
+ : undefined;
641
+ const defaultN = normalizeReplicaCount(raw.defaultN);
642
+ return {
643
+ ...(description ? { description } : {}),
644
+ members,
645
+ ...(defaultN !== undefined ? { defaultN } : {}),
646
+ // dedupExempt is always meaningful for a MAGI panel (intentional same-prompt
647
+ // fan-out). Persist it true unless the caller explicitly disables it.
648
+ dedupExempt: raw.dedupExempt === false ? false : true,
649
+ };
650
+ }
651
+
652
+ function normalizePanelName(name: unknown): string {
653
+ const trimmed = typeof name === 'string' ? name.trim() : '';
654
+ if (!trimmed) throw new Error('invalid_magi_panel: panel name is required');
655
+ return trimmed.slice(0, 100);
656
+ }
657
+
658
+ /** All configured MAGI panels (machine-local), keyed by name. Empty when none. */
659
+ export function listMagiPanels(): Record<string, MagiPanel> {
660
+ return loadMeshConfig().magiPanels ?? {};
661
+ }
662
+
663
+ /** A single panel by name, or undefined when not configured. */
664
+ export function getMagiPanel(name: string): MagiPanel | undefined {
665
+ const key = typeof name === 'string' ? name.trim() : '';
666
+ if (!key) return undefined;
667
+ return loadMeshConfig().magiPanels?.[key];
668
+ }
669
+
670
+ /**
671
+ * Upsert a named panel into meshes.json. Defaults to refusing to clobber an
672
+ * existing panel (overwrite=false) — mirrors the mesh_init write/overwrite
673
+ * precedent. Returns the normalized, persisted panel.
674
+ */
675
+ export function upsertMagiPanel(
676
+ name: string,
677
+ config: unknown,
678
+ opts: { overwrite?: boolean } = {},
679
+ ): MagiPanel {
680
+ const key = normalizePanelName(name);
681
+ const panel = normalizeMagiPanel(config);
682
+ const stored = loadMeshConfig();
683
+ const panels = stored.magiPanels ?? {};
684
+ if (panels[key] && opts.overwrite !== true) {
685
+ throw new Error(`magi_panel_exists: panel '${key}' already exists — pass overwrite=true to replace it`);
686
+ }
687
+ panels[key] = panel;
688
+ stored.magiPanels = panels;
689
+ saveMeshConfig(stored);
690
+ return panel;
691
+ }
692
+
693
+ /** Remove a named panel. Returns true when a panel was removed. */
694
+ export function removeMagiPanel(name: string): boolean {
695
+ const key = typeof name === 'string' ? name.trim() : '';
696
+ if (!key) return false;
697
+ const stored = loadMeshConfig();
698
+ if (!stored.magiPanels || !stored.magiPanels[key]) return false;
699
+ delete stored.magiPanels[key];
700
+ saveMeshConfig(stored);
701
+ return true;
702
+ }
package/src/index.ts CHANGED
@@ -192,8 +192,18 @@ export type { SavedProviderSessionEntry } from './config/saved-sessions.js';
192
192
  export {
193
193
  listMeshes, getMesh, getMeshByRepo, createMesh, updateMesh, deleteMesh,
194
194
  addNode, removeNode, updateNode, normalizeRepoIdentity,
195
+ listMagiPanels, getMagiPanel, upsertMagiPanel, removeMagiPanel, normalizeMagiPanel,
195
196
  } from './config/mesh-config.js';
196
197
  export type { CreateMeshOptions, UpdateMeshOptions, AddNodeOptions } from './config/mesh-config.js';
198
+ // MAGI panel / common-output / synthesis types (re-exported from the mesh-shared
199
+ // leaf so the mcp-server — which depends only on @adhdev/daemon-core — can consume
200
+ // them without taking a direct @adhdev/mesh-shared dependency).
201
+ export type {
202
+ MagiPanel, MagiPanelMember, MagiPanelMap, MagiMode,
203
+ MagiClaim, MagiClaimStance, MagiAgentResponse,
204
+ MagiResponseSource, MagiReplicaGitRef, MagiGitSkew, MagiSynthesizedResponse,
205
+ MagiClusterCategory, MagiClusterMember, MagiClaimCluster, MagiSynthesis,
206
+ } from '@adhdev/mesh-shared';
197
207
 
198
208
  // ── Mesh shared daemon-id / node-id helpers (re-export so external tooling —
199
209
  // e.g. the mcp-server, which depends only on @adhdev/daemon-core — can
@@ -259,6 +269,8 @@ export type { StaleDirectPruneClassification, StaleDirectPruneResult, PruneStale
259
269
  export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary, MeshActiveWorkSource, MeshStaleDirectWorkSummary } from './mesh/mesh-active-work.js';
260
270
  export { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs, STALE_TERMINAL_REFINE_WINDOW_MS, RECENT_TERMINAL_REFINE_CAP } from './mesh/mesh-refine-status.js';
261
271
  export type { MeshAsyncRefineJobStatus, MeshAsyncRefineJobSummary, MeshAsyncRefineJobsSummary } from './mesh/mesh-refine-status.js';
272
+ export { buildMeshMagiActivity, summarizeMeshMagiActivity, getMeshMagiActivityByGroup, STALE_MAGI_WINDOW_MS, RECENT_MAGI_CAP, MAGI_NEEDS_VERIFICATION_PREVIEW_CAP } from './mesh/mesh-magi-status.js';
273
+ export type { MeshMagiActivityStatus, MeshMagiActivitySummary, MeshMagiActivitySummaryFold, MeshMagiNeedsVerificationItem } from './mesh/mesh-magi-status.js';
262
274
 
263
275
  // ── Mesh Scheduling Runtime (observability projection) ──
264
276
  export { buildMeshSchedulingRuntime } from './mesh/mesh-scheduling-runtime.js';
@@ -719,13 +719,29 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
719
719
  // coordinator has no local instance and keeps relying on the relayed fields — unchanged.
720
720
  const enrichedMetadataEvent = ((): Record<string, unknown> => {
721
721
  const last = sourceSession ? getLastDisplayMessage(sourceSession.getState()) : null;
722
- if (!last || !last.preview) return args.metadataEvent;
723
- return {
724
- ...args.metadataEvent,
725
- lastMessagePreview: last.preview,
726
- lastMessageRole: last.role,
727
- ...(last.receivedAt > 0 ? { lastMessageAt: last.receivedAt } : {}),
728
- };
722
+ const base = (!last || !last.preview)
723
+ ? args.metadataEvent
724
+ : {
725
+ ...args.metadataEvent,
726
+ lastMessagePreview: last.preview,
727
+ lastMessageRole: last.role,
728
+ ...(last.receivedAt > 0 ? { lastMessageAt: last.receivedAt } : {}),
729
+ };
730
+ // MAGI: stamp the queue task's consensusGroupId onto the completion metadata
731
+ // so the intentional-fan-out dedup exemption (buildPendingEventFingerprint)
732
+ // can see it. The work queue is owned by THIS host/coordinator daemon — where
733
+ // both the lookup and the dedup run — so the local lookup covers local and
734
+ // relayed workers alike. Best-effort: never fail the event path on a miss, and
735
+ // never clobber a consensusGroupId the worker already relayed.
736
+ if (readNonEmptyString((base as Record<string, unknown>).consensusGroupId)) return base;
737
+ const eventTaskId = readNonEmptyString(args.metadataEvent.taskId);
738
+ if (!eventTaskId) return base;
739
+ try {
740
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(args.meshId, eventTaskId);
741
+ const consensusGroupId = readNonEmptyString((entry as { consensusGroupId?: unknown } | null)?.consensusGroupId);
742
+ if (consensusGroupId) return { ...base, consensusGroupId };
743
+ } catch { /* queue lookup is best-effort; absence just falls back to the generic fingerprint */ }
744
+ return base;
729
745
  })();
730
746
 
731
747
  // R2: cloud P2P dashboard metadata sync. The cloud daemon used to do this from its own
@@ -17,6 +17,7 @@ export {
17
17
  __resetIdleAutoFastForwardForTests,
18
18
  activeReadonlyAssignedCount,
19
19
  activeWriteAssignedCount,
20
+ isSessionActivelyGenerating,
20
21
  triggerMeshQueue,
21
22
  tryAssignQueueTask,
22
23
  } from './mesh-queue-assignment.js';
@@ -121,6 +121,21 @@ export function buildPendingEventFingerprint(event: PendingMeshCoordinatorEvent)
121
121
  ].join('::');
122
122
  }
123
123
  }
124
+ // MAGI consensus-group exemption: a consensusGroupId marks an INTENTIONAL
125
+ // same-prompt fan-out across N replicas — the exact opposite of the accidental
126
+ // duplicates this dedup collapses. Anchor the fingerprint on the unique
127
+ // (taskId, consensusGroupId) so grouped replicas can NEVER be collapsed by any
128
+ // future prompt-content-based tightening of this builder. Mirrors the
129
+ // bootstrap-event exemption above and serves as the explicit fan-out marker.
130
+ // (Today this is belt-and-suspenders: each replica already gets a distinct
131
+ // taskId, so the generic key below would not collapse them either.)
132
+ const consensusGroupId = readNonEmptyString(metadata.consensusGroupId)
133
+ || readNonEmptyString(readRecord(metadata.payload)?.consensusGroupId);
134
+ if (consensusGroupId) {
135
+ const groupTaskId = readNonEmptyString(metadata.taskId)
136
+ || readNonEmptyString(readRecord(metadata.payload)?.taskId);
137
+ return [event.meshId, event.event, groupTaskId || '', consensusGroupId, 'group'].join('::');
138
+ }
124
139
  const sessionId = resolveEventSessionId(metadata);
125
140
  const providerSessionId = readNonEmptyString(metadata.providerSessionId);
126
141
  const taskId = readNonEmptyString(metadata.taskId) || readNonEmptyString(readRecord(metadata.payload)?.taskId);
@@ -27,6 +27,7 @@ export {
27
27
  export type { MeshQueueTriggerResult } from './mesh-events-coordinator.js';
28
28
  export {
29
29
  tryAssignQueueTask,
30
+ isSessionActivelyGenerating,
30
31
  triggerMeshQueue,
31
32
  handleMeshForwardEvent,
32
33
  setupMeshEventForwarding,
@@ -59,6 +59,13 @@ export type MeshLedgerKind =
59
59
  | 'mission_created'
60
60
  | 'mission_status_changed'
61
61
  | 'mission_goal_updated'
62
+ // MAGI (Multi-Agent Ground-truth Insight) cross-verification activity. Persisted
63
+ // so a wait=false fan-out and its later synthesis survive coordinator restarts and
64
+ // are foldable into mesh_status (keyed by consensusGroupId).
65
+ // magi_dispatched payload: { source:'magi', consensusGroupId, missionId?, panel?, question?, replicaCount }
66
+ // magi_synthesis payload: { source:'magi', consensusGroupId, missionId?, panel?, question?, synthesis }
67
+ | 'magi_dispatched'
68
+ | 'magi_synthesis'
62
69
  ;
63
70
 
64
71
  export interface MeshLedgerEntry {