@dharma-ai-labs/agent-fabric 0.2.102 → 0.2.104

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 (71) hide show
  1. package/dist/demoPackage.d.ts +2 -2
  2. package/dist/demoPackage.d.ts.map +1 -1
  3. package/dist/demoPackage.js +4 -1
  4. package/dist/demoPackage.js.map +1 -1
  5. package/dist/demoPackage.test.js +42 -0
  6. package/dist/demoPackage.test.js.map +1 -1
  7. package/dist/demoStatus.d.ts +14 -0
  8. package/dist/demoStatus.d.ts.map +1 -0
  9. package/dist/demoStatus.js +11 -0
  10. package/dist/demoStatus.js.map +1 -0
  11. package/dist/demoSupervisor.d.ts +34 -0
  12. package/dist/demoSupervisor.d.ts.map +1 -0
  13. package/dist/demoSupervisor.js +164 -0
  14. package/dist/demoSupervisor.js.map +1 -0
  15. package/dist/demoSupervisor.test.d.ts +2 -0
  16. package/dist/demoSupervisor.test.d.ts.map +1 -0
  17. package/dist/demoSupervisor.test.js +249 -0
  18. package/dist/demoSupervisor.test.js.map +1 -0
  19. package/dist/demoWatchControl.d.ts +60 -0
  20. package/dist/demoWatchControl.d.ts.map +1 -0
  21. package/dist/demoWatchControl.js +128 -0
  22. package/dist/demoWatchControl.js.map +1 -0
  23. package/dist/demoWatchControl.test.d.ts +2 -0
  24. package/dist/demoWatchControl.test.d.ts.map +1 -0
  25. package/dist/demoWatchControl.test.js +184 -0
  26. package/dist/demoWatchControl.test.js.map +1 -0
  27. package/dist/demoWatchHealth.d.ts +26 -0
  28. package/dist/demoWatchHealth.d.ts.map +1 -0
  29. package/dist/demoWatchHealth.js +177 -0
  30. package/dist/demoWatchHealth.js.map +1 -0
  31. package/dist/demoWatchHealth.test.d.ts +2 -0
  32. package/dist/demoWatchHealth.test.d.ts.map +1 -0
  33. package/dist/demoWatchHealth.test.js +175 -0
  34. package/dist/demoWatchHealth.test.js.map +1 -0
  35. package/dist/demoWatchRegistry.d.ts +17 -0
  36. package/dist/demoWatchRegistry.d.ts.map +1 -0
  37. package/dist/demoWatchRegistry.js +170 -0
  38. package/dist/demoWatchRegistry.js.map +1 -0
  39. package/dist/demoWatchRegistry.test.d.ts +2 -0
  40. package/dist/demoWatchRegistry.test.d.ts.map +1 -0
  41. package/dist/demoWatchRegistry.test.js +146 -0
  42. package/dist/demoWatchRegistry.test.js.map +1 -0
  43. package/dist/index.d.ts +5 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +248 -82
  46. package/dist/index.js.map +1 -1
  47. package/dist/index.test.js +69 -5
  48. package/dist/index.test.js.map +1 -1
  49. package/dist/pidLock.test.d.ts +2 -0
  50. package/dist/pidLock.test.d.ts.map +1 -0
  51. package/dist/pidLock.test.js +172 -0
  52. package/dist/pidLock.test.js.map +1 -0
  53. package/dist/relayAutostart.d.ts +17 -6
  54. package/dist/relayAutostart.d.ts.map +1 -1
  55. package/dist/relayAutostart.js +124 -19
  56. package/dist/relayAutostart.js.map +1 -1
  57. package/dist/relayAutostart.test.js +158 -1
  58. package/dist/relayAutostart.test.js.map +1 -1
  59. package/dist/relaySupervisor.test.js +75 -1
  60. package/dist/relaySupervisor.test.js.map +1 -1
  61. package/dist/repositoryOnboardingCaller.test.js +21 -0
  62. package/dist/repositoryOnboardingCaller.test.js.map +1 -1
  63. package/dist/schemas/demo-watch-control.v1.schema.json +27 -0
  64. package/dist/schemas/demo-watch-health.v1.schema.json +24 -0
  65. package/dist/schemas/demo-watch-registration.v1.schema.json +18 -0
  66. package/dist/schemas/relay-autostart.v2.schema.json +23 -0
  67. package/dist/usage.d.ts +1 -1
  68. package/dist/usage.d.ts.map +1 -1
  69. package/dist/usage.js +1 -1
  70. package/dist/usage.js.map +1 -1
  71. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { createHash, createPrivateKey, createPublicKey, randomUUID } from 'node:
