@adhdev/daemon-core 0.9.82-rc.455 → 0.9.82-rc.457

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 (32) hide show
  1. package/dist/commands/med-family/mesh-crud.d.ts +19 -0
  2. package/dist/config/config.d.ts +14 -0
  3. package/dist/config/registry-resolver.d.ts +54 -0
  4. package/dist/index.js +369 -67
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +369 -67
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/mesh/preview-freshness.d.ts +11 -1
  9. package/dist/mesh/worktree-bootstrap-config.d.ts +9 -0
  10. package/dist/providers/approval-utils.d.ts +25 -0
  11. package/dist/providers/cli-provider-instance.d.ts +30 -1
  12. package/dist/providers/manual-attendance.d.ts +16 -0
  13. package/dist/providers/provider-instance.d.ts +8 -1
  14. package/dist/providers/provider-loader.d.ts +17 -2
  15. package/dist/providers/spec/fsm-driver.d.ts +5 -0
  16. package/package.json +3 -3
  17. package/src/boot/daemon-lifecycle.ts +2 -0
  18. package/src/commands/handler.ts +9 -5
  19. package/src/commands/low-family/daemon-lifecycle.ts +14 -1
  20. package/src/commands/med-family/mesh-crud.ts +83 -49
  21. package/src/config/config.ts +18 -0
  22. package/src/config/registry-resolver.ts +100 -0
  23. package/src/mesh/preview-freshness.ts +46 -1
  24. package/src/mesh/worktree-bootstrap-config.ts +1 -1
  25. package/src/providers/approval-utils.ts +42 -0
  26. package/src/providers/cli-provider-instance.ts +246 -13
  27. package/src/providers/manual-attendance.ts +20 -0
  28. package/src/providers/provider-instance.ts +6 -1
  29. package/src/providers/provider-loader.ts +36 -9
  30. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +13 -2
  31. package/src/providers/spec/fsm-driver.ts +49 -2
  32. package/src/commands/WINDOWS-UPGRADE-LOCK-FAILURE.md +0 -198
@@ -15,4 +15,14 @@ export interface PreviewFreshness {
15
15
  }>;
16
16
  nextAction: string;
17
17
  }
18
- export declare function buildPreviewFreshness(repoRoot: string): PreviewFreshness;
18
+ /**
19
+ * Gate: does this repository actually configure the preview-deploy pipeline?
20
+ *
21
+ * The preview-freshness surface embeds this project's private release-pipeline
22
+ * instructions (`npm run deploy:preview`, smoke preview, …). Those are only
23
+ * meaningful in a repo that ships the pipeline. An external repo joined to a
24
+ * mesh must not have that guidance leak into its coordinator prompt, so this
25
+ * gate keeps the surface off unless a concrete pipeline artifact is present.
26
+ */
27
+ export declare function isPreviewPipelineConfigured(repoRoot: string): boolean;
28
+ export declare function buildPreviewFreshness(repoRoot: string): PreviewFreshness | null;
@@ -29,6 +29,15 @@ export interface WorktreeBootstrapState extends MeshAsyncJobLifecycle {
29
29
  staleReason?: string;
30
30
  }
31
31
  export declare const WORKTREE_BOOTSTRAP_STALE_RUNNING_MS: number;
