@ran-sh/dsh-crew 2.2.1 → 2.2.3

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.
@@ -42,12 +42,15 @@ import { homedir } from 'node:os';
42
42
  import * as realInstaller from './install.mjs';
43
43
  import { samePayloadContent, capturePayloadContent } from './payload-content.mjs';
44
44
  import { crewDshHome, crewProfileDir, claudeIntegrationLine } from './install.mjs';
45
+ import { integrationRoot } from './crew-paths.mjs';
45
46
  import { releaseClaimsState } from '../release-in-use.mjs';
46
47
  import { compareProcessToken, processStartToken } from '../process-identity.mjs';
47
48
  import { RELEASE_COHORT_FILENAME } from '../dsh-cohort.mjs';
48
49
  import { renameTree } from './tree-move.mjs';
49
50
  import { checkRuntimeAdvance, normalizeRuntimeState, runtimeStateMayHaveStarted } from './runtime-lifecycle.mjs';
50
- import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, gcRetainedRuntimes, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
51
+ import { ensureCrewDshRuntime, ensureCrewPluginRegistration, ensureCrewWebProfile, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, gcRetainedRuntimes, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
52
+ import { desktopAttach, desktopDetach, desktopStatus } from './desktop-profile.mjs';
53
+ import { mirrorOfficialHarnessConfig, officialConfigMirrorStatus } from './harness-config-import.mjs';
51
54
  import {
52
55
  ensureOfficialWebIntegration,
53
56
  officialWebIntegrationStatus,
@@ -970,6 +973,28 @@ function defaultNpmInstaller(stageRoot, log) {
970
973
  return true;
971
974
  }
972
975
 
976
+ // Exact-version specs only (the pinned-cohort shape). Ranges stay with normal
977
+ // resolution: a range override would both fight the resolver and violate the
978
+ // verbatim-spec rule npm applies when a direct dependency shares the name.
979
+ const EXACT_SPEC_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
980
+
981
+ /**
982
+ * Lock every exact-pinned dependency to its own version across the whole
983
+ * staged tree. The npm fallback install has no flag channel (sanitized env,
984
+ * fixed args), so a drifting transitive peer — e.g. a projection package
985
+ * peer-requiring a newer cohort than the payload's pin — otherwise aborts
986
+ * staging with ERESOLVE. npm v11 rejects an override whose spec differs from
987
+ * the direct dependency, so each override reuses the dependency's exact spec
988
+ * verbatim.
989
+ */
990
+ export function exactSpecOverrides(dependencies) {
991
+ const overrides = {};
992
+ for (const [name, spec] of Object.entries(dependencies ?? {})) {
993
+ if (typeof spec === 'string' && EXACT_SPEC_RE.test(spec)) overrides[name] = spec;
994
+ }
995
+ return Object.keys(overrides).length > 0 ? overrides : null;
996
+ }
997
+
973
998
  // ---- payload staging ---------------------------------------------------------
974
999
 
975
1000
  /**
@@ -1029,6 +1054,8 @@ export function stageCandidatePayload({
1029
1054
  ...(manifest.peerDependencies ?? {}),
1030
1055
  ...(manifest.dependencies ?? {}),
1031
1056
  };
1057
+ const overrides = exactSpecOverrides(stagedManifest.dependencies);
1058
+ if (overrides) stagedManifest.overrides = overrides;
1032
1059
  writeFileSync(join(stageDir, 'package.json'), JSON.stringify(stagedManifest, null, 2) + '\n');
1033
1060
 
1034
1061
  // Materialize dependencies: prefer replicating the exact bits the candidate
@@ -2217,7 +2244,7 @@ export function buildProductionMigration({ home, log = () => {}, stopOwned, star
2217
2244
  });
2218
2245
  }
2219
2246
 
2220
- async function ensureRuntimeStep({ home, log, ensureRuntime, migrateRuntime, stopOwned, startOwned, verifyOwned, supervisorFactory = crewSupervisor }) { const ensure = ensureRuntime ?? ((opts) => {
2247
+ async function ensureRuntimeStep({ home, log, ensureRuntime, migrateRuntime, ensureWebProfile, stopOwned, startOwned, verifyOwned, supervisorFactory = crewSupervisor }) { const ensure = ensureRuntime ?? ((opts) => {
2221
2248
  const r = ensureCrewDshRuntime({ ...opts, env: sanitizedPackageManagerEnv() });
2222
2249
  if (!r.ok && r.stderrTail) {
2223
2250
  log(` (runtime installer said: ${r.stderrTail})`);
@@ -2225,17 +2252,37 @@ async function ensureRuntimeStep({ home, log, ensureRuntime, migrateRuntime, sto
2225
2252
  if (!r.ok && r.code === 'DSH_RUNTIME_COHORT_MISMATCH') {
2226
2253
  return { ok: false, code: r.code, error: r.error, installed: r.installed, target: r.target, needsMigration: true };
2227
2254
  }
2228
- return r.ok ? { ok: true, version: r.cli?.version ?? null } : { ok: false, error: r.error ?? r.code ?? 'runtime bootstrap failed' };
2255
+ return r.ok
2256
+ ? { ok: true, version: r.cli?.version ?? null, cohortRepaired: r.cohortRepaired === true }
2257
+ : { ok: false, error: r.error ?? r.code ?? 'runtime bootstrap failed' };
2229
2258
  });
2230
2259
  // Production migration wiring: ONE supervisor instance per migration,
2231
2260
  // reused by stop/start/verify/rollback so child identity stays coherent.
2232
2261
  // No legacy bridge involved. Missing callbacks fail closed inside
2233
2262
  // migrateCrewDshRuntime.
2234
2263
  const migrate = migrateRuntime ?? buildProductionMigration({ home, log, stopOwned, startOwned, verifyOwned, supervisorFactory });
2264
+ // The runtime alone cannot boot a managed 3210/3080: the launcher preflight
2265
+ // and the supervisor require <DSH_HOME>/profiles/web. Materialize it here —
2266
+ // before any supervisor convergence, on every path that ends in "runtime
2267
+ // ready" — so a fresh install cannot deadlock on a heartbeat that a
2268
+ // profile-less service can never publish.
2269
+ const convergeWebProfile = () => {
2270
+ const profile = (ensureWebProfile ?? ensureCrewWebProfile)({ home });
2271
+ if (!profile.ok) {
2272
+ log(`✗ Crew web profile bootstrap failed (${profile.code ?? 'unknown error'})`);
2273
+ return false;
2274
+ }
2275
+ if (profile.created) log(`✓ Crew web profile scaffold created (${profile.profileRoot})`);
2276
+ return true;
2277
+ };
2235
2278
  const r = await ensure({ home });
2236
2279
  if (r?.ok) {
2237
2280
  log(`✓ reusable Crew DSH runtime${r.version ? ` (@${r.version})` : ''}`);
2238
- return true;
2281
+ // The install locks the whole family when the registry had published a newer
2282
+ // prerelease than the pinned cohort; say so, because a silent repair is
2283
+ // indistinguishable from a registry that never drifted.
2284
+ if (r.cohortRepaired) log(' the DSH family had drifted from the pinned cohort; installed the pinned family instead');
2285
+ return convergeWebProfile();
2239
2286
  }
2240
2287
  if (r?.needsMigration === true) {
2241
2288
  log(`- runtime cohort stale (${r.installed} -> ${r.target}); migrating via staged transaction`);
@@ -2245,7 +2292,7 @@ async function ensureRuntimeStep({ home, log, ensureRuntime, migrateRuntime, sto
2245
2292
  return false;
2246
2293
  }
2247
2294
  log(`✓ runtime cohort migrated (@${m.version})`);
2248
- return true;
2295
+ return convergeWebProfile();
2249
2296
  }
2250
2297
  log(`✗ reusable Crew DSH runtime unavailable: ${r?.error ?? 'unknown error'}`);
2251
2298
  return false;
@@ -2624,9 +2671,18 @@ async function activateRelease({ home, releaseDir, manifest, log, installer, sup
2624
2671
  // leave four configurations naming a directory that is gone. Writing the
2625
2672
  // release path directly is what made crash recovery unable to delete a
2626
2673
  // candidate without breaking Codex, ZCode and Claude Code.
2627
- const integrationRoot = registration.linkPath ?? releaseDir;
2628
-
2629
- const codex = installer.installCodex({ home, root: integrationRoot });
2674
+ //
2675
+ // The root is derived through the SAME integrationRoot() the readiness
2676
+ // checks use, not blindly from registration.linkPath: write and validate
2677
+ // must be one function of (home, releaseDir, name). In the healthy case the
2678
+ // gate passes right after registration re-pointed the link, so this is
2679
+ // still the link path; when the gate fails, both sides fall back to the
2680
+ // release directory together instead of the writer using the link while the
2681
+ // validator reports the release — the disagreement that made every clean
2682
+ // install read back as "needs repair".
2683
+ const integrationWriteRoot = integrationRoot({ home, root: releaseDir, name: manifest.name });
2684
+
2685
+ const codex = installer.installCodex({ home, root: integrationWriteRoot });
2630
2686
  if (codex.ok === false) {
2631
2687
  log(`✗ Codex Desktop integration failed: ${(codex.actions ?? []).join('; ')}`);
2632
2688
  return false;
@@ -2634,7 +2690,7 @@ async function activateRelease({ home, releaseDir, manifest, log, installer, sup
2634
2690
  log('✓ Codex Desktop integration');
2635
2691
 
2636
2692
  if (installer.installZCode) {
2637
- const zcode = installer.installZCode({ home, root: integrationRoot });
2693
+ const zcode = installer.installZCode({ home, root: integrationWriteRoot });
2638
2694
  if (zcode.ok === false) {
2639
2695
  log(`✗ ZCode integration failed (${zcode.code ?? 'unknown'})`);
2640
2696
  return false;
@@ -2649,7 +2705,7 @@ async function activateRelease({ home, releaseDir, manifest, log, installer, sup
2649
2705
  }
2650
2706
  if (startup?.supported) log('✓ Windows login startup');
2651
2707
 
2652
- const claude = await installer.installClaudeCode({ home, root: integrationRoot });
2708
+ const claude = await installer.installClaudeCode({ home, root: integrationWriteRoot });
2653
2709
  if (claude.ok === false) {
2654
2710
  log(claudeIntegrationLine(claude));
2655
2711
  return false;
@@ -2688,6 +2744,59 @@ export async function npxDetach({ home = homedir(), log = console.log } = {}) {
2688
2744
  return { ok: false, error: 'OFFICIAL_WEB_PROFILE_READ_ONLY' };
2689
2745
  }
2690
2746
 
2747
+ // Desktop-client integration. The official desktop app boots its Harness with the
2748
+ // app-exclusive `desktop` profile, so the Crew panel can only reach it through
2749
+ // that profile's own patch layer — the one official-home write Crew performs, and
2750
+ // only because an operator asked for it by name. It never runs during install,
2751
+ // update or supervision.
2752
+ export async function npxDesktop({ home = homedir(), args = [], dryRun = false, log = console.log } = {}) {
2753
+ const action = args[0] ?? 'status';
2754
+ if (!['attach', 'detach', 'status'].includes(action)) {
2755
+ log(`✗ unknown desktop action: ${action}`);
2756
+ return { ok: false, error: 'usage: dsh-crew desktop <attach|detach|status> [--dry-run]' };
2757
+ }
2758
+ if (action === 'status') {
2759
+ const status = desktopStatus({ home });
2760
+ if (!status.file_present) log(`- desktop patch layer: absent (${status.path})`);
2761
+ else if (!status.attached) log(`- desktop patch layer: present, no Crew bridge (${status.path})`);
2762
+ else if (status.current) log(`✓ desktop patch layer carries the current Crew bridge (revision ${status.bridge?.revision})`);
2763
+ else log(`! desktop patch layer carries a stale Crew bridge (current revision ${status.bridge?.revision ?? 'unavailable'})`);
2764
+ if (!status.bridge) log(` (${status.bridge_error})`);
2765
+ log(JSON.stringify(status, null, 2));
2766
+ return { ok: true, status };
2767
+ }
2768
+ const result = action === 'attach'
2769
+ ? desktopAttach({ home, dryRun, log })
2770
+ : desktopDetach({ home, dryRun, log });
2771
+ if (!result.ok) log(`✗ desktop ${action} failed: ${result.error ?? result.code}`);
2772
+ return result;
2773
+ }
2774
+
2775
+ // Bring the operator's user-level DSH configuration across from the official home
2776
+ // into Crew's home: the Crew hub then runs the same models, providers, policy and
2777
+ // MCP servers as the desktop app. Read-only on ~/.dsh, no secrets copied (provider
2778
+ // entries keep their apiKeyEnv references, and those variables are inherited).
2779
+ export async function npxConfig({ home = homedir(), args = [], dryRun = false, log = console.log } = {}) {
2780
+ const action = args[0] ?? 'status';
2781
+ if (!['import', 'status'].includes(action)) {
2782
+ log(`✗ unknown config action: ${action}`);
2783
+ return { ok: false, error: 'usage: dsh-crew config <import|status> [--dry-run]' };
2784
+ }
2785
+ if (action === 'status') {
2786
+ const status = officialConfigMirrorStatus({ home });
2787
+ for (const layer of [status.home_layer, status.profile_layer]) {
2788
+ log(`- ${layer.label}: ${!layer.source_present ? `no official source (${layer.source})` : layer.mirrored ? 'mirrored' : 'not imported yet (run: dsh-crew config import)'}`);
2789
+ }
2790
+ log(JSON.stringify(status, null, 2));
2791
+ return { ok: true, status };
2792
+ }
2793
+ const result = mirrorOfficialHarnessConfig({ home, dryRun, log });
2794
+ if (result.ok && result.changed && !dryRun) {
2795
+ log(' the Crew harness hot-watches its patch layers; the hub picks the imported configuration up without a restart');
2796
+ }
2797
+ return result;
2798
+ }
2799
+
2691
2800
  export async function npxInstall({
2692
2801
  home = homedir(),
2693
2802
  log = console.log,
@@ -3175,7 +3284,13 @@ export function npxStatus({
3175
3284
  const integrationLabel = (entry) => !entry?.installed ? 'not installed' : entry.ready === true ? 'installed' : 'needs repair';
3176
3285
  const codex = integrationLabel(st?.codex);
3177
3286
  const zcode = integrationLabel(st?.zcode);
3178
- const claude = integrationLabel(st?.claude);
3287
+ // Claude Code installs best-effort: on a host without the CLI the settings are
3288
+ // registered and the CLI step — the only one that can create the plugin snapshot
3289
+ // — is skipped, so such a machine carries a footprint that can never be ready.
3290
+ // Nothing there is repairable, and "needs repair" told operators to fix an
3291
+ // integration they never installed. A host whose CLI IS present with an
3292
+ // unfinished install still reads as needing repair.
3293
+ const claude = st?.claude?.host_detected === false ? 'not installed' : integrationLabel(st?.claude);
3179
3294
  const official = officialWebIntegrationStatus({ home, releaseDir: pointer?.path });
3180
3295
  const officialWeb = !official.legacy_present ? 'not present (native 3210 control plane)' : official.healthy ? 'legacy full bridge present (deprecated; manual cleanup available)' : 'legacy full bridge record present but unhealthy (deprecated)';
3181
3296
  const startupState = installer.windowsStartupStatus?.({ home, root: runningPackageRoot() });
@@ -3196,6 +3311,16 @@ export function npxStatus({
3196
3311
  }
3197
3312
  log(`DSH plugin: ${dshPlugin} (dedicated dsh-crew profile on 3210)`);
3198
3313
  log(`Official 3080 UI bridge: ${officialWeb}`);
3314
+ // The desktop app carries its own bridge entry, which pins a revision: after a
3315
+ // payload update that entry is stale until it is re-pointed, so say which of the
3316
+ // three states the machine is in instead of leaving the panel silently old.
3317
+ const desktop = desktopStatus({ home });
3318
+ log(`Desktop app integration: ${
3319
+ !desktop.file_present ? 'not attached (run: dsh-crew desktop attach)'
3320
+ : desktop.attached && desktop.current ? 'attached'
3321
+ : desktop.attached ? 'attached to a stale bridge revision (run: dsh-crew desktop attach)'
3322
+ : 'not attached (run: dsh-crew desktop attach)'
3323
+ }`);
3199
3324
  log(`Codex Desktop integration: ${codex}`);
3200
3325
  log(`ZCode integration: ${zcode}`);
3201
3326
  log(`Claude Code integration: ${claude}`);
@@ -3291,6 +3416,16 @@ Commands:
3291
3416
  install persist the candidate package into Crew-owned state and register it
3292
3417
  integrate disabled: the official web profile is read-only (legacy bridge retired)
3293
3418
  detach disabled: the official web profile is read-only (legacy bridge retired)
3419
+ desktop the official DSH desktop app: attach|detach|status the Crew panel in its
3420
+ own patch layer (~/.dsh/profiles/desktop/cordis.patch.yml, hot-watched
3421
+ by the Harness). The only official-home write Crew performs, and only
3422
+ when this command is run; attach is idempotent and follows the current
3423
+ bridge revision, detach removes exactly the block it wrote
3424
+ config import|status the operator's user-level DSH configuration (models,
3425
+ providers, policy, MCP servers) from the official home into Crew's own
3426
+ home, so the Crew hub matches the desktop app. Read-only on ~/.dsh; no
3427
+ secrets are copied (provider entries keep their apiKeyEnv references);
3428
+ entries naming packages absent from Crew's runtime cohort are skipped
3294
3429
  status read-only report of launcher/installed versions and integrations
3295
3430
  inspect print the machine-readable extension capability/readiness contract
3296
3431
  jobs machine-first job API: list|get|watch|cancel|submit
@@ -3312,6 +3447,7 @@ Options:
3312
3447
  --replacement-default <id> with providers delete-plan: replacement Harness Default provider
3313
3448
  --confirm with providers delete: confirm the destructive mutation
3314
3449
  --purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
3450
+ --dry-run with desktop attach|detach: report the change without writing
3315
3451
  --help show this help
3316
3452
 
3317
3453
  Primary install: npm install -g @ran-sh/dsh-crew (then run: dsh-crew install)
@@ -3622,6 +3758,7 @@ function normalizeCommand(argv) {
3622
3758
  let expectedRevision;
3623
3759
  let replacementDefault;
3624
3760
  let confirm = false;
3761
+ let dryRun = false;
3625
3762
  let purgeOrphanCredentials = false;
3626
3763
  for (let index = 0; index < flags.length; index += 1) {
3627
3764
  if (flags[index] === '--candidate') {
@@ -3661,16 +3798,18 @@ function normalizeCommand(argv) {
3661
3798
  replacementDefault = flags[index].slice('--replacement-default='.length); flags.splice(index, 1); index -= 1;
3662
3799
  } else if (flags[index] === '--confirm') {
3663
3800
  confirm = true; flags.splice(index, 1); index -= 1;
3801
+ } else if (flags[index] === '--dry-run') {
3802
+ dryRun = true; flags.splice(index, 1); index -= 1;
3664
3803
  } else if (flags[index] === '--purge-orphan-credentials') {
3665
3804
  purgeOrphanCredentials = true; flags.splice(index, 1); index -= 1;
3666
3805
  }
3667
3806
  }
3668
- const knownFlags = new Set(['--purge']);
3807
+ const knownFlags = new Set(['--purge', '--dry-run']);
3669
3808
  const unknown = flags.filter((f) => f.startsWith('--') && !knownFlags.has(f));
3670
3809
  const args = flags.filter((f) => !f.startsWith('--'));
3671
3810
  if (!Number.isInteger(after) || after < 0) unknown.push('--after');
3672
3811
  if (!['compact', 'full'].includes(detail)) unknown.push('--detail');
3673
- return { command: argv[0], purge: flags.includes('--purge'), candidate, after, detail, request, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, args, unknown };
3812
+ return { command: argv[0], purge: flags.includes('--purge'), candidate, after, detail, request, planId, expectedRevision, replacementDefault, confirm, dryRun, purgeOrphanCredentials, args, unknown };
3674
3813
  }
3675
3814
 
3676
3815
  /**
@@ -3682,7 +3821,7 @@ export async function runNpxCli({
3682
3821
  error = console.error,
3683
3822
  commands = {},
3684
3823
  } = {}) {
3685
- const { command, purge, candidate, after, detail, request, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, args, unknown } = normalizeCommand(argv);
3824
+ const { command, purge, candidate, after, detail, request, planId, expectedRevision, replacementDefault, confirm, dryRun, purgeOrphanCredentials, args, unknown } = normalizeCommand(argv);
3686
3825
  if (command === '--help' || command === '-h' || command === 'help') {
3687
3826
  log(USAGE);
3688
3827
  return 0;
@@ -3691,7 +3830,7 @@ export async function runNpxCli({
3691
3830
  error(USAGE);
3692
3831
  return 1;
3693
3832
  }
3694
- if (unknown.length > 0 || !['install', 'integrate', 'detach', 'status', 'inspect', 'jobs', 'providers', 'credentials', 'releases', 'rollback', 'update', 'uninstall'].includes(command)) {
3833
+ if (unknown.length > 0 || !['install', 'integrate', 'detach', 'desktop', 'config', 'status', 'inspect', 'jobs', 'providers', 'credentials', 'releases', 'rollback', 'update', 'uninstall'].includes(command)) {
3695
3834
  error(`unknown command: ${command ?? '<none>'}\n\n${USAGE}`);
3696
3835
  return 1;
3697
3836
  }
@@ -3700,6 +3839,8 @@ export async function runNpxCli({
3700
3839
  install: commands.install ?? npxInstall,
3701
3840
  integrate: commands.integrate ?? npxIntegrate,
3702
3841
  detach: commands.detach ?? npxDetach,
3842
+ desktop: commands.desktop ?? npxDesktop,
3843
+ config: commands.config ?? npxConfig,
3703
3844
  status: commands.status ?? npxStatus,
3704
3845
  inspect: commands.inspect ?? npxInspect,
3705
3846
  jobs: commands.jobs ?? npxJobs,
@@ -3713,6 +3854,8 @@ export async function runNpxCli({
3713
3854
  let result;
3714
3855
  if (command === 'uninstall') result = await actions.uninstall({ purge, log });
3715
3856
  else if (command === 'update') result = await actions.update({ candidate, log });
3857
+ else if (command === 'desktop') result = await actions.desktop({ args, dryRun, log });
3858
+ else if (command === 'config') result = await actions.config({ args, dryRun, log });
3716
3859
  else if (command === 'jobs') result = await actions.jobs({ args, after, detail, request, log });
3717
3860
  else if (command === 'providers') result = await actions.providers({ args, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, log });
3718
3861
  else if (command === 'credentials') result = await actions.credentials({ args, planId, expectedRevision, confirm, log });
@@ -406,7 +406,9 @@ export function registerOfficialWebBridge(ctx, options = {}) {
406
406
  ui_role: 'quick-controls',
407
407
  execution_plane: 'hub-3210',
408
408
  listen_port: 3210,
409
- full_control_plane_url: 'http://127.0.0.1:3210/',
409
+ // The usable entry: the hub mints its own session and redirects, because
410
+ // a bare port asks for a token only the hub's own process can sign.
411
+ full_control_plane_url: 'http://127.0.0.1:3210/_dsh/dsh-crew/control-plane',
410
412
  });
411
413
  },
412
414
  });
@@ -382,9 +382,27 @@ export function inspectProviderSettings(source) {
382
382
  return { ok: true, schema: 'harness-settings-llm-pi-ai-v1', providerIds: parsed.entries.map((entry) => entry.id), revision };
383
383
  }
384
384
 
385
+ /** Whether the settings document carries a Harness provider section at all. */
386
+ export function hasProviderSettingsSection(source) {
387
+ return typeof source === 'string' && source.split(/\r?\n/u).some((line) => /^llm-pi-ai:\s*$/u.test(line));
388
+ }
389
+
390
+ /** Whether the settings document declares a Harness default-model authority. */
391
+ export function hasHarnessDefaultBlock(source) {
392
+ return typeof source === 'string' && source.split(/\r?\n/u).some((line) => /^agent-default-model:\s*$/u.test(line));
393
+ }
394
+
385
395
  export function readProviderSettingsDeclarations(source, { file = 'harness/settings.yaml' } = {}) {
386
396
  const parsed = parseProviderMap(source);
387
- if (!parsed.ok) return { ok: false, code: parsed.code };
397
+ // A settings file with no `llm-pi-ai:` section declares no providers. That is
398
+ // not an unsupported schema — it is the state every home starts in and the
399
+ // state a provider migration writes into — so only a section that EXISTS but
400
+ // cannot be parsed is refused.
401
+ if (!parsed.ok) {
402
+ return parsed.code === 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' && !hasProviderSettingsSection(source)
403
+ ? { ok: true, declarations: [], absent: true, file }
404
+ : { ok: false, code: parsed.code };
405
+ }
388
406
  return {
389
407
  ok: true,
390
408
  declarations: parsed.entries.map((entry) => {
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '2.2.1';
39
+ export const RUNTIME_VERSION = '2.2.3';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([