4
4
  import { realpathSync } from 'node:fs';
5
5
  import { access, chmod, link, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, unlink, writeFile } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
- import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
7
+ import { basename, dirname, isAbsolute, posix, relative, resolve, win32 } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { promisify } from 'node:util';
10
10
  import { canonicalize, createActionDecisionPublicKeyResolver, sha256, validateContract, validateTrustedServerSigningKeysetContract, verifyCanonicalObject, verifyInitialServerSigningKeyset, } from '@dharma-ai-labs/agent-fabric-contracts';
@@ -17,7 +17,7 @@ import { contentHash, getActiveSkillBundleAuthorization, getExpiredSkillBundleAu
17
17
  import { executeTask, FileActionExecutionJournal, FileTaskReceiptStore, providerInstructionsForTask, } from '@dharma-ai-labs/agent-fabric-task-runner';
18
18
  import { CLI_USAGE } from './usage.js';
19
19
  import { superviseRelay } from './relaySupervisor.js';
20
- import { disableRelayAutostart, enableRelayAutostart, relayAutostartStatus } from './relayAutostart.js';
20
+ import { disableRelayAutostart, enableRelayAutostart, relayAutostartStatus, startRelayAutostart } from './relayAutostart.js';
21
21
  import { initializeRepositoryKnowledge, readRepositoryKnowledgeSource } from './repositoryKnowledge.js';
22
22
  import { inventoryRepositoryPackage, readRepositoryPackageSnapshot, readRepositorySourceBaselineSnapshot, writeRepositoryPackageSnapshot } from './repositoryPackage.js';
23
23
  import { validateRepositorySourceAuthorization } from './repositorySourceAuthorization.js';
@@ -38,8 +38,13 @@ import { waitForRepositoryReadiness } from './repositoryReadinessWait.js';
38
38
  import { connectDemoDevice, verifyDemoDevice } from './demoEnrollment.js';
39
39
  import { performDemoPeerAction, withDemoDeviceLock } from './demoPeer.js';
40
40
  import { demoRepositoryPackage } from './demoPackage.js';
41
+ import { demoDeviceAndPackageStatus } from './demoStatus.js';
41
42
  import { runDemoWatch } from './demoWatch.js';
42
- const VERSION = '0.2.102';
43
+ import { runDemoSupervisor } from './demoSupervisor.js';
44
+ import { demoWatchRegistrationKey, listDemoWatchRegistrations } from './demoWatchRegistry.js';
45
+ import { createDemoWatchHealthRecorder } from './demoWatchHealth.js';
46
+ import { demoWatchStatus, disableDemoWatch, enableDemoWatch, inspectDemoWatchSupervisor } from './demoWatchControl.js';
47
+ const VERSION = '0.2.104';
43
48
  const USAGE = CLI_USAGE;
44
49
  const execFileAsync = promisify(execFile);
45
50
  const LOCAL_PROVIDER_IDS = ['codex', 'claude', 'agy', 'hermes'];
@@ -576,10 +581,40 @@ async function acquirePidLock(lockPath, timeoutMs, timeoutMessage) {
576
581
  throw error;
577
582
  const recoveryPath = `${lockPath}.recovery`;
578
583
  const recoveryCandidate = `${recoveryPath}.${process.pid}.${randomUUID()}.candidate`;
584
+ let windowsRecoveryOwner;
579
585
  try {
580
586
  await mkdir(recoveryCandidate);
581
587
  await writeFile(resolve(recoveryCandidate, 'owner'), `${process.pid}\n`, { mode: 0o600 });
582
- await rename(recoveryCandidate, recoveryPath);
588
+ for (let attempt = 0;; attempt++) {
589
+ try {
590
+ await rename(recoveryCandidate, recoveryPath);
591
+ break;
592
+ }
593
+ catch (renameError) {
594
+ if (process.platform !== 'win32' || renameError.code !== 'EPERM')
595
+ throw renameError;
596
+ // Windows uses EPERM for an existing destination. Its owner can
597
+ // finish during inspection; retry publication, never ignore denial.
598
+ try {
599
+ const directory = await lstat(recoveryPath);
600
+ const ownerPath = resolve(recoveryPath, 'owner');
601
+ const ownerFile = await lstat(ownerPath);
602
+ const ownerText = (await readFile(ownerPath, 'utf8')).trim();
603
+ const owner = Number(ownerText);
604
+ if (!directory.isDirectory() || directory.isSymbolicLink()
605
+ || !ownerFile.isFile() || ownerFile.isSymbolicLink()
606
+ || !/^[1-9][0-9]*$/.test(ownerText) || !Number.isSafeInteger(owner))
607
+ throw renameError;
608
+ windowsRecoveryOwner = owner;
609
+ }
610
+ catch (inspectionError) {
611
+ if (inspectionError.code === 'ENOENT' && attempt < 3)
612
+ continue;
613
+ throw renameError;
614
+ }
615
+ throw renameError;
616
+ }
617
+ }
583
618
  try {
584
619
  let ownerPid = 0;
585
620
  try {
@@ -617,13 +652,21 @@ async function acquirePidLock(lockPath, timeoutMs, timeoutMessage) {
617
652
  }
618
653
  catch (recoveryError) {
619
654
  await rm(recoveryCandidate, { recursive: true, force: true });
620
- if (!['EEXIST', 'ENOTEMPTY'].includes(recoveryError.code || ''))
621
- throw recoveryError;
655
+ const recoveryCode = recoveryError.code || '';
622
656
  let recoveryOwner = 0;
623
- try {
624
- recoveryOwner = Number((await readFile(resolve(recoveryPath, 'owner'), 'utf8')).trim());
657
+ if (process.platform === 'win32' && recoveryCode === 'EPERM') {
658
+ if (windowsRecoveryOwner === undefined)
659
+ throw recoveryError;
660
+ recoveryOwner = windowsRecoveryOwner;
661
+ }
662
+ else {
663
+ if (!['EEXIST', 'ENOTEMPTY'].includes(recoveryCode))
664
+ throw recoveryError;
665
+ try {
666
+ recoveryOwner = Number((await readFile(resolve(recoveryPath, 'owner'), 'utf8')).trim());
667
+ }
668
+ catch { }
625
669
  }
626
- catch { }
627
670
  let recoveryOwnerAlive = Number.isSafeInteger(recoveryOwner) && recoveryOwner > 0;
628
671
  if (recoveryOwnerAlive) {
629
672
  try {
@@ -900,6 +943,15 @@ async function withFileLock(lockPath, operation, timeoutMessage = 'Timed out wai
900
943
  await release();
901
944
  }
902
945
  }
946
+ export async function withRelayStartupMutation(operation, home = dharmaHome()) {
947
+ const release = await acquirePidLock(resolve(home, 'autostart-mutation.lock'), 30_000, 'demo_watch_startup_busy: another startup operation is still running.');
948
+ try {
949
+ return await operation();
950
+ }
951
+ finally {
952
+ await release();
953
+ }
954
+ }
903
955
  export async function withWorkspacePolicyRefreshLock(workspaceId, operation) {
904
956
  if (!workspaceId)
905
957
  throw new Error('Content transmission requires a registered workspace ID.');
@@ -1370,64 +1422,89 @@ export function requireCompletedBootstrapEvidence(result, discovered) {
1370
1422
  + 'Enrollment and repository setup were preserved; rerun bounded evidence synchronization after the service is available.');
1371
1423
  }
1372
1424
  async function archiveEnrollmentForAuthorizedRebind(existing) {
1373
- const pidPath = resolve(dharmaHome(), 'relay', 'relay.pid');
1374
- let relayPid = 0;
1375
- try {
1376
- relayPid = Number((await readFile(pidPath, 'utf8')).trim());
1377
- }
1378
- catch { }
1379
- if (Number.isSafeInteger(relayPid) && relayPid > 0) {
1380
- try {
1381
- process.kill(relayPid, 'SIGTERM');
1425
+ return withRelayStartupMutation(async () => {
1426
+ if ((await listDemoWatchRegistrations(dharmaHome())).length) {
1427
+ throw new Error('demo_watch_standard_rebind_conflict: preserve Demo scopes; standard rebind needs a scoped recovery path.');
1382
1428
  }
1383
- catch (error) {
1384
- if (error.code !== 'ESRCH')
1385
- throw error;
1429
+ const pidPath = resolve(dharmaHome(), 'relay', 'relay.pid');
1430
+ let relayPid = 0;
1431
+ try {
1432
+ relayPid = Number((await readFile(pidPath, 'utf8')).trim());
1386
1433
  }
1387
- for (let attempt = 0; attempt < 20; attempt += 1) {
1434
+ catch { }
1435
+ if (Number.isSafeInteger(relayPid) && relayPid > 0) {
1436
+ try {
1437
+ process.kill(relayPid, 'SIGTERM');
1438
+ }
1439
+ catch (error) {
1440
+ if (error.code !== 'ESRCH')
1441
+ throw error;
1442
+ }
1443
+ for (let attempt = 0; attempt < 20; attempt += 1) {
1444
+ try {
1445
+ process.kill(relayPid, 0);
1446
+ }
1447
+ catch (error) {
1448
+ if (error.code === 'ESRCH')
1449
+ break;
1450
+ }
1451
+ await new Promise((resolveWait) => setTimeout(resolveWait, 100));
1452
+ }
1388
1453
  try {
1389
1454
  process.kill(relayPid, 0);
1455
+ throw new Error('The previous organization relay did not stop; enrollment was not changed.');
1390
1456
  }
1391
1457
  catch (error) {
1392
- if (error.code === 'ESRCH')
1393
- break;
1458
+ if (error.code !== 'ESRCH')
1459
+ throw error;
1394
1460
  }
1395
- await new Promise((resolveWait) => setTimeout(resolveWait, 100));
1396
- }
1397
- try {
1398
- process.kill(relayPid, 0);
1399
- throw new Error('The previous organization relay did not stop; enrollment was not changed.');
1400
1461
  }
1401
- catch (error) {
1402
- if (error.code !== 'ESRCH')
1403
- throw error;
1462
+ const backupId = `${new Date().toISOString().replace(/[:.]/g, '-')}-${createHash('sha256')
1463
+ .update(`${existing.hqUrl}\0${existing.organizationId}`)
1464
+ .digest('hex').slice(0, 12)}`;
1465
+ const backupRoot = resolve(dirname(dharmaHome()), '.dharma-rebind-backups', backupId);
1466
+ await disableRelayAutostart({ home: dharmaHome() });
1467
+ await mkdir(backupRoot, { recursive: true, mode: 0o700 });
1468
+ for (const relativePath of ['device.json', 'pending-enrollment.json', 'registry', 'relay', 'vault']) {
1469
+ const source = resolve(dharmaHome(), relativePath);
1470
+ if (!await pathExists(source))
1471
+ continue;
1472
+ await rename(source, resolve(backupRoot, relativePath));
1404
1473
  }
1405
- }
1406
- const backupId = `${new Date().toISOString().replace(/[:.]/g, '-')}-${createHash('sha256')
1407
- .update(`${existing.hqUrl}\0${existing.organizationId}`)
1408
- .digest('hex').slice(0, 12)}`;
1409
- const backupRoot = resolve(dirname(dharmaHome()), '.dharma-rebind-backups', backupId);
1410
- await disableRelayAutostart({ home: dharmaHome() });
1411
- await mkdir(backupRoot, { recursive: true, mode: 0o700 });
1412
- for (const relativePath of ['device.json', 'pending-enrollment.json', 'registry', 'relay', 'vault']) {
1413
- const source = resolve(dharmaHome(), relativePath);
1414
- if (!await pathExists(source))
1415
- continue;
1416
- await rename(source, resolve(backupRoot, relativePath));
1417
- }
1418
- await writeJsonAtomic(resolve(backupRoot, 'RECEIPT.json'), {
1419
- schema: 'dharma.local-enrollment-rebind-backup/v1',
1420
- previousOrganizationId: existing.organizationId,
1421
- previousPortalOrigin: existing.hqUrl,
1422
- createdAt: new Date().toISOString(),
1474
+ await writeJsonAtomic(resolve(backupRoot, 'RECEIPT.json'), {
1475
+ schema: 'dharma.local-enrollment-rebind-backup/v1',
1476
+ previousOrganizationId: existing.organizationId,
1477
+ previousPortalOrigin: existing.hqUrl,
1478
+ createdAt: new Date().toISOString(),
1479
+ });
1480
+ return { changed: true, backupId, previousOrganizationId: existing.organizationId };
1423
1481
  });
1424
- return { changed: true, backupId, previousOrganizationId: existing.organizationId };
1425
1482
  }
1426
- export function stableRepositoryLauncherContents(version = VERSION) {
1483
+ export function stableRepositoryLauncherContents(version = VERSION, runtime) {
1484
+ if (!/^(?=.{1,64}$)\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) {
1485
+ throw new Error('Managed launcher version is invalid.');
1486
+ }
1487
+ let shellEnvironment = '';
1488
+ let windowsEnvironment = '';
1489
+ if (runtime) {
1490
+ const directory = runtime.nodeDirectory;
1491
+ if (!directory || directory.length > 4096 || /[\r\n\0]/.test(directory)
1492
+ || !(runtime.platform === 'win32' ? win32 : posix).isAbsolute(directory)) {
1493
+ throw new Error('Managed launcher runtime directory is invalid.');
1494
+ }
1495
+ if (runtime.platform === 'win32') {
1496
+ if (directory.includes('"'))
1497
+ throw new Error('Managed launcher runtime directory is invalid.');
1498
+ windowsEnvironment = `setlocal DisableDelayedExpansion\r\nset "PATH=${directory.replace(/%/g, '%%')};%PATH%"\r\n`;
1499
+ }
1500
+ else {
1501
+ shellEnvironment = `export PATH='${directory.replace(/'/g, "'\\''")}':"$PATH"\n`;
1502
+ }
1503
+ }
1427
1504
  const invocation = `npm exec --yes -- @dharma-ai-labs/agent-fabric@${version}`;
1428
1505
  return {
1429
- shell: `#!/bin/sh\nexec ${invocation} "$@"\n`,
1430
- windows: `@echo off\r\n${invocation} %*\r\n`,
1506
+ shell: `#!/bin/sh\n${shellEnvironment}exec ${invocation} "$@"\n`,
1507
+ windows: `@echo off\r\n${windowsEnvironment}${invocation} %*\r\n`,
1431
1508
  };
1432
1509
  }
1433
1510
  async function installStableRepositoryLauncher(workspace) {
@@ -1435,7 +1512,7 @@ async function installStableRepositoryLauncher(workspace) {
1435
1512
  await mkdir(launcherRoot, { recursive: true, mode: 0o700 });
1436
1513
  const shellPath = resolve(launcherRoot, 'dharma');
1437
1514
  const cmdPath = resolve(launcherRoot, 'dharma.cmd');
1438
- const contents = stableRepositoryLauncherContents();
1515
+ const contents = stableRepositoryLauncherContents(VERSION, { platform: process.platform, nodeDirectory: dirname(process.execPath) });
1439
1516
  await writeFile(shellPath, contents.shell, { mode: 0o700 });
1440
1517
  await chmod(shellPath, 0o700);
1441
1518
  await writeFile(cmdPath, contents.windows, { mode: 0o600 });
@@ -1510,32 +1587,88 @@ async function startRelayDaemon(policyPath) {
1510
1587
  const readiness = await waitForRelayReadiness({ expectedVersion: VERSION });
1511
1588
  return { started: !alreadyRunning, supervisor: 'running', ...readiness };
1512
1589
  }
1590
+ async function supervisedDemoCycle(registration, signal) {
1591
+ signal.throwIfAborted();
1592
+ const workspace = await realpath(registration.workspace);
1593
+ if (workspace !== registration.workspace)
1594
+ throw new Error('Demo watch checkout is no longer canonical.');
1595
+ const root = await gitValue(workspace, ['rev-parse', '--show-toplevel']);
1596
+ const remote = await gitValue(workspace, ['config', '--get', 'remote.origin.url']);
1597
+ if (!root || !remote || normalizeGitRemoteIdentity(remote) !== registration.normalizedRepository) {
1598
+ throw Object.assign(new Error('Demo watch repository binding changed.'), { code: 'demo_watch_repository_changed' });
1599
+ }
1600
+ const scope = { hqUrl: registration.hqUrl, organizationId: registration.organizationId,
1601
+ repositoryId: registration.repositoryId, normalizedRepository: registration.normalizedRepository,
1602
+ installationId: await loadOrCreateInstallationId(), stateRoot: dharmaHome() };
1603
+ const nativeDirectory = registration.provider === 'codex' ? resolve(workspace, '.agents', 'skills')
1604
+ : registration.provider === 'claude' ? resolve(workspace, '.claude', 'skills')
1605
+ : nativeSkillDirectory(registration.provider);
1606
+ return withDemoDeviceLock(scope, () => {
1607
+ signal.throwIfAborted();
1608
+ return demoRepositoryPackage({ scope, workspace, provider: registration.provider,
1609
+ nativeSkillDirectory: nativeDirectory }, { fetcher: (url, options) => {
1610
+ signal.throwIfAborted();
1611
+ return fetch(url, { ...options,
1612
+ signal: options?.signal ? AbortSignal.any([options.signal, signal]) : signal });
1613
+ } });
1614
+ });
1615
+ }
1513
1616
  async function relaySupervise(flags) {
1514
- const policyPath = resolve(required(flags, 'policy'));
1515
- if (!await readDeviceConfig())
1516
- throw new Error('Relay supervision requires an enrolled device.');
1517
- const selectedWorkspace = (await registry()).find((item) => resolve(item.path, '.dharma', 'approved-policy.json') === policyPath);
1518
- if (!selectedWorkspace)
1519
- throw new Error('Relay policy must be the canonical policy of one registered workspace.');
1617
+ const demoOnly = flags.has('demo-only');
1618
+ if (demoOnly && flags.has('policy'))
1619
+ throw new Error('Demo-only supervision cannot select a standard policy.');
1620
+ const policyPath = demoOnly ? null : resolve(required(flags, 'policy'));
1621
+ const selectedWorkspace = policyPath
1622
+ ? (await registry()).find((item) => resolve(item.path, '.dharma', 'approved-policy.json') === policyPath) : null;
1623
+ if (policyPath) {
1624
+ if (!await readDeviceConfig())
1625
+ throw new Error('Relay supervision requires an enrolled device.');
1626
+ if (!selectedWorkspace)
1627
+ throw new Error('Relay policy must be the canonical policy of one registered workspace.');
1628
+ }
1629
+ else if (!(await listDemoWatchRegistrations(dharmaHome())).length) {
1630
+ throw new Error('Relay supervision requires at least one registered Demo repository.');
1631
+ }
1520
1632
  const releaseLease = await acquireRelaySupervisorLease();
1521
1633
  const bindingPath = resolve(dharmaHome(), 'relay', 'supervisor-workspace.json');
1522
1634
  const controller = new AbortController();
1523
1635
  const stop = () => controller.abort();
1524
1636
  process.on('SIGINT', stop);
1525
1637
  process.on('SIGTERM', stop);
1638
+ const services = [];
1639
+ const health = createDemoWatchHealthRecorder({ home: dharmaHome(), pid: process.pid, version: VERSION });
1526
1640
  try {
1527
- await writeJsonAtomic(bindingPath, { pid: process.pid, workspaceId: selectedWorkspace.workspaceId, policyPath });
1528
- const result = await superviseRelay({
1641
+ await writeJsonAtomic(bindingPath, { pid: process.pid, workspaceId: selectedWorkspace?.workspaceId ?? null,
1642
+ policyPath, demoWatches: true, version: VERSION });
1643
+ const demo = runDemoSupervisor({ signal: controller.signal,
1644
+ list: () => listDemoWatchRegistrations(dharmaHome()), cycle: (registration, signal) => {
1645
+ if (!health.available(demoWatchRegistrationKey(registration))) {
1646
+ throw Object.assign(new Error('Demo watch health storage is unavailable.'), { code: 'demo_watch_health_unavailable' });
1647
+ }
1648
+ return supervisedDemoCycle(registration, signal);
1649
+ },
1650
+ onObservation: observation => {
1651
+ health.record(observation);
1652
+ process.stderr.write(`${JSON.stringify({ event: 'demo_watch_cycle', ...observation })}\n`);
1653
+ },
1654
+ });
1655
+ services.push(demo);
1656
+ const standard = policyPath ? superviseRelay({
1529
1657
  signal: controller.signal,
1530
1658
  start: () => spawn(process.execPath, [fileURLToPath(import.meta.url), 'relay', 'start', '--policy', policyPath], {
1531
1659
  cwd: dirname(dirname(policyPath)),
1532
1660
  stdio: 'ignore',
1533
1661
  env: process.env,
1534
1662
  }),
1535
- });
1536
- return { ok: true, stopped: true, ...result };
1663
+ }) : Promise.resolve({ restarts: 0 });
1664
+ services.push(standard);
1665
+ const [demoResult, standardResult] = await Promise.all([demo, standard]);
1666
+ return { ok: true, stopped: true, ...standardResult, demo: demoResult };
1537
1667
  }
1538
1668
  finally {
1669
+ controller.abort();
1670
+ await Promise.allSettled(services);
1671
+ await health.drain();
1539
1672
  process.removeListener('SIGINT', stop);
1540
1673
  process.removeListener('SIGTERM', stop);
1541
1674
  const binding = await readFile(bindingPath, 'utf8')
@@ -1812,11 +1945,11 @@ async function bootstrap(flags) {
1812
1945
  const launcher = await installStableRepositoryLauncher(workspace);
1813
1946
  const autostart = flags.has('no-relay-daemon')
1814
1947
  ? { state: 'disabled', backend: null }
1815
- : await withOnboardingStage('autostart', String(onboarded.workspaceId || ''), `dharma bootstrap --resume --complete --portal-url ${hqUrl} --organization-id ${organizationId} --workspace . --policy-revision ${policyRevision}`, () => enableRelayAutostart({
1948
+ : await withOnboardingStage('autostart', String(onboarded.workspaceId || ''), `dharma bootstrap --resume --complete --portal-url ${hqUrl} --organization-id ${organizationId} --workspace . --policy-revision ${policyRevision}`, () => withRelayStartupMutation(() => enableRelayAutostart({
1816
1949
  home: dharmaHome(), workspace, policy: resolve(workspace, '.dharma', 'approved-policy.json'),
1817
1950
  launcher: resolve(workspace, process.platform === 'win32' ? launcher.windows : launcher.shell),
1818
1951
  version: VERSION,
1819
- }));
1952
+ })));
1820
1953
  let sharedRepositoryReady = onboarded.sharedRepositoryReady === true;
1821
1954
  const completionRequested = flags.has('complete');
1822
1955
  if (!completionRequested) {
@@ -5762,7 +5895,7 @@ export async function run(argv) {
5762
5895
  if (flags.has('version') || command === 'version')
5763
5896
  return { version: VERSION };
5764
5897
  if (command === 'demo' && ['connect', 'status', 'role', 'peers', 'ask', 'reply', 'inbox', 'ack', 'resume',
5765
- 'package', 'package-status', 'watch']
5898
+ 'package', 'package-status', 'watch', 'watch-enable', 'watch-status', 'watch-disable']
5766
5899
  .includes(String(subcommand))) {
5767
5900
  const hqUrl = normalizeHqUrl(portalUrl(flags));
5768
5901
  const organizationId = required(flags, 'organization-id');
@@ -5776,8 +5909,12 @@ export async function run(argv) {
5776
5909
  if (!remote || normalizeGitRemoteIdentity(remote) !== normalizedRepository) {
5777
5910
  throw new Error('Local Git remote does not match the private Demo repository binding.');
5778
5911
  }
5912
+ const watchControl = ['watch-enable', 'watch-status', 'watch-disable'].includes(String(subcommand));
5913
+ if (watchControl && await realpath(root) !== workspace) {
5914
+ throw new Error('Demo watch setup must select the exact Git repository root.');
5915
+ }
5779
5916
  if (flags.has('dry-run'))
5780
- return { ok: true, stage: 'demo_device_plan',
5917
+ return { ok: true, stage: watchControl ? 'demo_watch_plan' : 'demo_device_plan',
5781
5918
  organizationId, repositoryId, normalizedRepository, workspaceVerified: true,
5782
5919
  repositoryPackageState: 'not_connected' };
5783
5920
  const grant = subcommand === 'connect' ? required(flags, 'grant') : null;
@@ -5785,6 +5922,35 @@ export async function run(argv) {
5785
5922
  hqUrl, organizationId, repositoryId, normalizedRepository,
5786
5923
  installationId: await loadOrCreateInstallationId(), stateRoot: dharmaHome(),
5787
5924
  };
5925
+ if (watchControl) {
5926
+ const existing = (await listDemoWatchRegistrations(dharmaHome()))
5927
+ .find(row => row.hqUrl === hqUrl && row.organizationId === organizationId && row.repositoryId === repositoryId);
5928
+ const requestedProvider = String(flags.get('provider') || 'auto').trim().toLowerCase();
5929
+ const provider = requestedProvider !== 'auto' ? requestedProvider : existing?.provider
5930
+ ?? (subcommand === 'watch-enable' ? await detectBootstrapProvider(workspace) : 'codex');
5931
+ if (!isLocalProviderId(provider))
5932
+ throw new Error('Demo watch provider must be auto, codex, claude, agy, or hermes.');
5933
+ const registration = { schema: 'dharma.demo-watch/v1',
5934
+ hqUrl, organizationId, repositoryId, normalizedRepository, provider, workspace };
5935
+ const input = { home: dharmaHome(), registration, version: VERSION };
5936
+ const deps = {
5937
+ verify: async () => { await withDemoDeviceLock(scope, () => verifyDemoDevice(scope)); },
5938
+ exclusive: operation => withRelayStartupMutation(operation),
5939
+ prepareStartup: async () => {
5940
+ const launcher = await installStableRepositoryLauncher(workspace);
5941
+ await enableRelayAutostart({ home: dharmaHome(), workspace, policy: null, version: VERSION,
5942
+ launcher: resolve(workspace, process.platform === 'win32' ? launcher.windows : launcher.shell) });
5943
+ },
5944
+ start: async () => { await startRelayAutostart({ home: dharmaHome() }); },
5945
+ autostart: async () => (await relayAutostartStatus({ home: dharmaHome() })).state,
5946
+ supervisor: () => inspectDemoWatchSupervisor(dharmaHome(), VERSION, async () => {
5947
+ const supervisor = await relaySupervisorProcessState();
5948
+ return supervisor === 'stopped' && await relayProcessState() !== 'stopped' ? 'unknown' : supervisor;
5949
+ }),
5950
+ };
5951
+ return subcommand === 'watch-enable' ? enableDemoWatch(input, deps)
5952
+ : subcommand === 'watch-disable' ? disableDemoWatch(input, deps) : demoWatchStatus(input, deps);
5953
+ }
5788
5954
  if (['package', 'package-status', 'watch'].includes(String(subcommand))) {
5789
5955
  const requestedProvider = String(flags.get('provider') || 'auto').trim().toLowerCase();
5790
5956
  const provider = subcommand === 'package-status' && requestedProvider === 'auto' ? 'codex'
@@ -5821,18 +5987,18 @@ export async function run(argv) {
5821
5987
  }
5822
5988
  }
5823
5989
  return withDemoDeviceLock(scope, async () => {
5824
- if (subcommand === 'connect' || subcommand === 'status') {
5825
- const connected = subcommand === 'status'
5826
- ? await verifyDemoDevice(scope)
5827
- : await connectDemoDevice({
5828
- ...scope, grant: grant,
5829
- deviceName: String(flags.get('device-name') || `${process.env.USER || process.env.USERNAME || 'developer'} device`),
5830
- platform: await platform(),
5831
- }, { onApprovalRequired: async (url) => {
5832
- if (!flags.has('no-browser'))
5833
- await openVerificationUri(url);
5834
- process.stderr.write(`Approve this exact Demo device in your signed-in Dharma session: ${url}\n`);
5835
- } });
5990
+ if (subcommand === 'status')
5991
+ return demoDeviceAndPackageStatus(scope, workspace);
5992
+ if (subcommand === 'connect') {
5993
+ const connected = await connectDemoDevice({
5994
+ ...scope, grant: grant,
5995
+ deviceName: String(flags.get('device-name') || `${process.env.USER || process.env.USERNAME || 'developer'} device`),
5996
+ platform: await platform(),
5997
+ }, { onApprovalRequired: async (url) => {
5998
+ if (!flags.has('no-browser'))
5999
+ await openVerificationUri(url);
6000
+ process.stderr.write(`Approve this exact Demo device in your signed-in Dharma session: ${url}\n`);
6001
+ } });
5836
6002
  const { configPath: _configPath, ...receipt } = connected;
5837
6003
  return receipt;
5838
6004
  }
@@ -5970,7 +6136,7 @@ export async function run(argv) {
5970
6136
  if (positional[2] === 'status')
5971
6137
  return relayAutostartStatus({ home: dharmaHome() });
5972
6138
  if (positional[2] === 'disable')
5973
- return disableRelayAutostart({ home: dharmaHome() });
6139
+ return withRelayStartupMutation(() => disableRelayAutostart({ home: dharmaHome() }));
5974
6140
  throw new Error('Use dharma relay autostart status or dharma relay autostart disable.');
5975
6141
  }
5976
6142
  if (command === 'relay' && subcommand === 'supervise')