@adhdev/daemon-core 1.0.18-rc.2 → 1.0.18-rc.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/cli-adapters/cli-state-engine.d.ts +77 -0
  2. package/dist/cli-adapters/provider-cli-shared.d.ts +33 -0
  3. package/dist/cli-adapters/pty-transport.d.ts +2 -1
  4. package/dist/commands/cli-manager.d.ts +9 -0
  5. package/dist/commands/process-lifecycle.d.ts +43 -0
  6. package/dist/commands/upgrade-helper.d.ts +1 -0
  7. package/dist/commands/windows-atomic-upgrade.d.ts +17 -1
  8. package/dist/config/mesh-json-config.d.ts +62 -0
  9. package/dist/index.d.ts +4 -2
  10. package/dist/index.js +6674 -4237
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +6361 -3927
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  15. package/dist/mesh/mesh-active-work.d.ts +1 -1
  16. package/dist/mesh/mesh-disk-retention.d.ts +105 -0
  17. package/dist/mesh/mesh-ledger.d.ts +28 -1
  18. package/dist/mesh/mesh-node-identity.d.ts +23 -0
  19. package/dist/mesh/mesh-refine-gates.d.ts +89 -0
  20. package/dist/mesh/mesh-remote-event-pull.d.ts +3 -0
  21. package/dist/mesh/mesh-runtime-store.d.ts +11 -0
  22. package/dist/mesh/mesh-work-queue.d.ts +24 -1
  23. package/dist/providers/auto-approve-modes.d.ts +14 -0
  24. package/dist/providers/chat-message-normalization.d.ts +22 -0
  25. package/dist/providers/cli-provider-instance-types.d.ts +4 -0
  26. package/dist/providers/cli-provider-instance.d.ts +78 -0
  27. package/dist/providers/contracts.d.ts +17 -0
  28. package/dist/providers/provider-schema.d.ts +1 -0
  29. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
  30. package/dist/providers/types/interactive-prompt.d.ts +18 -0
  31. package/dist/repo-mesh-types.d.ts +38 -6
  32. package/dist/shared-types.d.ts +4 -2
  33. package/package.json +3 -3
  34. package/src/boot/daemon-lifecycle.ts +10 -0
  35. package/src/cli-adapters/cli-state-engine.ts +220 -3
  36. package/src/cli-adapters/provider-cli-adapter.ts +41 -11
  37. package/src/cli-adapters/provider-cli-shared.ts +48 -0
  38. package/src/cli-adapters/pty-transport.d.ts +2 -1
  39. package/src/cli-adapters/pty-transport.ts +1 -1
  40. package/src/cli-adapters/session-host-transport.ts +8 -3
  41. package/src/commands/cli-manager.ts +72 -8
  42. package/src/commands/high-family/mesh-coordinator-launch.ts +17 -2
  43. package/src/commands/high-family/mesh-events.ts +13 -2
  44. package/src/commands/med-family/mesh-crud.ts +155 -0
  45. package/src/commands/med-family/mesh-queue.ts +74 -1
  46. package/src/commands/process-lifecycle.ts +248 -0
  47. package/src/commands/router-refine.ts +71 -4
  48. package/src/commands/router.ts +8 -0
  49. package/src/commands/upgrade-helper.ts +106 -82
  50. package/src/commands/windows-atomic-upgrade.ts +281 -35
  51. package/src/config/mesh-json-config.ts +111 -0
  52. package/src/index.ts +21 -1
  53. package/src/mesh/coordinator-prompt.ts +258 -11
  54. package/src/mesh/mesh-active-work.ts +11 -1
  55. package/src/mesh/mesh-completion-synthesis.ts +12 -1
  56. package/src/mesh/mesh-disk-retention.ts +370 -0
  57. package/src/mesh/mesh-event-classify.ts +19 -0
  58. package/src/mesh/mesh-event-forwarding.ts +64 -6
  59. package/src/mesh/mesh-events-utils.ts +42 -0
  60. package/src/mesh/mesh-ledger.ts +77 -0
  61. package/src/mesh/mesh-node-identity.ts +49 -0
  62. package/src/mesh/mesh-queue-assignment.ts +210 -11
  63. package/src/mesh/mesh-reconcile-loop.ts +306 -3
  64. package/src/mesh/mesh-refine-gates.ts +300 -0
  65. package/src/mesh/mesh-remote-event-pull.ts +68 -47
  66. package/src/mesh/mesh-runtime-store.ts +21 -0
  67. package/src/mesh/mesh-work-queue.ts +85 -2
  68. package/src/providers/auto-approve-modes.ts +103 -0
  69. package/src/providers/chat-message-normalization.ts +53 -0
  70. package/src/providers/cli-provider-instance-types.ts +53 -0
  71. package/src/providers/cli-provider-instance.ts +497 -15
  72. package/src/providers/contracts.ts +25 -1
  73. package/src/providers/provider-schema.ts +83 -0
  74. package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
  75. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
  76. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +44 -0
  77. package/src/providers/types/interactive-prompt.ts +77 -0
  78. package/src/repo-mesh-types.ts +112 -12
  79. package/src/session-host/managed-host.ts +34 -0
  80. package/src/shared-types.ts +9 -1
  81. package/src/status/reporter.ts +1 -0
  82. package/src/status/snapshot.ts +2 -0
