@adhdev/daemon-core 1.0.18-rc.8 → 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 +5 -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 +2309 -689
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2277 -666
  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 +78 -15
  38. package/src/commands/windows-atomic-upgrade.ts +146 -38
  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 +210 -11
  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
@@ -254,6 +254,47 @@ export interface CliProviderModule {
254
254
  _versionWarning?: string | null;
255
255
  }
256
256
 
257
+ /**
258
+ * PURE-PTY TRANSCRIPT CLASS predicate (kimi and kin).
259
+ *
260
+ * A provider is "pure-PTY full-buffer" when it reconstructs its ENTIRE transcript
261
+ * from the rendered PTY buffer on every read and has NO alternate transcript
262
+ * source:
263
+ * - transcriptAuthority !== 'provider' (the daemon PTY parser owns the transcript,
264
+ * not a provider-side canonical source)
265
+ * - NO nativeHistory (no on-disk / native-source history to fall back to)
266
+ * - tui.transcriptPty.scope === 'buffer' (the parser walks the full rendered buffer)
267
+ *
268
+ * This class is invisible to two provider-authority-keyed code paths that other
269
+ * providers rely on for mesh completion semantics:
270
+ * 1. CliStateEngine.onTurnStarted only promotes to 'generating' for
271
+ * transcriptAuthority==='provider' providers — so a pure-PTY session that
272
+ * submits a prompt while already idle collapses idle→idle, the
273
+ * generating→idle edge never occurs, and agent:generating_completed is never
274
+ * emitted (the coordinator ledger leaves the task 'assigned' forever).
275
+ * 2. checkMeshWorkerStall's native-transcript completion reconcile is gated on
276
+ * the native-source shape, so a finished pure-PTY worker's static idle is
277
+ * misread as monitor:no_progress (a false task_stalled).
278
+ *
279
+ * The runtime capability, NOT any single spec field value, is authoritative: a
280
+ * given kimi manifest checkout may declare nativeHistory/transcriptAuthority, but
281
+ * the live-loaded pure-PTY session has none. Callers that only hold a
282
+ * CliProviderModule (the engine) share this exact predicate with the adapter
283
+ * (parsesFullPtyTranscriptFromBuffer) so the two never drift.
284
+ */
285
+ export function isPurePtyTranscriptProvider(provider: {
286
+ transcriptAuthority?: 'provider' | 'daemon';
287
+ nativeHistory?: unknown;
288
+ tui?: Record<string, unknown>;
289
+ }): boolean {
290
+ if (provider.transcriptAuthority === 'provider') return false;
291
+ // nativeHistory is a top-level provider field not surfaced on
292
+ // CliProviderModule; read it via the same structural shape the adapter uses.
293
+ if (provider.nativeHistory) return false;
294
+ const transcriptPty = (provider.tui as { transcriptPty?: { scope?: unknown } } | undefined)?.transcriptPty;
295
+ return transcriptPty?.scope === 'buffer';
296
+ }
297
+
257
298
  function stripAnsi(str: string): string {
258
299
  // eslint-disable-next-line no-control-regex
259
300
  return str
@@ -34,6 +34,7 @@ import type { SessionRegistry } from '../sessions/registry.js';
34
34
  import type { ProviderInstance } from '../providers/provider-instance.js';
35
35
  import { LOG } from '../logging/logger.js';
36
36
  import { shouldRestoreHostedRuntime } from './hosted-runtime-restore.js';
37
+ import { findProviderAutoApproveMode, resolveProviderAutoApproveMode } from '../providers/auto-approve-modes.js';
37
38
 
38
39
  // ─── external dependency interface ──────────────────────────
39
40
 
@@ -452,6 +453,40 @@ export function expandThinkingLaunchArgs(
452
453
  return template.map((part) => part.includes('{{level}}') ? part.replace('{{level}}', mapped) : part);
453
454
  }
454
455
 
456
+ function matchesRemovedLaunchArg(arg: string, removeArg: string): boolean {
457
+ return arg === removeArg || (removeArg.startsWith('--') && arg.startsWith(`${removeArg}=`));
458
+ }
459
+
460
+ /**
461
+ * Apply a selected launch-args auto-approve mode without mutating provider metadata.
462
+ * removeArgs only targets provider-owned base spawn.args; launchArgs are prepended to
463
+ * per-launch args beside model/thinking args, making conflict removal order-independent.
464
+ */
465
+ export function applyAutoApproveModeLaunchArgs(
466
+ provider: ProviderModule | undefined,
467
+ cliArgs: string[] | undefined,
468
+ settings: Record<string, unknown> | undefined,
469
+ ): { provider: ProviderModule | undefined; cliArgs: string[] | undefined } {
470
+ if (!provider) return { provider, cliArgs };
471
+ const resolved = resolveProviderAutoApproveMode(provider, settings);
472
+ if (!resolved.active || resolved.strategy !== 'launch-args') return { provider, cliArgs };
473
+ const mode = findProviderAutoApproveMode(provider, resolved.modeId);
474
+ if (!mode || !Array.isArray(mode.launchArgs) || mode.launchArgs.length === 0) return { provider, cliArgs };
475
+
476
+ const removeArgs = Array.isArray(mode.removeArgs) ? mode.removeArgs : [];
477
+ const baseArgs = provider.spawn?.args;
478
+ const filteredBaseArgs = Array.isArray(baseArgs) && removeArgs.length > 0
479
+ ? baseArgs.filter((arg) => !removeArgs.some((removeArg) => matchesRemovedLaunchArg(arg, removeArg)))
480
+ : baseArgs;
481
+ const launchProvider = filteredBaseArgs === baseArgs
482
+ ? provider
483
+ : { ...provider, spawn: { ...provider.spawn!, args: filteredBaseArgs } };
484
+ return {
485
+ provider: launchProvider,
486
+ cliArgs: [...mode.launchArgs, ...(cliArgs || [])],
487
+ };
488
+ }
489
+
455
490
  function readSubcommandSessionId(args: string[], subcommands: string[]): string | undefined {
456
491
  const resumeIndex = args.findIndex((arg) => subcommands.includes(arg));
457
492
  if (resumeIndex < 0) return undefined;
@@ -707,6 +742,24 @@ export class DaemonCliManager {
707
742
  clearInterval(checkStopped);
708
743
  setTimeout(() => {
709
744
  if (this.adapters.has(key)) {
745
+ // KIMI-MESH-COMPLETION-EMIT (axis 2): before removeInstance closes the
746
+ // event-emit window, give a mesh DELEGATED worker one last chance to emit
747
+ // its completion. A native-source worker (e.g. kimi) can have its PTY
748
+ // killed by a false stall AFTER it finished the task (transcript written)
749
+ // but BEFORE the FSM's idle→completed event fired — the instance is the
750
+ // only thing that can emit that event, and it is about to be removed. The
751
+ // instance-side method is a no-op for a non-mesh session or when the
752
+ // turn's completion already fired (double-emit guard) or when there is no
753
+ // transcript evidence of a finished turn. Best-effort — never blocks cleanup.
754
+ try {
755
+ const inst = instanceManager?.getInstance(key) as (ProviderInstance & { flushMeshCompletionBeforeCleanup?: () => boolean }) | undefined;
756
+ if (typeof inst?.flushMeshCompletionBeforeCleanup === 'function') {
757
+ const emitted = inst.flushMeshCompletionBeforeCleanup();
758
+ if (emitted) LOG.info('CLI', `Emitted pre-cleanup mesh completion for ${cliType} session ${key} before auto-clean`);
759
+ }
760
+ } catch (e) {
761
+ LOG.warn('CLI', `pre-cleanup mesh completion flush failed for ${key}: ${(e as Error)?.message || e}`);
762
+ }
710
763
  this.adapters.delete(key);
711
764
  this.deps.removeAgentTracking(key);
712
765
  sessionRegistry?.unregisterByInstanceKey(key);
@@ -1030,6 +1083,17 @@ export class DaemonCliManager {
1030
1083
  console.log(colorize('cyan', ` 📦 Using provider: ${provider.name} (${provider.type})`));
1031
1084
  }
1032
1085
 
1086
+ const launchSettings = {
1087
+ ...this.providerLoader.getSettings(normalizedType),
1088
+ ...(options?.settingsOverride || {}),
1089
+ };
1090
+ const versionResolvedProvider = provider
1091
+ ? (this.providerLoader.resolve(cliType, { version: cliInfo.version }) || provider)
1092
+ : undefined;
1093
+ const autoApproveLaunch = applyAutoApproveModeLaunchArgs(versionResolvedProvider, cliArgs, launchSettings);
1094
+ const launchProvider = autoApproveLaunch.provider || provider;
1095
+ const cliArgsWithAutoApprove = autoApproveLaunch.cliArgs;
1096
+
1033
1097
  // ─── Model axis (MAGI kind-panel): expand initialModel → launch args ───
1034
1098
  // For a plain CLI provider the model is selected at spawn time via the manifest's
1035
1099
  // modelLaunchArgs template ('{{model}}' → the requested model). ACP providers took
@@ -1037,10 +1101,10 @@ export class DaemonCliManager {
1037
1101
  // or no requested model, is a no-op — model selection is best-effort and must never
1038
1102
  // fail a launch. The model args are prepended so a caller's explicit cliArgs (e.g. a
1039
1103
  // resume flag) still win positionally where order matters.
1040
- const modelLaunchArgs = expandModelLaunchArgs(provider?.modelLaunchArgs, initialModel);
1104
+ const modelLaunchArgs = expandModelLaunchArgs(launchProvider?.modelLaunchArgs, initialModel);
1041
1105
  const cliArgsWithModel = modelLaunchArgs
1042
- ? [...modelLaunchArgs, ...(cliArgs || [])]
1043
- : cliArgs;
1106
+ ? [...modelLaunchArgs, ...(cliArgsWithAutoApprove || [])]
1107
+ : cliArgsWithAutoApprove;
1044
1108
  if (initialModel && !modelLaunchArgs) {
1045
1109
  LOG.warn('CLI', `[${normalizedType}] initialModel='${initialModel}' requested but provider declares no modelLaunchArgs template — launching without model selection.`);
1046
1110
  }
@@ -1051,7 +1115,7 @@ export class DaemonCliManager {
1051
1115
  // Best-effort; a provider with no template (or no requested level) is a no-op. ACP
1052
1116
  // providers route thinking through setConfigOption('thought_level') above.
1053
1117
  const initialThinkingLevel = options?.initialThinkingLevel;
1054
- const thinkingLaunchArgs = expandThinkingLaunchArgs(provider?.thinkingLaunchArgs, initialThinkingLevel, provider?.thinkingLevelMap);
1118
+ const thinkingLaunchArgs = expandThinkingLaunchArgs(launchProvider?.thinkingLaunchArgs, initialThinkingLevel, launchProvider?.thinkingLevelMap);
1055
1119
  const cliArgsWithBrain = thinkingLaunchArgs
1056
1120
  ? [...thinkingLaunchArgs, ...(cliArgsWithModel || [])]
1057
1121
  : cliArgsWithModel;
@@ -1060,13 +1124,13 @@ export class DaemonCliManager {
1060
1124
  }
1061
1125
 
1062
1126
  // ─── Resolve launch options → provider session binding ───
1063
- const sessionBinding = resolveCliSessionBinding(provider, normalizedType, cliArgsWithBrain, options?.resumeSessionId);
1127
+ const sessionBinding = resolveCliSessionBinding(launchProvider, normalizedType, cliArgsWithBrain, options?.resumeSessionId);
1064
1128
  const resolvedCliArgs = sessionBinding.cliArgs;
1065
1129
 
1066
1130
  // If InstanceManager exists, manage as CliProviderInstance unified
1067
1131
  const instanceManager = this.deps.getInstanceManager();
1068
- if (provider && instanceManager) {
1069
- const resolvedProvider = this.providerLoader.resolve(cliType, { version: cliInfo.version }) || provider;
1132
+ if (launchProvider && instanceManager) {
1133
+ const resolvedProvider = launchProvider;
1070
1134
  await this.registerCliInstance(
1071
1135
  key,
1072
1136
  normalizedType,
@@ -1074,7 +1138,7 @@ export class DaemonCliManager {
1074
1138
  resolvedDir,
1075
1139
  resolvedCliArgs,
1076
1140
  resolvedProvider,
1077
- { ...this.providerLoader.getSettings(normalizedType), ...(options?.settingsOverride || {}) },
1141
+ launchSettings,
1078
1142
  false,
1079
1143
  {
1080
1144
  providerSessionId: sessionBinding.providerSessionId,
@@ -93,10 +93,21 @@ export const meshEventsHandlers: Record<string, HighFamilyHandler> = {
93
93
  ? args.sessionId.trim()
94
94
  : '';
95
95
  if (!sessionId) return { success: false, error: 'targetSessionId required' };
96
- const response = normalizeInteractivePromptResponse(args?.response ?? args);
96
+ const rawResponse = args?.response ?? args;
97
97
  const instance = ctx.deps.instanceManager.getInstance(sessionId);
98
98
  if (!instance) return { success: false, error: `No running instance for session ${sessionId}` };
99
- ctx.deps.instanceManager.sendEvent(sessionId, 'interactive_prompt_response', response);
99
+ // mesh_answer_question (mission f1d25e11) sends a coordinator-friendly answer array
100
+ // that is resolved against the AUTHORITATIVE active prompt inside the instance
101
+ // (resolveInteractivePromptResponse). Forward that shape RAW. The legacy strict
102
+ // (questionId-keyed) form is still validated here so a malformed dashboard-local
103
+ // answer is rejected before it reaches the instance.
104
+ const isFriendlyArrayForm = rawResponse
105
+ && typeof rawResponse === 'object'
106
+ && Array.isArray((rawResponse as { answers?: unknown }).answers);
107
+ const payload = isFriendlyArrayForm
108
+ ? rawResponse
109
+ : normalizeInteractivePromptResponse(rawResponse);
110
+ ctx.deps.instanceManager.sendEvent(sessionId, 'interactive_prompt_response', payload);
100
111
  return { success: true };
101
112
  },
102
113
  };
@@ -399,6 +399,161 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
399
399
  }
400
400
  },
401
401
 
402
+ // READ path for `.adhdev/mesh.json` — returns the currently-committed repo
403
+ // config (parsed + normalized) for a workspace so a UI can render/edit the
404
+ // existing declarative zones (notably `providerDefaults.autoApproveModes`)
405
+ // WITHOUT re-deriving them from the machine-local scaffold. Never writes.
406
+ // `config` is undefined when no repo file exists (sourceType 'unavailable') or
407
+ // it is unparseable (sourceType 'invalid', with the parse error surfaced).
408
+ read_mesh_json_config: async (_ctx: MedFamilyContext, args: any) => {
409
+ const workspace = typeof args?.workspace === 'string' && args.workspace.trim() ? args.workspace.trim() : process.cwd();
410
+ try {
411
+ const { loadRepoMeshJsonConfig } = await import('../../config/mesh-json-config.js');
412
+ const loaded = loadRepoMeshJsonConfig(workspace);
413
+ return {
414
+ success: true,
415
+ workspace,
416
+ sourceType: loaded.sourceType,
417
+ source: loaded.source,
418
+ ...(loaded.path ? { path: loaded.path } : {}),
419
+ ...(loaded.error ? { error: loaded.error } : {}),
420
+ config: loaded.config,
421
+ // Convenience projection so the UI does not have to reach into config.
422
+ providerDefaults: loaded.config?.providerDefaults,
423
+ };
424
+ } catch (e: any) {
425
+ return { success: false, error: e.message };
426
+ }
427
+ },
428
+
429
+ // Partial-edit WRITE path for `.adhdev/mesh.json` `providerDefaults` — a
430
+ // READ-MODIFY-WRITE that preserves operator hand-edits. Unlike
431
+ // write_mesh_json_config (which rebuilds the WHOLE file from the machine-local
432
+ // scaffold and can silently drop hand-edited zones), this parses the existing
433
+ // repo file, merges ONLY the providerDefaults.autoApproveModes zone, and
434
+ // re-serializes — coordinator prompt, operating notes and limits authored in
435
+ // the repo are carried through untouched. Defaults to dry-run.
436
+ //
437
+ // args: { workspace?, autoApproveModes: Record<providerType,modeId>, write?, merge? }
438
+ // merge=true (default): per-provider merge into the existing map; a modeId of
439
+ // '' | null removes that provider's entry. merge=false: REPLACE the whole
440
+ // autoApproveModes map with the supplied one.
441
+ set_mesh_provider_defaults: async (_ctx: MedFamilyContext, args: any) => {
442
+ const workspace = typeof args?.workspace === 'string' && args.workspace.trim() ? args.workspace.trim() : process.cwd();
443
+ const write = args?.write === true;
444
+ const merge = args?.merge !== false; // default true
445
+ const inputModes = args?.autoApproveModes;
446
+ if (inputModes !== undefined && (typeof inputModes !== 'object' || inputModes === null || Array.isArray(inputModes))) {
447
+ return { success: false, error: 'autoApproveModes must be an object (providerType → modeId) when provided' };
448
+ }
449
+ try {
450
+ const {
451
+ loadRepoMeshJsonConfig,
452
+ normalizeRepoMeshDeclarativeConfig,
453
+ MESH_JSON_CONFIG_LOCATIONS,
454
+ } = await import('../../config/mesh-json-config.js');
455
+ const { existsSync, readFileSync, mkdirSync, writeFileSync } = await import('fs');
456
+ const { dirname, join } = await import('path');
457
+ const yaml = await import('js-yaml');
458
+
459
+ const relativePath = MESH_JSON_CONFIG_LOCATIONS[0];
460
+
461
+ // Read-modify-write: parse the EXISTING on-disk document (preferring the
462
+ // first existing json/yaml variant) so unrelated zones survive verbatim.
463
+ let baseDoc: Record<string, any> = { version: 1 };
464
+ let existingPath = join(workspace, relativePath);
465
+ let existedAsYaml = false;
466
+ for (const relative of MESH_JSON_CONFIG_LOCATIONS) {
467
+ const candidate = join(workspace, relative);
468
+ if (!existsSync(candidate)) continue;
469
+ try {
470
+ const text = readFileSync(candidate, 'utf-8');
471
+ const parsed = /\.json$/i.test(candidate) ? JSON.parse(text) : yaml.load(text);
472
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
473
+ baseDoc = parsed as Record<string, any>;
474
+ existingPath = candidate;
475
+ existedAsYaml = !/\.json$/i.test(candidate);
476
+ }
477
+ } catch (e: any) {
478
+ return { success: false, error: `existing ${relative} is unparseable, refusing to overwrite: ${e?.message || e}` };
479
+ }
480
+ break;
481
+ }
482
+
483
+ // Merge ONLY the providerDefaults.autoApproveModes zone.
484
+ const existingPd = baseDoc.providerDefaults && typeof baseDoc.providerDefaults === 'object' && !Array.isArray(baseDoc.providerDefaults)
485
+ ? baseDoc.providerDefaults as Record<string, any>
486
+ : {};
487
+ const existingModes = existingPd.autoApproveModes && typeof existingPd.autoApproveModes === 'object' && !Array.isArray(existingPd.autoApproveModes)
488
+ ? { ...existingPd.autoApproveModes as Record<string, string> }
489
+ : {};
490
+
491
+ const nextModes: Record<string, string> = merge ? existingModes : {};
492
+ if (inputModes) {
493
+ for (const [providerType, modeId] of Object.entries(inputModes as Record<string, unknown>)) {
494
+ const type = typeof providerType === 'string' ? providerType.trim() : '';
495
+ if (!type) continue;
496
+ const id = typeof modeId === 'string' ? modeId.trim() : '';
497
+ if (id) nextModes[type] = id;
498
+ else delete nextModes[type]; // '' / null → remove this provider's entry
499
+ }
500
+ }
501
+
502
+ const nextDoc: Record<string, any> = { ...baseDoc, version: 1 };
503
+ if (Object.keys(nextModes).length) {
504
+ nextDoc.providerDefaults = { ...existingPd, autoApproveModes: nextModes };
505
+ } else {
506
+ // No entries left → drop the zone entirely so we don't leave an empty stub.
507
+ if (nextDoc.providerDefaults) {
508
+ const { autoApproveModes, ...restPd } = nextDoc.providerDefaults;
509
+ if (Object.keys(restPd).length) nextDoc.providerDefaults = restPd;
510
+ else delete nextDoc.providerDefaults;
511
+ }
512
+ }
513
+
514
+ // Validate the merged document before it ever touches disk.
515
+ const validation = normalizeRepoMeshDeclarativeConfig(nextDoc);
516
+ if (!validation.valid) {
517
+ return { success: false, error: `merged mesh.json is invalid: ${validation.errors.join('; ')}` };
518
+ }
519
+
520
+ // Serialize in the on-disk format (JSON unless the existing file was YAML).
521
+ const absolutePath = existingPath;
522
+ const serialized = existedAsYaml
523
+ ? yaml.dump(nextDoc, { indent: 2 })
524
+ : `${JSON.stringify(nextDoc, null, 2)}\n`;
525
+
526
+ if (!write) {
527
+ return {
528
+ success: true,
529
+ written: false,
530
+ dryRun: true,
531
+ path: absolutePath,
532
+ relativePath,
533
+ merge,
534
+ providerDefaults: nextDoc.providerDefaults,
535
+ preview: serialized,
536
+ note: 'Dry-run: nothing written. Re-run with write=true to persist. Only the providerDefaults zone is merged; other repo zones are preserved.',
537
+ };
538
+ }
539
+
540
+ mkdirSync(dirname(absolutePath), { recursive: true });
541
+ writeFileSync(absolutePath, serialized, 'utf-8');
542
+ return {
543
+ success: true,
544
+ written: true,
545
+ dryRun: false,
546
+ path: absolutePath,
547
+ relativePath,
548
+ merge,
549
+ providerDefaults: nextDoc.providerDefaults,
550
+ note: 'Wrote providerDefaults into .adhdev/mesh.json (read-modify-write; other zones preserved). Commit it to the repo.',
551
+ };
552
+ } catch (e: any) {
553
+ return { success: false, error: e.message };
554
+ }
555
+ },
556
+
402
557
  delete_mesh: async (_ctx: MedFamilyContext, args: any) => {
403
558
  const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
404
559
  if (!meshId) return { success: false, error: 'meshId required' };
@@ -9,6 +9,67 @@
9
9
  */
10
10
  import { readStringValue } from '../router.js';
11
11
  import type { MedFamilyContext, MedFamilyHandler } from './types.js';
12
+ import { LOG } from '../../logging/logger.js';
13
+ import type { CancelledTaskAssignment } from '../../mesh/mesh-work-queue.js';
14
+
15
+ /**
16
+ * CANCEL-STICKY-TERMINAL (authoritative cancel): stop the worker a just-cancelled task was
17
+ * bound to. Mirrors the transport-aware stop mesh-event-forwarding's stopStaleMeshWorker
18
+ * performs for reclaimed workers, but runs from the command layer using ctx.deps (which the
19
+ * pure queue-store module lacks): local adapter → stop_cli directly; otherwise resolve the
20
+ * worker node's daemon id from mesh config and dispatch stop_cli over P2P/relay. Fully
21
+ * best-effort — a failed stop only loses the belt-and-suspenders; the cancelled row is already
22
+ * terminal and cannot be resurrected (updateTaskStatus terminal guard).
23
+ */
24
+ async function stopCancelledTaskWorker(
25
+ ctx: MedFamilyContext,
26
+ meshId: string,
27
+ prior: CancelledTaskAssignment,
28
+ ): Promise<void> {
29
+ const { sessionId, nodeId, providerType } = prior;
30
+ const stopArgs: Record<string, unknown> = {
31
+ targetSessionId: sessionId,
32
+ ...(providerType ? { cliType: providerType } : {}),
33
+ mode: 'hard',
34
+ reason: 'mesh_task_cancelled',
35
+ };
36
+ try {
37
+ const cliManager = ctx.deps.cliManager as any;
38
+ const isLocal = cliManager?.adapters?.has?.(sessionId) === true;
39
+ if (isLocal) {
40
+ if (!stopArgs.cliType) {
41
+ const localType = cliManager?.adapters?.get?.(sessionId)?.cliType;
42
+ if (localType) stopArgs.cliType = localType;
43
+ }
44
+ await Promise.resolve(cliManager?.handleCliCommand?.('stop_cli', stopArgs))
45
+ .catch((e: any) => LOG.warn('MeshQueue', `Local stop of cancelled worker ${sessionId} failed: ${e?.message || e}`));
46
+ return;
47
+ }
48
+ // Remote: resolve the worker node's daemon id from mesh config, then dispatch stop_cli.
49
+ const dispatch = ctx.deps.dispatchMeshCommand;
50
+ let daemonId: string | undefined;
51
+ if (nodeId) {
52
+ try {
53
+ const [{ getMesh }, { meshNodeIdMatches }, { readMeshNodeDaemonId }] = await Promise.all([
54
+ import('../../config/mesh-config.js'),
55
+ import('@adhdev/mesh-shared'),
56
+ import('../../mesh/mesh-node-identity.js'),
57
+ ]);
58
+ const mesh = getMesh(meshId) as { nodes?: any[] } | undefined;
59
+ const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
60
+ daemonId = node ? readMeshNodeDaemonId(node) || undefined : undefined;
61
+ } catch { /* best-effort resolution */ }
62
+ }
63
+ if (daemonId && dispatch) {
64
+ await Promise.resolve(dispatch(daemonId, 'stop_cli', stopArgs))
65
+ .catch((e: any) => LOG.warn('MeshQueue', `Remote stop of cancelled worker ${sessionId} on daemon ${daemonId} failed: ${e?.message || e}`));
66
+ } else {
67
+ LOG.warn('MeshQueue', `Cannot stop cancelled worker ${sessionId}: no local adapter and no resolvable remote daemon id (node ${nodeId ?? '?'}). Row is already terminal; if the worker completes it strand-fails harmlessly.`);
68
+ }
69
+ } catch (e: any) {
70
+ LOG.warn('MeshQueue', `stopCancelledTaskWorker error for ${sessionId}: ${e?.message || e}`);
71
+ }
72
+ }
12
73
 
13
74
  export const meshQueueHandlers: Record<string, MedFamilyHandler> = {
14
75
  get_mesh_queue: async (_ctx: MedFamilyContext, args: any) => {
@@ -50,10 +111,22 @@ export const meshQueueHandlers: Record<string, MedFamilyHandler> = {
50
111
  const ownerFailure = await ctx.requireMeshHostMutationOwner(meshId, args?.inlineMesh, 'queue cancellation');
51
112
  if (ownerFailure) return ownerFailure;
52
113
  try {
53
- const { cancelTask } = await import('../../mesh/mesh-work-queue.js');
114
+ const { cancelTask, takeCancelledTaskAssignment } = await import('../../mesh/mesh-work-queue.js');
54
115
  const reason = typeof args?.reason === 'string' ? args.reason : undefined;
55
116
  const task = cancelTask(meshId, taskId, { reason });
56
117
  if (!task) return { success: false, error: `Queue task '${taskId}' not found` };
118
+ // CANCEL-STICKY-TERMINAL (authoritative cancel): stop the worker that was bound to the
119
+ // now-cancelled task. cancelTask already cleared the queue's assignment fields (so the
120
+ // reclaim watchdog no longer sees it as a live assignment), but a still-running worker
121
+ // keeps emitting delivery/turn signals that re-ignite reclaim. cancelTask runs in the
122
+ // pure queue-store module (no DaemonComponents), so the transport-aware stop is done
123
+ // here where ctx.deps holds cliManager / dispatchMeshCommand. Best-effort: a failed
124
+ // stop only loses the belt-and-suspenders — the row is already terminal and un-revivable
125
+ // thanks to the updateTaskStatus terminal guard.
126
+ const prior = takeCancelledTaskAssignment(meshId, taskId);
127
+ if (prior?.sessionId) {
128
+ void stopCancelledTaskWorker(ctx, meshId, prior);
129
+ }
57
130
  return { success: true, task };
58
131
  } catch (e: any) {
59
132
  return { success: false, error: e.message };
@@ -36,8 +36,10 @@ import {
36
36
  RefineExecFileAsync,
37
37
  RefineStageOutcome,
38
38
  classifyPatchEquivalenceFailure,
39
+ convergeDivergedSubmoduleGitlinks,
39
40
  recordMeshRefineStage,
40
41
  resolveRefineryAutoPublishSubmoduleMainCommits,
42
+ rootRebaseResolvingGitlinks,
41
43
  runMeshRefineEffectiveDiffGate,
42
44
  runMeshRefinePatchEquivalenceGate,
43
45
  runMeshRefineSubmoduleReachabilityGate,
@@ -539,6 +541,9 @@ async function computeBranchBaseDivergence(
539
541
  export async function refineSyncBaseStage(self: DaemonCommandRouter, ctx: RefineContext): Promise<RefineStageOutcome> {
540
542
  const { repoRoot, baseHead, node, branch, baseBranch, refineStages, execFileAsync } = ctx;
541
543
  let branchHead = ctx.branchHead;
544
+ // Converged submodule gitlink resolutions (path → rebased commit) from STEP 1;
545
+ // consumed by the gitlink-aware root rebase (STEP 2) to resolve gitlink conflicts.
546
+ let gitlinkResolutions: Array<{ path: string; rebasedCommit: string }> = [];
542
547
  const syncStarted = Date.now();
543
548
  const divergence = await computeBranchBaseDivergence(execFileAsync, node.workspace, baseHead, branchHead);
544
549
 
@@ -571,25 +576,87 @@ export async function refineSyncBaseStage(self: DaemonCommandRouter, ctx: Refine
571
576
  const preRebasePe = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
572
577
  const alreadyMerged = !preRebasePe.actualPatchId && !!preRebasePe.expectedPatchId;
573
578
  const submoduleConflict = preRebasePe.actionableHint?.kind === 'submodule_conflict';
574
- if (alreadyMerged || submoduleConflict) {
579
+ if (alreadyMerged) {
575
580
  recordMeshRefineStage(refineStages, 'sync_base', 'passed', syncStarted, {
576
581
  ahead: divergence.ahead,
577
582
  behind: divergence.behind,
578
583
  rebased: false,
579
- reason: alreadyMerged ? 'already_merged_via_other_path_skip_rebase' : 'submodule_conflict_defer_to_patch_equivalence',
584
+ reason: 'already_merged_via_other_path_skip_rebase',
580
585
  });
581
586
  return { kind: 'continue', ctx };
582
587
  }
588
+ if (submoduleConflict) {
589
+ // DS3: a "submodule conflict" here means base and branch advanced the
590
+ // SAME submodule to DIVERGED sibling commits (neither an ancestor of the
591
+ // other), so the gitlink stays in the diff and patch-equivalence fails.
592
+ // Attempt to auto-converge it (STEP 1): rebase the branch-side submodule
593
+ // commit onto the base-side commit INSIDE the worktree submodule, so the
594
+ // base-side commit becomes a strict ancestor of the rebased tip. The root
595
+ // rebase below (STEP 2) then resolves the gitlink conflict to that rebased
596
+ // commit. Together this automates the documented manual strict-ff bypass
597
+ // and keeps the landed oss history linear. On any real submodule content
598
+ // conflict it backs out cleanly → we FALL BACK to the historical
599
+ // defer→patch_equivalence path below.
600
+ const converge = convergeDivergedSubmoduleGitlinks(node.workspace, repoRoot, baseHead, branchHead);
601
+ if (converge.converged) {
602
+ gitlinkResolutions = converge.resolutions;
603
+ recordMeshRefineStage(refineStages, 'submodule_gitlink_converge', 'passed', syncStarted, {
604
+ reason: 'submodule_diverged_auto_rebased',
605
+ gitlinks: converge.gitlinks,
606
+ });
607
+ LOG.info('Mesh', `[Refinery] Auto-converged diverged submodule gitlink(s) onto base for node ${node.id}: `
608
+ + converge.resolutions.map(r => `${r.path}→${r.rebasedCommit.slice(0, 12)}`).join(', '));
609
+ // Fall through (do NOT return) → the gitlink-aware root rebase below runs.
610
+ } else {
611
+ // Fail-safe: convergence declined (conflict / unreachable / not a real
612
+ // divergence) → preserve the historical defer→blocked_review behavior.
613
+ recordMeshRefineStage(refineStages, 'submodule_gitlink_converge', 'skipped', syncStarted, {
614
+ reason: 'submodule_conflict_defer_to_patch_equivalence',
615
+ convergeReason: converge.reason,
616
+ gitlinks: converge.gitlinks,
617
+ });
618
+ recordMeshRefineStage(refineStages, 'sync_base', 'passed', syncStarted, {
619
+ ahead: divergence.ahead,
620
+ behind: divergence.behind,
621
+ rebased: false,
622
+ reason: 'submodule_conflict_defer_to_patch_equivalence',
623
+ });
624
+ return { kind: 'continue', ctx };
625
+ }
626
+ }
583
627
  } catch { /* fail-open: on gate error, fall through to the rebase */ }
584
628
 
585
629
  // behind>0: strictly-behind OR diverged. Rebase the branch onto the pinned
586
630
  // baseHead. A conflict aborts and terminates blocked_review (retryable=false —
587
631
  // a real content conflict needs human resolution, not a base-movement retry).
632
+ //
633
+ // When STEP 1 converged a diverged submodule gitlink, use the gitlink-aware
634
+ // root rebase (STEP 2): git's recursive merge refuses to auto-merge the still-
635
+ // diverged intermediate gitlink, so we drive the rebase and resolve each
636
+ // submodule-gitlink conflict to the converged commit. A non-gitlink conflict
637
+ // aborts and falls through to the same blocked_review handling as a plain
638
+ // rebase conflict below (via the thrown gitlinkRebaseError).
588
639
  const rebaseStarted = Date.now();
589
640
  try {
590
- execFileSync('git', ['rebase', baseHead], { cwd: node.workspace, stdio: ['ignore', 'pipe', 'pipe'] });
641
+ if (gitlinkResolutions.length > 0) {
642
+ const gitlinkRebase = rootRebaseResolvingGitlinks(node.workspace, baseHead, gitlinkResolutions);
643
+ if (!gitlinkRebase.ok) {
644
+ // Surface as a rebase failure so the shared blocked_review handling
645
+ // (submodule-hint recovery included) runs — nothing was left mid-rebase
646
+ // (rootRebaseResolvingGitlinks aborts on failure).
647
+ const err: any = new Error(`gitlink-aware rebase aborted: ${gitlinkRebase.reason || 'unknown'}`);
648
+ err.gitlinkRebaseReason = gitlinkRebase.reason;
649
+ err.gitlinkRebaseConflicts = gitlinkRebase.conflictPaths;
650
+ err.alreadyAborted = true;
651
+ throw err;
652
+ }
653
+ } else {
654
+ execFileSync('git', ['rebase', baseHead], { cwd: node.workspace, stdio: ['ignore', 'pipe', 'pipe'] });
655
+ }
591
656
  } catch (rebaseErr: any) {
592
- try { execFileSync('git', ['rebase', '--abort'], { cwd: node.workspace, stdio: 'ignore' }); } catch { /* ignore */ }
657
+ if (!rebaseErr?.alreadyAborted) {
658
+ try { execFileSync('git', ['rebase', '--abort'], { cwd: node.workspace, stdio: 'ignore' }); } catch { /* ignore */ }
659
+ }
593
660
  // A rebase conflict on a submodule/gitlink divergence is a SPECIAL case the
594
661
  // patch-equivalence gate describes with a rich actionable hint (which
595
662
  // submodule, base vs branch commit, how to resolve). Run that gate against
@@ -227,6 +227,14 @@ const MESH_FORWARDABLE_SESSION_COMMANDS = new Set([
227
227
  // MUTATES the worker PTY, so forwarding to the real owner (not a wrong local session) is
228
228
  // doubly important. The daemon re-enforces the destructive-key confirm gate after the forward.
229
229
  'send_keys',
230
+ // interactive_prompt_response (mesh_answer_question, mission f1d25e11): the coordinator
231
+ // answers a REMOTE worker's AskUserQuestion (waiting_choice). The answer must reach the
232
+ // OWNING worker session's live instance — its activeInteractivePrompt (the authoritative
233
+ // prompt the labels/indexes resolve against) and its adapter.setInteractivePromptResponse
234
+ // live only there. Without forwarding, the coordinator's local high-family handler returns
235
+ // 'No running instance for session …' and the question is never answered — the exact
236
+ // remote-worker forwarding gap of mission 6938892f, now closed for questions too.
237
+ 'interactive_prompt_response',
230
238
  ]);
231
239
 
232
240
  function normalizeCommandSource(source: string): CommandLogEntry['source'] {