@adhdev/daemon-core 1.0.18-rc.9 → 1.0.18

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 (67) hide show
  1. package/dist/cli-adapters/provider-cli-shared.d.ts +33 -0
  2. package/dist/commands/cli-manager.d.ts +9 -0
  3. package/dist/commands/upgrade-helper.d.ts +1 -0
  4. package/dist/commands/windows-atomic-upgrade.d.ts +1 -0
  5. package/dist/config/mesh-json-config.d.ts +62 -0
  6. package/dist/index.d.ts +4 -2
  7. package/dist/index.js +2204 -657
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2172 -634
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/mesh/mesh-active-work.d.ts +1 -1
  12. package/dist/mesh/mesh-ledger.d.ts +1 -1
  13. package/dist/mesh/mesh-node-identity.d.ts +23 -0
  14. package/dist/mesh/mesh-refine-gates.d.ts +89 -0
  15. package/dist/mesh/mesh-remote-event-pull.d.ts +3 -0
  16. package/dist/mesh/mesh-work-queue.d.ts +24 -1
  17. package/dist/providers/auto-approve-modes.d.ts +14 -0
  18. package/dist/providers/chat-message-normalization.d.ts +22 -0
  19. package/dist/providers/cli-provider-instance-types.d.ts +2 -0
  20. package/dist/providers/cli-provider-instance.d.ts +76 -0
  21. package/dist/providers/contracts.d.ts +17 -0
  22. package/dist/providers/provider-schema.d.ts +1 -0
  23. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
  24. package/dist/providers/types/interactive-prompt.d.ts +18 -0
  25. package/dist/repo-mesh-types.d.ts +38 -6
  26. package/dist/shared-types.d.ts +4 -2
  27. package/package.json +3 -3
  28. package/src/cli-adapters/cli-state-engine.ts +17 -1
  29. package/src/cli-adapters/provider-cli-adapter.ts +2 -6
  30. package/src/cli-adapters/provider-cli-shared.ts +41 -0
  31. package/src/commands/cli-manager.ts +72 -8
  32. package/src/commands/high-family/mesh-events.ts +13 -2
  33. package/src/commands/med-family/mesh-crud.ts +155 -0
  34. package/src/commands/med-family/mesh-queue.ts +74 -1
  35. package/src/commands/router-refine.ts +71 -4
  36. package/src/commands/router.ts +8 -0
  37. package/src/commands/upgrade-helper.ts +50 -1
  38. package/src/commands/windows-atomic-upgrade.ts +88 -23
  39. package/src/config/mesh-json-config.ts +103 -0
  40. package/src/index.ts +21 -1
  41. package/src/mesh/coordinator-prompt.ts +2 -1
  42. package/src/mesh/mesh-active-work.ts +11 -1
  43. package/src/mesh/mesh-completion-synthesis.ts +12 -1
  44. package/src/mesh/mesh-event-classify.ts +19 -0
  45. package/src/mesh/mesh-event-forwarding.ts +64 -6
  46. package/src/mesh/mesh-events-utils.ts +42 -0
  47. package/src/mesh/mesh-ledger.ts +5 -0
  48. package/src/mesh/mesh-node-identity.ts +49 -0
  49. package/src/mesh/mesh-queue-assignment.ts +84 -10
  50. package/src/mesh/mesh-reconcile-loop.ts +257 -3
  51. package/src/mesh/mesh-refine-gates.ts +300 -0
  52. package/src/mesh/mesh-remote-event-pull.ts +68 -47
  53. package/src/mesh/mesh-work-queue.ts +85 -2
  54. package/src/providers/auto-approve-modes.ts +97 -0
  55. package/src/providers/chat-message-normalization.ts +53 -0
  56. package/src/providers/cli-provider-instance-types.ts +28 -0
  57. package/src/providers/cli-provider-instance.ts +394 -28
  58. package/src/providers/contracts.ts +25 -1
  59. package/src/providers/provider-schema.ts +83 -0
  60. package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
  61. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
  62. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +44 -0
  63. package/src/providers/types/interactive-prompt.ts +77 -0
  64. package/src/repo-mesh-types.ts +112 -12
  65. package/src/shared-types.ts +9 -1
  66. package/src/status/reporter.ts +1 -0
  67. package/src/status/snapshot.ts +2 -0
@@ -144,19 +144,68 @@ function resolveInstallPrefixFromPackageRoot(packageRoot: string, packageName: s
144
144
  return maybeLibDir;
145
145
  }