32
+ /**
33
+ * Enumerate registered submodule paths for a worktree, generically (no hardcoded
34
+ * 'oss'/'adhdev-providers'). Reads `.gitmodules` via `git config --file .gitmodules
35
+ * --get-regexp path`, whose lines are `submodule.<name>.path <relativePath>`. Paths
36
+ * are returned normalized to forward slashes (git porcelain always emits '/'),
37
+ * trailing slash stripped. Returns an empty set when there are no submodules or the
38
+ * lookup fails — callers must then treat any change as dirty (conservative).
39
+ */
40
+ export declare function getRegisteredSubmodulePaths(workspace: string): Set<string>;
32
41
  export declare function isWorktreeBootstrapStaleRunning(node: {
33
42
  worktreeBootstrap?: {
34
43
  status?: string;
@@ -6,6 +6,31 @@ import type { ProviderModule } from './contracts.js';
6
6
  * decline, which distinguishes it from a generic numbered menu or prose list.
7
7
  */
8
8
  export declare function hasNegativeApprovalOption(buttons: string[] | null | undefined): boolean;
9
+ /**
10
+ * True when a button reliably identifies a tool-CONSENT modal on its own — a
11
+ * scoped permission-grant affirmative such as:
12
+ * - "Yes, allow all edits in tmp/ during this session"
13
+ * - "Yes, and don't ask again for example.com"
14
+ * - "Yes, allow reading from etc/ from this project"
15
+ * - "Always allow"
16
+ *
17
+ * These options only ever appear in a genuine approval/permission prompt; a
18
+ * /model or /mode picker ("1. Default 2. Opus 3. Sonnet") never offers a
19
+ * "grant this scope" choice. They therefore serve as a SECOND reliable
20
+ * structural anchor alongside {@link hasNegativeApprovalOption}.
21
+ *
22
+ * Why this exists (tall-diff fallback, #137): when a Write/Edit diff is tall,
23
+ * the trailing decline option ("3. No") can scroll off the bottom of the
24
+ * captured PTY frame, leaving only "1. Yes" + "2. Yes, allow … this session".
25
+ * hasNegativeApprovalOption then reads false and the auto-approve gate bails —
26
+ * a delegated worker sits forever on a modal it could safely have approved. The
27
+ * grant-scope affirmative lets the gate recognize the consent modal WITHOUT
28
+ * seeing the off-frame decline. The gate still selects the plain "Yes"
29
+ * (allow-once) via pickApprovalButton, never the broader grant, and the settle
30
+ * gate still requires a stable modal — so a half-rendered frame never fires.
31
+ * Kept deliberately narrow so no picker/confirm modal can trip it.
32
+ */
33
+ export declare function hasReliableApprovalAffirmative(buttons: string[] | null | undefined): boolean;
9
34
  export declare function getApprovalPositiveHints(provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): string[];
10
35
  export declare function pickApprovalButton(buttons: string[] | null | undefined, provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): {
11
36
  index: number;
@@ -115,6 +115,7 @@ export declare class CliProviderInstance implements ProviderInstance {
115
115
  private autoApproveSettleTimer;
116
116
  private autoApproveInactiveSince;
117
117
  private autoApproveMaskSince;
118
+ private stalledApprovalNudgeEpisode;
118
119
  private readonly manualAttendance;
119
120
  private controlValues;
120
121
  private summaryMetadata;
@@ -298,6 +299,10 @@ export declare class CliProviderInstance implements ProviderInstance {
298
299
  */
299
300
  private completingTurnTaskId;
300
301
  private meshTraceCtx;
302
+ private completionTraceOn;
303
+ private fsmTraceOn;
304
+ private recordCompletionGateTrace;
305
+ private recordFsmTransitionTrace;
301
306
  private flushCompletedDebounceIfFinalized;
302
307
  private maybeAutoApproveStatus;
303
308
  /**
@@ -355,7 +360,9 @@ export declare class CliProviderInstance implements ProviderInstance {
355
360
  get cliName(): string;
356
361
  private shouldAutoApprove;
357
362
  /** @see ProviderInstance.noteManualInteraction */
358
- noteManualInteraction(now?: number): void;
363
+ noteManualInteraction(now?: number, opts?: {
364
+ passive?: boolean;
365
+ }): void;
359
366
  /**
360
367
  * Whether auto-approve should be treated as active *right now* for display
361
368
  * and firing decisions: the configured intent AND the user is not currently
@@ -366,6 +373,28 @@ export declare class CliProviderInstance implements ProviderInstance {
366
373
  */
367
374
  private autoApproveEffectivelyActive;
368
375
  private autoApproveMaskStalled;
376
+ /**
377
+ * NOTIF-APPROVAL-MASKED (Q1b): surface a delegated worker's STALLED auto-approve modal
378
+ * to the mesh COORDINATOR, decoupled from the dashboard visible-status mask.
379
+ *
380
+ * When auto-approve is configured but the episode never settles (modal parse miss / the
381
+ * settle gate never satisfied), getState()/detectStatusTransition() fold the raw
382
+ * `waiting_approval` into `generating` to suppress dashboard flicker — so
383
+ * detectStatusTransition()'s `waiting_approval` arm never runs and NO agent:waiting_approval
384
+ * event is emitted. The coordinator's real-time approval-nudge delivery then has no input and
385
+ * the worker's stuck modal is never surfaced (the live ~25s stall). The dashboard mask is
386
+ * intentional and stays; this emits the coordinator nudge exactly ONCE, gated on the SAME
387
+ * raw-waiting_approval + mask-stalled signal resolveModalParkStatus() distinguishes, the
388
+ * instant the mask-stall threshold trips (the same moment getState un-folds the mask).
389
+ *
390
+ * Only delegated worker sessions qualify: a foreground session has no coordinator to notify,
391
+ * and its own dashboard mask already reveals the modal on stall. A normally-resolving
392
+ * auto-approve never reaches AUTO_APPROVE_MASK_STALL_MS, so it emits nothing here; and if a
393
+ * masked approval clears just as this fires, rc.455's isApprovalNudgeResolved stale-drop
394
+ * discards the nudge coordinator-side without noise. Dedup is per-episode (keyed on the
395
+ * mask-clock value) so a modal that flaps between parsed/unparsed states is announced once.
396
+ */
397
+ private maybeEmitStalledApprovalNudge;
369
398
  private recordAutoApproval;
370
399
  recordApprovalSelection(buttonText: string): void;
371
400
  private formatMarkerTimestamp;
@@ -61,3 +61,19 @@ export declare class ManualAttendanceTracker {
61
61
  * pure read commands (read_chat / list_chats — passive polling, not driving).
62
62
  */
63
63
  export declare const MANUAL_ATTENDANCE_COMMANDS: ReadonlySet<string>;
64
+ /**
65
+ * The subset of {@link MANUAL_ATTENDANCE_COMMANDS} that are PASSIVE view-only
66
+ * actions — foregrounding a session's tab / opening its panel. They convey "I am
67
+ * looking at this session", not "I am driving it", and carry no user input.
68
+ *
69
+ * For a foreground (base-node) session these still attend: a user who
70
+ * foregrounds their own session should get the quiet window so an incoming
71
+ * approval stays visible for them to act on. But for a DELEGATED worker session
72
+ * a passive peek must NOT attend — a coordinator merely opening a worker's panel
73
+ * to watch progress would otherwise suppress that worker's delegated
74
+ * auto-approve for the whole window (secondary cause, #137). The per-instance
75
+ * hook decides: it drops a passive stamp only when the session is a delegated
76
+ * worker, so explicit input (controlbar / resolve_action / pty_input) still
77
+ * attends a worker and a foreground session is unaffected.
78
+ */
79
+ export declare const MANUAL_ATTENDANCE_PASSIVE_VIEW_COMMANDS: ReadonlySet<string>;
@@ -200,8 +200,15 @@ export interface ProviderInstance {
200
200
  * input). Provider-common signal that suppresses auto-approve for a short
201
201
  * window so the user can drive the session manually; background mesh worker
202
202
  * sessions never receive it, so their delegated auto-approve is unaffected.
203
+ *
204
+ * `opts.passive` marks a view-only action (select_session / open_panel). A
205
+ * delegated worker session ignores passive stamps so a coordinator merely
206
+ * watching its panel does not suppress its delegated auto-approve; explicit
207
+ * input still attends. Foreground sessions attend on passive views too.
203
208
  */
204
- noteManualInteraction?(now?: number): void;
209
+ noteManualInteraction?(now?: number, opts?: {
210
+ passive?: boolean;
211
+ }): void;
205
212
  /** cleanup */
206
213
  dispose(): void;
207
214
  }
@@ -55,10 +55,15 @@ export declare class ProviderLoader {
55
55
  private logFn;
56
56
  private versionArchive;
57
57
  private scriptsCache;
58
+ /**
59
+ * Resolved registry base URL and provider tarball URL. Resolution order:
60
+ * explicit config field (constructor option) → env var → vendor default.
61
+ * See `config/registry-resolver.ts`.
62
+ */
63
+ private readonly registryBaseUrl;
64
+ private readonly providerTarballUrl;
58
65
  /** Inject VersionArchive so resolve() can auto-detect installed versions */
59
66
  setVersionArchive(archive: VersionArchive): void;
60
- private static readonly GITHUB_TARBALL_URL;
61
- private static readonly REGISTRY_BASE_URL;
62
67
  private static readonly META_FILE;
63
68
  private static readonly REGISTRY_META_FILE;
64
69
  private static readonly REPO_PROVIDER_DIRNAME;
@@ -85,6 +90,16 @@ export declare class ProviderLoader {
85
90
  * probing; production code should leave this unset.
86
91
  */
87
92
  probeStarts?: string[];
93
+ /**
94
+ * Explicit provider registry base URL override (config.registryUrl).
95
+ * Highest-priority resolver source, ahead of ADHDEV_REGISTRY_URL + default.
96
+ */
97
+ registryUrl?: string;
98
+ /**
99
+ * Explicit provider tarball URL override (config.providerTarballUrl).
100
+ * Highest-priority resolver source, ahead of ADHDEV_PROVIDER_TARBALL_URL + default.
101
+ */
102
+ providerTarballUrl?: string;
88
103
  });
89
104
  private migrateMarketplaceDirToExternal;
90
105
  private log;
@@ -181,6 +181,11 @@ export declare class FsmDriver implements ISpecDriver {
181
181
  * (−1 = whole screen), or a `section:<id>` / `<region>#ignore:<pat>` string
182
182
  * when the clause scopes to a section or declares an ignore_lines filter. */
183
183
  private regionLastChangedAt;
184
+ /** COMPLETION-EARLYNOTIFY stable-eval trace: last stable/not-stable verdict
185
+ * recorded per stable region, so the trace fires only when the verdict FLIPS
186
+ * (not every quiet frame). Cleared on every transition alongside
187
+ * regionLastChangedAt. Diagnostic-only — never consulted by the FSM. */
188
+ private stableVerdictCache;
184
189
  /** Timer that re-runs evaluate() when a time-condition would flip true
185
190
  * with no PTY frame to trigger it. */
186
191
  private wakeTimer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.455",
3
+ "version": "0.9.82-rc.457",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,8 +46,8 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.455",
50
- "@adhdev/session-host-core": "0.9.82-rc.455",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.457",
50
+ "@adhdev/session-host-core": "0.9.82-rc.457",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
53
53
  "ajv-formats": "^3.0.1",
@@ -180,6 +180,8 @@ export async function initDaemonComponents(config: DaemonInitConfig): Promise<Da
180
180
  logFn: config.providerLogFn,
181
181
  sourceMode: providerSourceMode,
182
182
  userDir: appConfig.providerDir,
183
+ registryUrl: appConfig.registryUrl,
184
+ providerTarballUrl: appConfig.providerTarballUrl,
183
185
  });
184
186
 
185
187
  // Boot-time auto-sync is intentionally disabled. The user picks which
@@ -20,13 +20,14 @@ import type { ProviderModule, ProviderScripts } from '../providers/contracts.js'
20
20
  import type { DaemonAgentStreamManager } from '../agent-stream/index.js';
21
21
  import type { CliAdapter } from '../cli-adapter-types.js';
22
22
  import { loadConfig } from '../config/config.js';
23
+ import { resolveRegistryBaseUrl } from '../config/registry-resolver.js';
23
24
  import { ChatHistoryWriter } from '../config/chat-history.js';
24
25
  import type { SessionRegistry, SessionRuntimeTarget } from '../sessions/registry.js';
25
26
  import { reconcileIdeRuntimeSessions } from '../sessions/reconcile.js';
26
27
  import { LOG } from '../logging/logger.js';
27
28
  import { resolveLegacyProviderScript, type LegacyStringScript } from './provider-script-resolver.js';
28
29
  import { sha256Hex } from '../system/hash.js';
29
- import { MANUAL_ATTENDANCE_COMMANDS } from '../providers/manual-attendance.js';
30
+ import { MANUAL_ATTENDANCE_COMMANDS, MANUAL_ATTENDANCE_PASSIVE_VIEW_COMMANDS } from '../providers/manual-attendance.js';
30
31
 
31
32
  // Sub-module imports
32
33
  import * as Chat from './chat-commands.js';
@@ -393,15 +394,18 @@ export class DaemonCommandHandler implements CommandHelpers {
393
394
  */
394
395
  private noteManualAttendanceIfApplicable(cmd: string, args: any): void {
395
396
  if (!MANUAL_ATTENDANCE_COMMANDS.has(cmd)) return;
397
+ // Passive view-only actions (select_session / open_panel) attend a
398
+ // foreground session but NOT a delegated worker — the instance decides.
399
+ const passive = MANUAL_ATTENDANCE_PASSIVE_VIEW_COMMANDS.has(cmd);
396
400
  const sessionId = this._currentRoute.session?.sessionId
397
401
  || (typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '');
398
402
  if (!sessionId) return;
399
403
  const session = this._ctx.sessionRegistry?.get(sessionId);
400
404
  const instanceKey = session?.adapterKey || session?.instanceKey || sessionId;
401
405
  const instance = this._ctx.instanceManager?.getInstance(instanceKey) as
402
- { noteManualInteraction?: (now?: number) => void } | undefined;
406
+ { noteManualInteraction?: (now?: number, opts?: { passive?: boolean }) => void } | undefined;
403
407
  try {
404
- instance?.noteManualInteraction?.();
408
+ instance?.noteManualInteraction?.(undefined, { passive });
405
409
  } catch {
406
410
  // attendance is best-effort — never block command dispatch
407
411
  }
@@ -689,7 +693,7 @@ export class DaemonCommandHandler implements CommandHelpers {
689
693
  const https = require('https') as typeof import('https');
690
694
  const fs = require('fs') as typeof import('fs');
691
695
  const path = require('path') as typeof import('path');
692
- const REGISTRY = 'https://api.adhf.dev/api/v1/registry';
696
+ const REGISTRY = resolveRegistryBaseUrl(loadConfig().registryUrl);
693
697
 
694
698
  function fetchText(url: string, timeoutMs: number): Promise<string> {
695
699
  return new Promise((resolve, reject) => {
@@ -1118,7 +1122,7 @@ export class DaemonCommandHandler implements CommandHelpers {
1118
1122
  if (!installed.success) return installed;
1119
1123
 
1120
1124
  const https = require('https') as typeof import('https');
1121
- const REGISTRY = 'https://api.adhf.dev/api/v1/registry';
1125
+ const REGISTRY = resolveRegistryBaseUrl(loadConfig().registryUrl);
1122
1126
 
1123
1127
  function fetchJson(url: string): Promise<any> {
1124
1128
  return new Promise((resolve, reject) => {
@@ -19,6 +19,10 @@ const CHANNEL_SERVER_URL: Record<ReleaseChannel, string> = {
19
19
  stable: 'https://api.adhf.dev',
20
20
  preview: 'https://api-preview.adhf.dev',
21
21
  };
22
+ // Vendor-managed serverUrls. A serverUrl equal to one of these (or unset) is
23
+ // steered to the channel default on upgrade; any other value is a user-set
24
+ // custom/self-host URL that must be preserved across upgrades.
25
+ const VENDOR_SERVER_URLS = new Set<string>(Object.values(CHANNEL_SERVER_URL));
22
26
 
23
27
  function normalizeReleaseChannel(value: unknown): ReleaseChannel | null {
24
28
  if (typeof value !== 'string') return null;
@@ -51,7 +55,16 @@ export const daemonLifecycleHandlers: Record<string, LowFamilyHandler> = {
51
55
  // Check channel-pinned dist-tag and resolve it to a concrete install version.
52
56
  const latest = String(execNpmCommandSync(['view', `${pkgName}@${npmTag}`, 'version'], { encoding: 'utf-8', timeout: 10000 }, npmSurface)).trim();
53
57
  LOG.info('Upgrade', `Latest ${pkgName}@${npmTag}: v${latest}`);
54
- updateConfig({ updateChannel: channel, serverUrl: CHANNEL_SERVER_URL[channel] } as any);
58
+ // Only steer serverUrl to the channel's vendor default when the current
59
+ // value is unset or already a vendor default. A self-hoster's custom
60
+ // serverUrl must survive the upgrade instead of being clobbered.
61
+ const currentServerUrl = typeof loadConfig().serverUrl === 'string' ? loadConfig().serverUrl.trim() : '';
62
+ const useVendorServerUrl = currentServerUrl === '' || VENDOR_SERVER_URLS.has(currentServerUrl);
63
+ updateConfig(
64
+ useVendorServerUrl
65
+ ? { updateChannel: channel, serverUrl: CHANNEL_SERVER_URL[channel] } as any
66
+ : { updateChannel: channel } as any,
67
+ );
55
68
  let currentInstalled: string | null = null;
56
69
  try {
57
70
  const currentJson = String(execNpmCommandSync(['ls', '-g', pkgName, '--depth=0', '--json'], {
@@ -12,6 +12,7 @@
12
12
  import { daemonIdsEquivalent, meshNodeIdMatches, normalizeMeshNodeId } from '@adhdev/mesh-shared';
13
13
  import { resolveMeshHostStatus, normalizeMeshDaemonRole } from '../../mesh/mesh-host-ownership.js';
14
14
  import {
15
+ getRegisteredSubmodulePaths,
15
16
  loadMeshWorktreeBootstrapConfig,
16
17
  runMeshWorktreeBootstrap,
17
18
  type WorktreeBootstrapState,
@@ -81,6 +82,83 @@ export async function decideOssCloneSync(
81
82
  return 'skip_diverged';
82
83
  }
83
84
 
85
+ /**
86
+ * Sync every registered submodule of a freshly-cloned worktree to its clone source
87
+ * node's working submodule HEAD, applying decideOssCloneSync's origin-tip-priority
88
+ * rewind guard per submodule.
89
+ *
90
+ * Generic over the submodule set: the paths come from `.gitmodules` via
91
+ * getRegisteredSubmodulePaths, so this operates identically over EVERY registered
92
+ * submodule (oss, adhdev-providers, …) instead of a hardcoded 'oss' literal. In a
93
+ * repo whose only synced submodule is `oss` the emitted git commands are byte-identical
94
+ * to the original oss-only path.
95
+ *
96
+ * Best-effort by design: a failure on one submodule is logged and skipped; it never
97
+ * blocks the other submodules or the clone. A submodule is only ever advanced to a
98
+ * STRICTLY-NEWER source SHA — the fresh (origin/main-derived) worktree tip is never
99
+ * rewound onto a behind/diverged source.
100
+ */
101
+ export async function syncClonedWorktreeSubmodules(
102
+ worktreePath: string,
103
+ sourceWorkspace: string,
104
+ rg: (ctx: GitRepoIdentity, argv: string[], opts?: { timeoutMs?: number }) => Promise<unknown>,
105
+ ): Promise<void> {
106
+ const submodulePaths = getRegisteredSubmodulePaths(worktreePath);
107
+ if (submodulePaths.size === 0) return;
108
+
109
+ const sourceCtx: GitRepoIdentity = { workspace: sourceWorkspace, repoRoot: sourceWorkspace, isGitRepo: true };
110
+ const worktreeCtx: GitRepoIdentity = { workspace: worktreePath, repoRoot: worktreePath, isGitRepo: true };
111
+ const readStdout = (out: unknown): string =>
112
+ (typeof out === 'string' ? out : (out as any)?.stdout ?? '').trim();
113
+
114
+ for (const submodulePath of submodulePaths) {
115
+ try {
116
+ // Read the source node's working submodule SHA.
117
+ const sourceStatusOut = await rg(sourceCtx, ['submodule', 'status', submodulePath], { timeoutMs: 10000 });
118
+ const sourceSha = readStdout(sourceStatusOut).match(/^[+\- ]?([0-9a-f]{40})/)?.[1];
119
+ if (!sourceSha) continue;
120
+
121
+ // Read the worktree's freshly-checked-out submodule HEAD.
122
+ const subCtx: GitRepoIdentity = {
123
+ workspace: `${worktreePath}/${submodulePath}`,
124
+ repoRoot: `${worktreePath}/${submodulePath}`,
125
+ isGitRepo: true,
126
+ };
127
+ const worktreeSubSha = readStdout(await rg(subCtx, ['rev-parse', 'HEAD'], { timeoutMs: 10000 }));
128
+ if (!worktreeSubSha || worktreeSubSha === sourceSha) continue;
129
+
130
+ // Bring the source node's submodule HEAD into the worktree submodule object
131
+ // DB so both SHAs are resolvable for the ancestry (rewind) guard below.
132
+ await rg(subCtx, ['fetch', `${sourceWorkspace}/${submodulePath}`, 'HEAD'], { timeoutMs: 60000 });
133
+
134
+ // Rewind guard: the worktree submodule HEAD was just checked out from the
135
+ // FRESH (origin/main-derived) root base. Only advance to the source SHA when
136
+ // it is strictly newer — never rewind to a stale source.
137
+ let action: OssCloneSyncAction;
138
+ try {
139
+ action = await decideOssCloneSync(subCtx, worktreeSubSha, sourceSha, rg);
140
+ } catch (decideErr: any) {
141
+ action = 'skip_diverged';
142
+ console.warn(`[mesh] ${submodulePath} submodule sync guard could not resolve ancestry (kept fresh worktree HEAD): ${decideErr?.message ?? decideErr}`);
143
+ }
144
+
145
+ if (action === 'advance') {
146
+ await rg(subCtx, ['checkout', sourceSha], { timeoutMs: 10000 });
147
+ await rg(worktreeCtx, ['add', submodulePath], { timeoutMs: 10000 });
148
+ await rg(worktreeCtx, ['commit', '-m', `chore: sync ${submodulePath} to source node HEAD on clone`], { timeoutMs: 10000 });
149
+ console.log(`[mesh] Advanced ${submodulePath} submodule to newer source HEAD ${sourceSha.slice(0, 8)} in worktree`);
150
+ } else if (action === 'skip_rewind') {
151
+ console.warn(`[mesh] Skipped ${submodulePath} submodule rewind on clone: source node ${submodulePath} ${sourceSha.slice(0, 8)} is an ancestor of the fresh worktree ${submodulePath} ${worktreeSubSha.slice(0, 8)} — kept fresher worktree HEAD`);
152
+ } else if (action === 'skip_diverged') {
153
+ console.warn(`[mesh] Skipped ${submodulePath} submodule sync on clone: source node ${submodulePath} ${sourceSha.slice(0, 8)} diverged from the fresh worktree ${submodulePath} ${worktreeSubSha.slice(0, 8)} — kept worktree HEAD (coordinator reconciles)`);
154
+ }
155
+ } catch (subErr: any) {
156
+ // Per-submodule best-effort: never let one submodule's failure block the rest.
157
+ console.warn(`[mesh] ${submodulePath} submodule sync to source HEAD failed (best-effort):`, subErr?.message ?? subErr);
158
+ }
159
+ }
160
+ }
161
+
84
162
  export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
85
163
  list_meshes: async (ctx: MedFamilyContext, _args: any) => {
86
164
  try {
@@ -1019,57 +1097,13 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
1019
1097
  );
1020
1098
  submodulesInitialized = true;
1021
1099
 
1022
- // Sync oss submodule to source node HEAD (best-effort)
1100
+ // Sync every registered submodule to the clone source node's
1101
+ // working HEAD (best-effort, generic over .gitmodules — no
1102
+ // hardcoded 'oss'; the rewind guard is applied per submodule).
1023
1103
  const sourceWorkspace = sourceNode.repoRoot || sourceNode.workspace;
1024
1104
  if (sourceWorkspace) {
1025
- try {
1026
- const { runGit: rg } = await import('../../git/git-executor.js');
1027
- const sourceCtx = { workspace: sourceWorkspace, repoRoot: sourceWorkspace, isGitRepo: true };
1028
- const worktreeCtx = { workspace: result.worktreePath, repoRoot: result.worktreePath, isGitRepo: true };
1029
-
1030
- // Read source node's oss submodule SHA
1031
- const sourceStatusOut = await rg(sourceCtx, ['submodule', 'status', 'oss'], { timeoutMs: 10000 });
1032
- const sourceStatusLine = (typeof sourceStatusOut === 'string' ? sourceStatusOut : (sourceStatusOut as any)?.stdout ?? '').trim();
1033
- const sourceShaMatch = sourceStatusLine.match(/^[+\- ]?([0-9a-f]{40})/);
1034
- const sourceSha = sourceShaMatch?.[1];
1035
-
1036
- if (sourceSha) {
1037
- // Read worktree's current oss HEAD
1038
- const ossCtx = { workspace: `${result.worktreePath}/oss`, repoRoot: `${result.worktreePath}/oss`, isGitRepo: true };
1039
- const worktreeOssHeadOut = await rg(ossCtx, ['rev-parse', 'HEAD'], { timeoutMs: 10000 });
1040
- const worktreeOssSha = (typeof worktreeOssHeadOut === 'string' ? worktreeOssHeadOut : (worktreeOssHeadOut as any)?.stdout ?? '').trim();
1041
-
1042
- if (worktreeOssSha && worktreeOssSha !== sourceSha) {
1043
- // Bring the source node's oss HEAD into the worktree object DB so
1044
- // both SHAs are resolvable for the ancestry (rewind) guard below.
1045
- await rg(ossCtx, ['fetch', `${sourceWorkspace}/oss`, 'HEAD'], { timeoutMs: 60000 });
1046
-
1047
- // Rewind guard: the worktree oss HEAD was just checked out from the
1048
- // FRESH (origin/main-derived) root base. Only advance to the source
1049
- // SHA when it is strictly newer — never rewind to a stale source.
1050
- let ossAction: OssCloneSyncAction;
1051
- try {
1052
- ossAction = await decideOssCloneSync(ossCtx, worktreeOssSha, sourceSha, rg);
1053
- } catch (decideErr: any) {
1054
- ossAction = 'skip_diverged';
1055
- console.warn(`[mesh] oss submodule sync guard could not resolve ancestry (kept fresh worktree HEAD): ${decideErr?.message ?? decideErr}`);
1056
- }
1057
-
1058
- if (ossAction === 'advance') {
1059
- await rg(ossCtx, ['checkout', sourceSha], { timeoutMs: 10000 });
1060
- await rg(worktreeCtx, ['add', 'oss'], { timeoutMs: 10000 });
1061
- await rg(worktreeCtx, ['commit', '-m', 'chore: sync oss to source node HEAD on clone'], { timeoutMs: 10000 });
1062
- console.log(`[mesh] Advanced oss submodule to newer source HEAD ${sourceSha.slice(0, 8)} in worktree`);
1063
- } else if (ossAction === 'skip_rewind') {
1064
- console.warn(`[mesh] Skipped oss submodule rewind on clone: source node oss ${sourceSha.slice(0, 8)} is an ancestor of the fresh worktree oss ${worktreeOssSha.slice(0, 8)} — kept fresher worktree HEAD`);
1065
- } else if (ossAction === 'skip_diverged') {
1066
- console.warn(`[mesh] Skipped oss submodule sync on clone: source node oss ${sourceSha.slice(0, 8)} diverged from the fresh worktree oss ${worktreeOssSha.slice(0, 8)} — kept worktree HEAD (coordinator reconciles)`);
1067
- }
1068
- }
1069
- }
1070
- } catch (ossErr: any) {
1071
- console.warn('[mesh] oss submodule sync to source HEAD failed (best-effort):', ossErr.message);
1072
- }
1105
+ const { runGit: rg } = await import('../../git/git-executor.js');
1106
+ await syncClonedWorktreeSubmodules(result.worktreePath, sourceWorkspace, rg);
1073
1107
  }
1074
1108
  } catch (subErr: any) {
1075
1109
  // Submodule init is best-effort; don't fail the clone
@@ -123,6 +123,22 @@ export interface ADHDevConfig {
123
123
  // Optional explicit provider override root (for example a local adhdev-providers checkout)
124
124
  providerDir?: string;
125
125
 
126
+ /**
127
+ * Optional provider registry base URL override (for example a self-hosted
128
+ * registry). Highest-priority source in the registry resolver, ahead of the
129
+ * `ADHDEV_REGISTRY_URL` env var and the vendor default. See
130
+ * `config/registry-resolver.ts`.
131
+ */
132
+ registryUrl?: string;
133
+
134
+ /**
135
+ * Optional provider tarball (archive) URL override so self-hosters can point
136
+ * the daemon at their own provider mirror instead of the vendor GitHub repo.
137
+ * Highest-priority source, ahead of `ADHDEV_PROVIDER_TARBALL_URL` and the
138
+ * vendor default. See `config/registry-resolver.ts`.
139
+ */
140
+ providerTarballUrl?: string;
141
+
126
142
  /** Preferred daemon update channel. Defaults to stable/latest. */
127
143
  updateChannel?: ReleaseChannel;
128
144
 
@@ -233,6 +249,8 @@ function normalizeConfig(raw: unknown): ADHDevConfig & { activeWorkspaceId?: str
233
249
  ideSettings: isPlainObject(parsed.ideSettings) ? parsed.ideSettings : {},
234
250
  providerSourceMode: resolveProviderSourceMode(parsed.providerSourceMode, parsed.disableUpstream),
235
251
  providerDir: asOptionalString(parsed.providerDir),
252
+ registryUrl: asOptionalString(parsed.registryUrl),
253
+ providerTarballUrl: asOptionalString(parsed.providerTarballUrl),
236
254
  updateChannel: parsed.updateChannel === 'preview' ? 'preview' : 'stable',
237
255
  terminalSizingMode: parsed.terminalSizingMode === 'fit' ? 'fit' : 'measured',
238
256
  };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Registry / provider-distribution URL resolver.
3
+ *
4
+ * Single source of truth for the ADHDev provider *registry* base URL and the
5
+ * provider *tarball* (GitHub) URL. Self-hosters can repoint both away from the
6
+ * vendor defaults so the daemon never phones home to `api.adhf.dev` /
7
+ * `github.com/vilmire/adhdev-providers`.
8
+ *
9
+ * Resolution priority (highest first):
10
+ * 1. Explicit config field — `config.registryUrl` / `config.providerTarballUrl`
11
+ * 2. Environment variable — `ADHDEV_REGISTRY_URL` / `ADHDEV_PROVIDER_TARBALL_URL`
12
+ * 3. Vendor default — existing URLs (unchanged for default users)
13
+ *
14
+ * Default users get byte-identical behavior: the vendor defaults equal the
15
+ * literals these functions replaced.
16
+ */
17
+
18
+ /** Vendor default registry base URL (no trailing slash). */
19
+ export const DEFAULT_REGISTRY_BASE_URL = 'https://api.adhf.dev/api/v1/registry';
20
+
21
+ /** Vendor default provider tarball URL (GitHub main branch archive). */
22
+ export const DEFAULT_PROVIDER_TARBALL_URL =
23
+ 'https://github.com/vilmire/adhdev-providers/archive/refs/heads/main.tar.gz';
24
+
25
+ /** Env var that overrides the registry base URL. */
26
+ export const REGISTRY_URL_ENV_VAR = 'ADHDEV_REGISTRY_URL';
27
+
28
+ /** Env var that overrides the provider tarball URL. */
29
+ export const PROVIDER_TARBALL_URL_ENV_VAR = 'ADHDEV_PROVIDER_TARBALL_URL';
30
+
31
+ function cleanString(value: unknown): string | undefined {
32
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
33
+ }
34
+
35
+ function stripTrailingSlashes(url: string): string {
36
+ return url.replace(/\/+$/, '');
37
+ }
38
+
39
+ /**
40
+ * Resolve the provider registry base URL.
41
+ *
42
+ * @param configuredUrl explicit config value (config.registryUrl); highest priority.
43
+ * @param env process env source (defaults to process.env; injectable for tests).
44
+ * @returns the resolved base URL with any trailing slash stripped, so callers
45
+ * can safely append `/providers`, `/providers/<type>`, etc.
46
+ */
47
+ export function resolveRegistryBaseUrl(
48
+ configuredUrl?: string | null,
49
+ env: NodeJS.ProcessEnv = process.env,
50
+ ): string {
51
+ const resolved =
52
+ cleanString(configuredUrl) ??
53
+ cleanString(env[REGISTRY_URL_ENV_VAR]) ??
54
+ DEFAULT_REGISTRY_BASE_URL;
55
+ return stripTrailingSlashes(resolved);
56
+ }
57
+
58
+ /**
59
+ * Resolve the provider tarball (archive) URL.
60
+ *
61
+ * @param configuredUrl explicit config value (config.providerTarballUrl); highest priority.
62
+ * @param env process env source (defaults to process.env; injectable for tests).
63
+ */
64
+ export function resolveProviderTarballUrl(
65
+ configuredUrl?: string | null,
66
+ env: NodeJS.ProcessEnv = process.env,
67
+ ): string {
68
+ return (
69
+ cleanString(configuredUrl) ??
70
+ cleanString(env[PROVIDER_TARBALL_URL_ENV_VAR]) ??
71
+ DEFAULT_PROVIDER_TARBALL_URL
72
+ );
73
+ }
74
+
75
+ /** Parsed tarball request target for callers that issue raw hostname/path requests. */
76
+ export interface TarballRequestTarget {
77
+ /** Full resolved tarball URL. */
78
+ url: string;
79
+ /** Hostname component (e.g. `github.com`). */
80
+ hostname: string;
81
+ /** Path component including any query string (e.g. `/vilmire/...tar.gz`). */
82
+ path: string;
83
+ }
84
+
85
+ /**
86
+ * Resolve the provider tarball URL and split it into `{ url, hostname, path }`
87
+ * for callers that build raw `https.request` options (e.g. HEAD ETag probes).
88
+ */
89
+ export function resolveProviderTarballTarget(
90
+ configuredUrl?: string | null,
91
+ env: NodeJS.ProcessEnv = process.env,
92
+ ): TarballRequestTarget {
93
+ const url = resolveProviderTarballUrl(configuredUrl, env);
94
+ const parsed = new URL(url);
95
+ return {
96
+ url,
97
+ hostname: parsed.hostname,
98
+ path: parsed.pathname + (parsed.search || ''),
99
+ };
100
+ }