@@ -254,6 +254,54 @@ 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
+ } | null | undefined): boolean {
290
+ // No provider (e.g. an instance whose module isn't set when the stall watchdog
291
+ // ticks) ⇒ not the pure-PTY class. Guarding here protects every caller — the
292
+ // stall-reconcile and turn-start paths pass this.provider unguarded, and
293
+ // this.provider is genuinely nullable at runtime (see this.provider?.nativeHistory
294
+ // in cli-provider-instance.ts). Matches resolveLocalSessionPurePty's own
295
+ // no-provider ⇒ not-pure-PTY contract.
296
+ if (!provider) return false;
297
+ if (provider.transcriptAuthority === 'provider') return false;
298
+ // nativeHistory is a top-level provider field not surfaced on
299
+ // CliProviderModule; read it via the same structural shape the adapter uses.
300
+ if (provider.nativeHistory) return false;
301
+ const transcriptPty = (provider.tui as { transcriptPty?: { scope?: unknown } } | undefined)?.transcriptPty;
302
+ return transcriptPty?.scope === 'buffer';
303
+ }
304
+
257
305
  function stripAnsi(str: string): string {
258
306
  // eslint-disable-next-line no-control-regex
259
307
  return str
@@ -37,7 +37,8 @@ export interface PtyRuntimeTransport {
37
37
  getMetadata?(): PtyRuntimeMetadata | null;
38
38
  onData(callback: (data: string) => void): void;
39
39
  onExit(callback: (info: {
40
- exitCode: number;
40
+ exitCode: number | null;
41
+ signal?: number | null;
41
42
  }) => void): void;
42
43
  }
43
44
  export interface PtyTransportFactory {
@@ -62,7 +62,7 @@ export interface PtyRuntimeTransport {
62
62
  updateMeta?(meta: Record<string, unknown>, replace?: boolean): void;
63
63
  getMetadata?(): PtyRuntimeMetadata | null;
64
64
  onData(callback: (data: string) => void): void;
65
- onExit(callback: (info: { exitCode: number }) => void): void;
65
+ onExit(callback: (info: { exitCode: number | null; signal?: number | null }) => void): void;
66
66
  }
67
67
 
68
68
  export interface PtyTransportFactory {
@@ -42,7 +42,7 @@ class SessionHostRuntimeTransport implements PtyRuntimeTransport {
42
42
 
43
43
  private readonly client: SessionHostClient;
44
44
  private readonly dataCallbacks = new Set<(data: string) => void>();
45
- private readonly exitCallbacks = new Set<(info: { exitCode: number }) => void>();
45
+ private readonly exitCallbacks = new Set<(info: { exitCode: number | null; signal?: number | null }) => void>();
46
46
  private readonly pendingOutput: string[] = [];
47
47
  private operationChain = Promise.resolve();
48
48
  private unsubscribe: (() => void) | null = null;
@@ -79,7 +79,7 @@ class SessionHostRuntimeTransport implements PtyRuntimeTransport {
79
79
  }
80
80
  }
81
81
 
82
- onExit(callback: (info: { exitCode: number }) => void): void {
82
+ onExit(callback: (info: { exitCode: number | null; signal?: number | null }) => void): void {
83
83
  this.exitCallbacks.add(callback);
84
84
  }
85
85
 
@@ -361,8 +361,13 @@ class SessionHostRuntimeTransport implements PtyRuntimeTransport {
361
361
  return;
362
362
  }
363
363
  if (event.type === 'session_exit') {
364
+ // Preserve the nullable/unknown exitCode and signal exactly as the
365
+ // session host reported them — never collapse null to 0, which would
366
+ // make a signal-terminated process indistinguishable from a clean exit.
367
+ const exitCode = typeof event.exitCode === 'number' ? event.exitCode : null;
368
+ const signal = typeof event.signal === 'number' ? event.signal : null;
364
369
  for (const callback of this.exitCallbacks) {
365
- callback({ exitCode: event.exitCode ?? 0 });
370
+ callback({ exitCode, signal });
366
371
  }
367
372
  void this.closeClient(false);
368
373
  }
@@ -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,
@@ -115,8 +115,12 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
115
115
  try {
116
116
  const { readOperatingNotes } = await import('../../mesh/mesh-ledger.js');
117
117
  // readOperatingNotes filters out tombstoned (forgotten) notes so a
118
- // retracted lesson never rides into the prompt. Newest last; tail 20.
119
- const noteEntries = readOperatingNotes(id, { tail: 20 });
118
+ // retracted lesson never rides into the prompt. Newest last.
119
+ // Phase 2 (d): the byte-budget/count cap now bounds the injected list
120
+ // (selectOperatingNotesForPrompt), so read a larger candidate tail than
121
+ // the old fixed 20 and let injection-side ranking + budget do the
122
+ // bounding. Keep a sane store-read ceiling to avoid unbounded arrays.
123
+ const noteEntries = readOperatingNotes(id, { tail: 100 });
120
124
  const notes = noteEntries
121
125
  .map((e) => {
122
126
  const p = (e.payload || {}) as Record<string, unknown>;
@@ -131,6 +135,17 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
131
135
  category,
132
136
  createdAt: typeof p.createdAt === 'string' ? p.createdAt : e.timestamp,
133
137
  sourceCoordinator: typeof p.sourceCoordinator === 'string' ? p.sourceCoordinator : undefined,
138
+ // Operating-notes lifecycle: thread pinned/expiresAt so the
139
+ // injection-side selection can honor them. Legacy notes lack
140
+ // these → pinned defaults false, expiry governed by category TTL.
141
+ pinned: p.pinned === true,
142
+ ...(typeof p.expiresAt === 'string' ? { expiresAt: p.expiresAt } : {}),
143
+ // Phase 2 (b)/(c): thread the ledger id + supersedes/subjectKey
144
+ // so version-supersede targeting and same-class folding work.
145
+ // Legacy notes lack them → no supersede, fold by leading [tag].
146
+ noteId: e.id,
147
+ ...(typeof p.supersedes === 'string' ? { supersedes: p.supersedes } : {}),
148
+ ...(typeof p.subjectKey === 'string' ? { subjectKey: p.subjectKey } : {}),
134
149
  };
135
150
  })
136
151
  .filter((n): n is NonNullable<typeof n> => n !== null);
@@ -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 };