146
146
 
147
+ // True when `prefix` is the bin dir of a portable Node 22 the installer manages
148
+ // under ~/.adhdev/tools/node22/<node-vX>/. A `npm i -g adhdev` run while that
149
+ // portable node is the active `node` installs adhdev into node's own default
150
+ // global prefix (= that dir) — a "legacy node22-prefix" install that lives at
151
+ // the FRONT of PATH (Enable-NodePath prepends node22) and shadows the canonical
152
+ // dispatcher shims in ~/.adhdev/npm-global. Because self-upgrade reuses the
153
+ // running prefix, that install then re-installs into the same node22 dir forever
154
+ // and never converts to the dispatcher. Detecting it lets us force convergence.
155
+ function isPortableNode22Prefix(prefix: string | null, homeDir: string): boolean {
156
+ if (!prefix) return false;
157
+ const portableRoot = path.join(homeDir, '.adhdev', 'tools', 'node22');
158
+ const normalizedPrefix = path.resolve(prefix).replace(/[\\/]+$/, '').toLowerCase();
159
+ const normalizedRoot = path.resolve(portableRoot).replace(/[\\/]+$/, '').toLowerCase();
160
+ return normalizedPrefix === normalizedRoot || normalizedPrefix.startsWith(`${normalizedRoot}${path.sep.toLowerCase()}`);
161
+ }
162
+
163
+ // Redirect a legacy node22-prefix install to the canonical dispatcher install
164
+ // root so resolveWindowsInstallerLayout accepts it and the atomic-upgrade
165
+ // publishes the ~/.adhdev/npm-global pointer + shims. Prefer the version the
166
+ // dispatcher pointer already names (so we sit on the real active dispatcher
167
+ // prefix); otherwise synthesize a stable migration sentinel under npm-installs.
168
+ // resolveWindowsInstallerLayout only requires a `npm-installs/version-*` path —
169
+ // performWindowsAtomicUpgrade stages a fresh version- prefix of its own and uses
170
+ // this only as the "old prefix" to stop/clean, so a non-existent path is a no-op.
171
+ function canonicalDispatcherInstallPrefix(homeDir: string): string {
172
+ const installRoot = path.join(homeDir, '.adhdev', 'npm-installs');
173
+ const pointerPath = path.join(homeDir, '.adhdev', 'npm-global', '.adhdev-current');
174
+ try {
175
+ const activeVersion = fs.readFileSync(pointerPath, 'utf8').trim();
176
+ if (activeVersion.startsWith('version-')) return path.join(installRoot, activeVersion);
177
+ } catch {
178
+ // No dispatcher pointer yet (first migration off the legacy layout).
179
+ }
180
+ return path.join(installRoot, 'version-legacy-migrate');
181
+ }
182
+
147
183
  export function resolveCurrentGlobalInstallSurface(options: {
148
184
  packageName: string;
149
185
  currentCliPath?: string;
150
186
  nodeExecutable?: string;
151
187
  platform?: NodeJS.Platform;
188
+ homeDir?: string;
152
189
  }): CurrentGlobalInstallSurface {
153
190
  const packageRoot = findCurrentPackageRoot(options.currentCliPath || process.argv[1], options.packageName);
154
191
  const npmInvocation = resolveSiblingNpmInvocation(options.nodeExecutable || process.execPath, options.platform);
192
+ const platform = options.platform || process.platform;
193
+ const homeDir = options.homeDir || os.homedir();
194
+ let installPrefix = packageRoot ? resolveInstallPrefixFromPackageRoot(packageRoot, options.packageName) : null;
195
+ // FIX C: on Windows, never let a self-upgrade perpetuate the legacy
196
+ // node22-prefix install. If the running adhdev lives under ~/.adhdev/tools/
197
+ // node22, force the install onto the canonical dispatcher prefix so the update
198
+ // converges to the ~/.adhdev/npm-global pointer + shims. Scoped to win32 AND a
199
+ // tools/node22 prefix so npm-linked dev / standalone / real dispatcher installs
200
+ // are untouched.
201
+ if (platform === 'win32' && isPortableNode22Prefix(installPrefix, homeDir)) {
202
+ installPrefix = canonicalDispatcherInstallPrefix(homeDir);
203
+ }
155
204
  return {
156
205
  npmExecutable: npmInvocation.executable,
157
206
  npmArgsPrefix: npmInvocation.argsPrefix,
158
207
  packageRoot,
159
- installPrefix: packageRoot ? resolveInstallPrefixFromPackageRoot(packageRoot, options.packageName) : null,
208
+ installPrefix,
160
209
  execOptions: npmInvocation.execOptions,
161
210
  };
162
211
  }
@@ -8,6 +8,15 @@ const POINTER_NAME = '.adhdev-current';
8
8
  const STABLE_FILES = [POINTER_NAME, 'adhdev.cmd', 'adhdev.ps1', 'adhdev'] as const;
9
9
  const DEFAULT_HEALTH_PORT = 19222;
10
10
 
11
+ // How long to wait for the replacement daemon to report the target version
12
+ // before deterministically rolling back. status.version only appears AFTER the
13
+ // daemon fully boots its components — a separate session-host process, node-pty/
14
+ // conpty, CDP, and providers — which on a Windows cold self-upgrade routinely
15
+ // exceeds the old 30s ceiling. Live Windows logs showed every rollback clustering
16
+ // at 33–35s (30s timeout + poll overhead), so the gate was tripping on slow-boot,
17
+ // not on genuine failure. 120s reflects real Windows self-upgrade cold-start time.
18
+ export const DEFAULT_HEALTH_TIMEOUT_MS = 120_000;
19
+
11
20
  function fetchLocalJson(port: number, pathname: string): Promise<{ ok: boolean; body: string }> {
12
21
  return new Promise((resolve) => {
13
22
  const req = http.get(`http://127.0.0.1:${port}${pathname}`, { timeout: 1500 }, (res) => {
@@ -310,10 +319,39 @@ function snapshotStableFiles(stablePrefix: string): Map<string, FileSnapshot> {
310
319
  return snapshots;
311
320
  }
312
321
 
313
- function restoreStableFiles(snapshots: Map<string, FileSnapshot>): void {
322
+ // Rollback must never leave PATH `adhdev` broken. Two failure modes made the old
323
+ // "restore-or-delete" logic dangerous:
324
+ // 1. If a stable shim (adhdev.cmd/.ps1/no-ext) did not exist at snapshot time —
325
+ // a first/partial install, or a stable tree that never had the launcher —
326
+ // deleting it leaves `where.exe adhdev` / `spawnSync('adhdev')` resolving to
327
+ // nothing (ENOENT). `adhdev doctor` then reports the runtime surface broken.
328
+ // 2. If the pointer (.adhdev-current) was absent at snapshot time, deleting it
329
+ // strands the re-published shims with no version to redirect to.
330
+ // So rollback (re)guarantees a valid launcher surface: existing snapshots restore
331
+ // their original bytes atomically; missing shims are re-issued from the canonical
332
+ // pointer-redirect launcher contents; and the pointer, when it has no snapshot,
333
+ // is re-written to the last-known-good active version instead of removed.
334
+ function restoreStableFiles(snapshots: Map<string, FileSnapshot>, layout: WindowsInstallerLayout): void {
335
+ const shims = stableShimContents();
314
336
  for (const [target, snapshot] of snapshots) {
315
- if (snapshot.exists && snapshot.data) atomicWrite(target, snapshot.data.toString('binary'), 'binary');
316
- else try { fs.unlinkSync(target); } catch { /* noop */ }
337
+ if (snapshot.exists && snapshot.data) {
338
+ atomicWrite(target, snapshot.data.toString('binary'), 'binary');
339
+ continue;
340
+ }
341
+ const name = path.basename(target);
342
+ if (name === 'adhdev.cmd' || name === 'adhdev.ps1' || name === 'adhdev') {
343
+ // Re-issue a valid pointer-redirect launcher rather than deleting it, so
344
+ // PATH `adhdev` always resolves after a rollback.
345
+ atomicWrite(target, shims[name].content, shims[name].encoding);
346
+ } else if (name === POINTER_NAME) {
347
+ // Preserve the last-successful (currently active) version so the redirect
348
+ // launchers still reach a real prefix. Only fall back to deleting when we
349
+ // have no active version to point at.
350
+ if (layout.activeVersionName) atomicWrite(target, layout.activeVersionName, 'ascii');
351
+ else try { fs.unlinkSync(target); } catch { /* noop */ }
352
+ } else {
353
+ try { fs.unlinkSync(target); } catch { /* noop */ }
354
+ }
317
355
  }
318
356
  }
319
357
 
@@ -377,7 +415,7 @@ export async function performWindowsAtomicUpgrade(options: WindowsAtomicUpgradeO
377
415
  if (restarted?.pid) hooks.stopProcess(restarted.pid);
378
416
  if (activated) {
379
417
  try {
380
- restoreStableFiles(snapshots);
418
+ restoreStableFiles(snapshots, layout);
381
419
  hooks.log(`Rolled back activation to ${layout.activeVersionName}`);
382
420
  } catch (rollbackError: any) {
383
421
  hooks.log(`Stable-file rollback failed: ${rollbackError?.message || String(rollbackError)}`);
@@ -403,7 +441,7 @@ export function createDefaultWindowsAtomicHooks(options: {
403
441
  healthTimeoutMs?: number;
404
442
  }): WindowsAtomicUpgradeHooks {
405
443
  const healthPort = options.healthPort ?? DEFAULT_HEALTH_PORT;
406
- const healthTimeoutMs = options.healthTimeoutMs ?? 30000;
444
+ const healthTimeoutMs = options.healthTimeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS;
407
445
  return {
408
446
  install: (stagedPrefix, portableNode) => {
409
447
  const env: NodeJS.ProcessEnv = {
@@ -454,8 +492,17 @@ export function createDefaultWindowsAtomicHooks(options: {
454
492
  child.unref();
455
493
  },
456
494
  waitForHealth: async (pid, targetVersion) => {
457
- const deadline = Date.now() + healthTimeoutMs;
495
+ const startedAt = Date.now();
496
+ const deadline = startedAt + healthTimeoutMs;
497
+ let attempt = 0;
498
+ // Log the transition milestones exactly once so the daemon log shows how far
499
+ // the replacement got before the gate resolved: not-yet-alive → alive but
500
+ // version-not-yet-ready (components still booting) → version matched. This is
501
+ // what pins down which boot stage the full-boot budget is being spent in.
502
+ let loggedAlive = false;
503
+ let loggedVersionPending = false;
458
504
  while (Date.now() < deadline) {
505
+ attempt += 1;
459
506
  // Liveness + pid identity come from GET /health, whose body is
460
507
  // {ok, pid, wsPath, port} — it carries NO version. The running version
461
508
  // lives only in GET /api/v1/status → payload.status.version, so the
@@ -465,9 +512,22 @@ export function createDefaultWindowsAtomicHooks(options: {
465
512
  const liveness = await fetchLocalHealth(healthPort);
466
513
  const alive = liveness.ok && liveness.pid === pid;
467
514
  const version = alive ? await fetchLocalStatusVersion(healthPort) : undefined;
468
- if (alive && version === targetVersion) return true;
515
+ const elapsedMs = Date.now() - startedAt;
516
+ if (alive && version === targetVersion) {
517
+ options.log(`Health gate passed after ${elapsedMs}ms (${attempt} probe(s)): pid ${pid} reports ${targetVersion}`);
518
+ return true;
519
+ }
520
+ if (alive && !loggedAlive) {
521
+ loggedAlive = true;
522
+ options.log(`Health gate: replacement pid ${pid} is alive at ${elapsedMs}ms; awaiting status.version (components still booting)`);
523
+ }
524
+ if (alive && version && version !== targetVersion && !loggedVersionPending) {
525
+ loggedVersionPending = true;
526
+ options.log(`Health gate: replacement reports version ${version} (want ${targetVersion}) at ${elapsedMs}ms`);
527
+ }
469
528
  await new Promise((resolve) => setTimeout(resolve, 500));
470
529
  }
530
+ options.log(`Health gate timed out after ${Date.now() - startedAt}ms (${attempt} probe(s), budget ${healthTimeoutMs}ms) waiting for pid ${pid} to report ${targetVersion}`);
471
531
  return false;
472
532
  },
473
533
  stopProcess: (pid) => {
@@ -502,13 +562,19 @@ export function boundedCleanupInactivePrefixes(
502
562
  return;
503
563
  }
504
564
  if (candidates.length === 0) return;
505
- const escaped = candidates.map((candidate) => quotePowerShellLiteral(candidate)).join(',');
506
- const script = `$ErrorActionPreference='Stop'; @(${escaped}) | ForEach-Object { if (Test-Path -LiteralPath $_) { Remove-Item -LiteralPath $_ -Recurse -Force } }`;
507
- const encoded = Buffer.from(script, 'utf16le').toString('base64');
508
- const result = spawnSync('powershell.exe', [
509
- '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded,
510
- ], { timeout: 5000, windowsHide: true, stdio: 'ignore' });
511
- if (result.error || result.status !== 0) log('Bounded inactive-prefix cleanup was incomplete; future updates will retry');
565
+ // Delete in-process (see removeInactivePrefix) rather than via a powershell.exe
566
+ // Remove-Item batch under a 5000ms spawnSync timeout, which ETIMEDOUT'd on
567
+ // Windows and left orphan version-* prefixes behind. best-effort: a failure
568
+ // here never blocks the upgrade success/failure signal.
569
+ let incomplete = false;
570
+ for (const candidate of candidates) {
571
+ try {
572
+ fs.rmSync(candidate, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
573
+ } catch {
574
+ incomplete = true;
575
+ }
576
+ }
577
+ if (incomplete) log('Bounded inactive-prefix cleanup was incomplete; future updates will retry');
512
578
  }
513
579
 
514
580
  export async function cleanupInactivePrefixesWithGuard(options: {
@@ -550,16 +616,15 @@ export async function cleanupInactivePrefixesWithGuard(options: {
550
616
  }
551
617
 
552
618
  function removeInactivePrefix(target: string, log?: (message: string) => void): void {
619
+ // Delete in-process with fs.rmSync instead of shelling out to powershell.exe.
620
+ // A version prefix holds thousands of small files (node_modules, node-pty
621
+ // prebuilds); Remove-Item -Recurse -Force over that, plus PowerShell 5.1's
622
+ // cold-start, routinely blew past the old 5000ms spawnSync timeout on Windows
623
+ // — every candidate then failed with ETIMEDOUT and orphan version-* dirs
624
+ // accumulated. fs.rmSync has no process spawn, no timeout, and retries the
625
+ // transient EBUSY/EPERM/ENOTEMPTY that a just-stopped process can leave behind.
553
626
  try {
554
- const escaped = quotePowerShellLiteral(target);
555
- const script = `if (Test-Path -LiteralPath ${escaped}) { Remove-Item -LiteralPath ${escaped} -Recurse -Force }`;
556
- const encoded = Buffer.from(script, 'utf16le').toString('base64');
557
- const result = spawnSync('powershell.exe', [
558
- '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded,
559
- ], { timeout: 5000, windowsHide: true, stdio: 'ignore' });
560
- if (result.error || result.status !== 0) {
561
- log?.(`Failed to remove inactive prefix ${target}: ${result.error?.message || `exit ${result.status}`}`);
562
- }
627
+ fs.rmSync(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
563
628
  } catch (error: any) {
564
629
  log?.(`Failed to remove inactive prefix ${target}: ${error?.message || String(error)}`);
565
630
  }
@@ -19,6 +19,18 @@
19
19
  * machine-local policy directly. The repo file shapes the coordinator prompt and
20
20
  * operating notes, nothing else.
21
21
  *
22
+ * `providerDefaults` IS NOT POLICY EITHER. It is a repo-shared DECLARATIVE
23
+ * "requested auto-approve mode" per providerType — the answer to "WHEN a delegated
24
+ * worker of type X is auto-approved, WHICH mode should it use by default". It has
25
+ * NO say over WHETHER auto-approve is enabled: the ENABLE decision (and the
26
+ * dangerous-mode opt-in) remains 100% machine-local (meshes.json /
27
+ * RepoMeshPolicy). A repo can declare `providerDefaults` freely; a machine that
28
+ * has delegatedWorkerAutoApprove=false still gets no auto-approve, and a dangerous
29
+ * requested mode is still downgraded to PTY parsing unless the machine opts in.
30
+ * The requested mode ID is validated at runtime against the provider spec — an
31
+ * unknown mode ID is IGNORED (fall back to the provider's own default), never
32
+ * silently coerced into a dangerous mode.
33
+ *
22
34
  * The coordinator/operatingNotes merge is **in-memory only**: the on-disk
23
35
  * machine-local `meshes.json` is never mutated by this module.
24
36
  *
@@ -60,6 +72,19 @@ export interface RepoMeshDeclarativeLimits {
60
72
  maxNotes?: number;
61
73
  }
62
74
 
75
+ /**
76
+ * Repo-shared declarative per-provider defaults. NOT policy — see the file header.
77
+ * `autoApproveModes` maps a providerType (e.g. "claude-cli") to the auto-approve
78
+ * mode ID that a delegated worker of that type should REQUEST when it is
79
+ * auto-approved. The map only influences WHICH mode is used, never WHETHER
80
+ * auto-approve is enabled (that stays machine-local). Mode IDs are validated
81
+ * against the live provider spec at resolve time; an unknown ID is ignored.
82
+ */
83
+ export interface RepoMeshDeclarativeProviderDefaults {
84
+ /** providerType → requested auto-approve mode ID. */
85
+ autoApproveModes?: Record<string, string>;
86
+ }
87
+
63
88
  /**
64
89
  * Parsed + normalized `.adhdev/mesh.json` shape. Every field is optional except
65
90
  * version so a repo can declare only the zone(s) it cares about. Policy is NOT a
@@ -70,6 +95,8 @@ export interface RepoMeshDeclarativeConfig {
70
95
  coordinator?: RepoMeshDeclarativeCoordinatorConfig;
71
96
  operatingNotes?: CoordinatorOperatingNote[];
72
97
  limits?: RepoMeshDeclarativeLimits;
98
+ /** Repo-shared per-provider defaults (requested auto-approve mode). NOT policy. */
99
+ providerDefaults?: RepoMeshDeclarativeProviderDefaults;
73
100
  }
74
101
 
75
102
  export interface RepoMeshJsonConfigLoadResult {
@@ -132,6 +159,20 @@ export const MESH_JSON_CONFIG_SCHEMA = {
132
159
  maxNotes: { type: 'number', minimum: 1 },
133
160
  },
134
161
  },
162
+ providerDefaults: {
163
+ type: 'object',
164
+ additionalProperties: false,
165
+ properties: {
166
+ // providerType → requested auto-approve mode ID. Mode IDs are
167
+ // validated against the live provider spec at resolve time; an
168
+ // unknown ID is ignored (provider default is used), so the schema
169
+ // only constrains the shape (string→string), not the ID values.
170
+ autoApproveModes: {
171
+ type: 'object',
172
+ additionalProperties: { type: 'string' },
173
+ },
174
+ },
175
+ },
135
176
  },
136
177
  } as const;
137
178
 
@@ -226,6 +267,38 @@ export function normalizeRepoMeshDeclarativeConfig(parsed: unknown): {
226
267
  }
227
268
  }
228
269
 
270
+ // providerDefaults: shape-only normalization. We keep every string→string
271
+ // (providerType → requested mode ID) entry after trimming; we deliberately do
272
+ // NOT validate the mode IDs against any provider spec here — the normalizer has
273
+ // no provider registry, and (crucially) validity depends on the LIVE provider
274
+ // at resolve time. The runtime resolver (resolveDelegatedWorkerAutoApprove)
275
+ // checks the requested ID against provider.autoApproveModes.modes and falls
276
+ // back to the provider's own default when the ID is unknown. This keeps the
277
+ // config fail-closed: a stale/typo'd mode ID never coerces into a dangerous
278
+ // mode, it just means "no repo override → provider default".
279
+ if (parsed.providerDefaults !== undefined) {
280
+ if (isRecord(parsed.providerDefaults)) {
281
+ const pd: RepoMeshDeclarativeProviderDefaults = {};
282
+ const rawModes = (parsed.providerDefaults as Record<string, unknown>).autoApproveModes;
283
+ if (rawModes !== undefined) {
284
+ if (isRecord(rawModes)) {
285
+ const modes: Record<string, string> = {};
286
+ for (const [providerType, modeId] of Object.entries(rawModes)) {
287
+ const type = typeof providerType === 'string' ? providerType.trim() : '';
288
+ const id = typeof modeId === 'string' ? modeId.trim() : '';
289
+ if (type && id) modes[type] = id;
290
+ }
291
+ if (Object.keys(modes).length) pd.autoApproveModes = modes;
292
+ } else {
293
+ errors.push('providerDefaults.autoApproveModes must be an object when provided');
294
+ }
295
+ }
296
+ if (Object.keys(pd).length) config.providerDefaults = pd;
297
+ } else {
298
+ errors.push('providerDefaults must be an object when provided');
299
+ }
300
+ }
301
+
229
302
  return { valid: true, config, errors };
230
303
  }
231
304
 
@@ -364,6 +437,21 @@ export function applyRepoMeshConfig<T extends Pick<LocalMeshEntry, 'coordinator'
364
437
  * exported — it is machine-local only and has no place in mesh.json. Operating
365
438
  * notes are intentionally NOT exported either: those are runtime ledger lessons,
366
439
  * and a repo should declare baseline notes deliberately.
440
+ *
441
+ * `providerDefaults` is NOT auto-exported from the machine-local mesh (there is no
442
+ * machine-local source for it — it is a repo-authored declaration). To help an
443
+ * operator hand-author it, the scaffold carries a commented example shape:
444
+ *
445
+ * "providerDefaults": {
446
+ * "autoApproveModes": {
447
+ * "claude-cli": "accept-edits", // requested mode WHEN auto-approve is on
448
+ * "codex-cli": "auto" // (enable/dangerous opt-in stays machine-local)
449
+ * }
450
+ * }
451
+ *
452
+ * The example is surfaced via the scaffold's `_providerDefaultsExample` hint (JSON
453
+ * has no comments) rather than a live `providerDefaults` value, so serializing the
454
+ * scaffold never writes an unwanted requested-mode map into the repo file.
367
455
  */
368
456
  export function buildMeshJsonConfigScaffold(
369
457
  mesh: Pick<LocalMeshEntry, 'coordinator'>,
@@ -378,6 +466,21 @@ export function buildMeshJsonConfigScaffold(
378
466
  return scaffold;
379
467
  }
380
468
 
469
+ /**
470
+ * A copy-paste example of the `providerDefaults` zone for operators hand-authoring
471
+ * a repo `.adhdev/mesh.json`. Returned by the export/write commands as a separate
472
+ * hint field (never merged into the serialized scaffold) so the repo file stays
473
+ * clean. The mode IDs shown are illustrative — a repo should use IDs that exist in
474
+ * its own providers' specs; an unknown ID is ignored at resolve time and the
475
+ * provider's own default is used.
476
+ */
477
+ export const MESH_JSON_PROVIDER_DEFAULTS_EXAMPLE: RepoMeshDeclarativeProviderDefaults = {
478
+ autoApproveModes: {
479
+ 'claude-cli': 'accept-edits',
480
+ 'codex-cli': 'auto',
481
+ },
482
+ };
483
+
381
484
  /** Serialize a scaffold to the canonical 2-space JSON draft text. */
382
485
  export function serializeMeshJsonConfigScaffold(config: RepoMeshDeclarativeConfig): string {
383
486
  return JSON.stringify(config, null, 2);
package/src/index.ts CHANGED
@@ -143,6 +143,8 @@ export type {
143
143
  export {
144
144
  DEFAULT_MESH_POLICY,
145
145
  resolveDelegatedWorkerAutoApprove,
146
+ delegatedWorkerAutoApproveSettings,
147
+ resolveDelegatedWorkerDangerousModeAllow,
146
148
  resolveAllowSendKeysDestructive,
147
149
  resolveMagiSessionCleanupMode,
148
150
  magiAutoLaunchedSessionCleanupDecision,
@@ -161,6 +163,24 @@ export {
161
163
  resolveAutoConvergeCodeChange,
162
164
  } from './repo-mesh-types.js';
163
165
 
166
+ // ── Repo-shared declarative mesh config (.adhdev/mesh.json) ──
167
+ export {
168
+ loadRepoMeshJsonConfig,
169
+ normalizeRepoMeshDeclarativeConfig,
170
+ buildMeshJsonConfigScaffold,
171
+ serializeMeshJsonConfigScaffold,
172
+ MESH_JSON_CONFIG_LOCATIONS,
173
+ MESH_JSON_CONFIG_SCHEMA,
174
+ MESH_JSON_PROVIDER_DEFAULTS_EXAMPLE,
175
+ } from './config/mesh-json-config.js';
176
+ export type {
177
+ RepoMeshDeclarativeConfig,
178
+ RepoMeshDeclarativeCoordinatorConfig,
179
+ RepoMeshDeclarativeLimits,
180
+ RepoMeshDeclarativeProviderDefaults,
181
+ RepoMeshJsonConfigLoadResult,
182
+ } from './config/mesh-json-config.js';
183
+
164
184
  // ── Git Surface ──
165
185
  export * from './git/index.js';
166
186
 
@@ -489,7 +509,7 @@ export { ProviderInstanceManager } from './providers/provider-instance-manager.j
489
509
  export { IdeProviderInstance } from './providers/ide-provider-instance.js';
490
510
  export { CliProviderInstance } from './providers/cli-provider-instance.js';
491
511
  export { AcpProviderInstance } from './providers/acp-provider-instance.js';
492
- export type { ProviderModule, CdpTargetFilter, ProviderResumeCapability, InputEnvelope, InputPart, MessagePart, ReadChatTurnStatus, ControlListResult, ControlSetResult, ControlInvokeResult } from './providers/contracts.js';
512
+ export type { ProviderModule, AutoApproveMode, AutoApproveModesConfig, AutoApproveModeRisk, AutoApproveModeStrategy, CdpTargetFilter, ProviderResumeCapability, InputEnvelope, InputPart, MessagePart, ReadChatTurnStatus, ControlListResult, ControlSetResult, ControlInvokeResult } from './providers/contracts.js';
493
513
  export type { ProviderSourceConfigSnapshot, ProviderSourceConfigUpdate } from './config/provider-source-config.js';
494
514
  export { parseProviderSourceConfigUpdate } from './config/provider-source-config.js';
495
515
  export { normalizeInputEnvelope, normalizeMessageParts, flattenMessageParts } from './providers/io-contracts.js';
@@ -1007,7 +1007,8 @@ const TOOLS_SECTION = `## Available Tools
1007
1007
  | \`mesh_fast_forward_node\` | Safely dry-run or explicitly execute an obvious clean fast-forward without launching an agent session |
1008
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) |
1009
1009
  | \`mesh_checkpoint\` | Create a git checkpoint on a node |
1010
- | \`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 |
1011
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 |
1012
1013
  | \`mesh_clone_node\` | Create a worktree node for isolated parallel branch work |
1013
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
@@ -8,6 +8,11 @@ const MESH_COORDINATOR_EVENTS = new Set([
8
8
  'agent:generating_started',
9
9
  'agent:generating_completed',
10
10
  'agent:waiting_approval',
11
+ // A worker parked on an AskUserQuestion multi-choice prompt. DISTINCT from
12
+ // agent:waiting_approval (a yes/no tool-consent modal): a question is answered
13
+ // with mesh_answer_question, never mesh_approve (mission f1d25e11). Carries the
14
+ // full InteractivePrompt payload so the coordinator can render + answer.
15
+ 'agent:waiting_choice',
11
16
  'agent:stopped',
12
17
  'agent:ready',
13
18
  'monitor:no_progress',
@@ -26,6 +31,7 @@ const MESH_COORDINATOR_EVENTS = new Set([
26
31
  export const EVENT_TO_LEDGER_KIND: Record<string, MeshLedgerKind> = {
27
32
  'agent:generating_completed': 'task_completed',
28
33
  'agent:waiting_approval': 'task_approval_needed',
34
+ 'agent:waiting_choice': 'task_question_pending',
29
35
  'agent:stopped': 'task_failed',
30
36
  'monitor:no_progress': 'task_stalled',
31
37
  };
@@ -45,6 +51,11 @@ export const MESH_FORCE_INJECT_EVENTS: ReadonlySet<string> = new Set([
45
51
  'agent:generating_completed',
46
52
  'agent:stopped',
47
53
  'agent:waiting_approval',
54
+ // A worker's question (waiting_choice) is real-time and the coordinator may be
55
+ // generating when it arrives; force-inject it like approval so the busy coordinator
56
+ // learns it must answer (mesh_answer_question) rather than the nudge sitting in the
57
+ // adapter's outbound queue until the coordinator idles on its own (mission f1d25e11).
58
+ 'agent:waiting_choice',
48
59
  'refine:completed',
49
60
  'refine:failed',
50
61
  'worktree_bootstrap_complete',
@@ -71,6 +82,14 @@ export function shouldForceInjectMeshEvent(eventName: unknown): boolean {
71
82
  // require, and why a stale/resolved approval nudge can simply be dropped.
72
83
  export const MESH_APPROVAL_EVENTS: ReadonlySet<string> = new Set([
73
84
  'agent:waiting_approval',
85
+ // agent:waiting_choice (a worker's AskUserQuestion) shares the approval class's
86
+ // real-time-nudge / level-backed contract: its authoritative state is recorded at
87
+ // LEVEL the moment it is processed (task_question_pending → mesh_status
88
+ // awaiting_choice), so the pending event is only a NUDGE and can be delivered to a
89
+ // busy coordinator's inbox (and dropped) without data loss — the level state
90
+ // re-derives it. It is therefore exempt from the idle-edge hold completions require,
91
+ // exactly like an approval nudge (mission f1d25e11).
92
+ 'agent:waiting_choice',
74
93
  ]);
75
94
 
76
95
  export function isMeshApprovalEvent(eventName: unknown): boolean {