@parall/daemon 1.32.1 → 1.34.0

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 (46) hide show
  1. package/bundle/bb-browser-daemon.js +15628 -0
  2. package/bundle/buildDomTree.js +1501 -0
  3. package/bundle/manifest.json +19 -11
  4. package/bundle/parall-claude-agent.js +185 -25
  5. package/bundle/parall-codex-agent.js +185 -25
  6. package/bundle/parall-daemon.js +4647 -2856
  7. package/bundle/parall-openclaw-agent.js +4 -3
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +8 -2
  10. package/dist/clip-runtime/browser-dependency.d.ts +20 -0
  11. package/dist/clip-runtime/browser-dependency.d.ts.map +1 -0
  12. package/dist/clip-runtime/browser-dependency.js +52 -0
  13. package/dist/clip-runtime/browser-profile-manager.d.ts +70 -0
  14. package/dist/clip-runtime/browser-profile-manager.d.ts.map +1 -0
  15. package/dist/clip-runtime/browser-profile-manager.js +608 -0
  16. package/dist/clip-runtime/clip-provider.d.ts +10 -1
  17. package/dist/clip-runtime/clip-provider.d.ts.map +1 -1
  18. package/dist/clip-runtime/clip-provider.js +63 -21
  19. package/dist/clip-runtime/hub-client.d.ts +79 -0
  20. package/dist/clip-runtime/hub-client.d.ts.map +1 -0
  21. package/dist/clip-runtime/hub-client.js +320 -0
  22. package/dist/clip-runtime/index.d.ts +2 -0
  23. package/dist/clip-runtime/index.d.ts.map +1 -1
  24. package/dist/clip-runtime/index.js +2 -0
  25. package/dist/clip-runtime/ipc.d.ts +6 -0
  26. package/dist/clip-runtime/ipc.d.ts.map +1 -1
  27. package/dist/clip-runtime/manifest.d.ts +16 -8
  28. package/dist/clip-runtime/manifest.d.ts.map +1 -1
  29. package/dist/clip-runtime/manifest.js +13 -0
  30. package/dist/clip-runtime/process-manager.d.ts +55 -3
  31. package/dist/clip-runtime/process-manager.d.ts.map +1 -1
  32. package/dist/clip-runtime/process-manager.js +226 -48
  33. package/dist/clip-runtime/process.d.ts +15 -1
  34. package/dist/clip-runtime/process.d.ts.map +1 -1
  35. package/dist/clip-runtime/process.js +73 -10
  36. package/dist/config.d.ts +9 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/config.js +12 -0
  39. package/dist/index.js +46 -5
  40. package/dist/runtime-bin-resolver.d.ts +7 -0
  41. package/dist/runtime-bin-resolver.d.ts.map +1 -0
  42. package/dist/runtime-bin-resolver.js +292 -0
  43. package/dist/supervisor.d.ts +53 -4
  44. package/dist/supervisor.d.ts.map +1 -1
  45. package/dist/supervisor.js +449 -117
  46. package/package.json +7 -6
@@ -3,18 +3,21 @@ import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import { effectiveLLMSourceExplicit } from '@parall/agent-core';
5
5
  import { ParallWs, } from '@parall/sdk';
6
- import { listDirectory } from './filesystem.js';
6
+ import { installClip, parseSource } from './clip-runtime/clip-installer.js';
7
+ import { parseIpcCommands } from './clip-runtime/manifest.js';
8
+ import { BrowserProfileManager, ClipProcessManager, ClipProvider, HubClient, } from './clip-runtime/index.js';
7
9
  import { agentClaudeCredentialsFileFor, agentClaudeHomeFor, agentStateDirFor, agentWorkspaceDirFor, sharedClaudeCredentialsFileFor, } from './config.js';
10
+ import { listDirectory } from './filesystem.js';
8
11
  import { assertAgentKey, getRuntimeAdapter } from './runtimes.js';
12
+ import { applyRuntimeBinaryEnv } from './runtime-bin-resolver.js';
9
13
  import { prepareWorkspace } from './workspace.js';
10
- import { ClipProcessManager, ClipProvider } from './clip-runtime/index.js';
11
- import { installClip, parseSource } from './clip-runtime/clip-installer.js';
12
14
  const RUNTIME_PACKAGES = {
13
15
  'claude-code': '@parall/claude-agent',
14
16
  codex: '@parall/codex-agent',
15
17
  openclaw: '@parall/openclaw-agent',
16
18
  };
17
19
  const WORKSPACE_SETUP_RETRY_DELAY_MS = 5_000;
20
+ const CLIP_RECONCILE_INTERVAL_MS = 5 * 60_000;
18
21
  /**
19
22
  * Sleep that wakes early on abort. Returns true if the full delay elapsed,
20
23
  * false if aborted. Used by bootstrap retry and the outer keepalive in
@@ -54,6 +57,7 @@ export class DaemonSupervisor {
54
57
  children = new Map();
55
58
  spawningAgents = new Set();
56
59
  pendingWorkspaceSetup = new Set();
60
+ browserProfileLifecycleQueues = new Map();
57
61
  workspaceSetupRetryTimers = new Map();
58
62
  cancelledSpawns = new Set();
59
63
  ws = null;
@@ -61,11 +65,29 @@ export class DaemonSupervisor {
61
65
  machineId = null;
62
66
  machineOrgId = null;
63
67
  machineLlmSource = 'parall';
68
+ // Whether this machine contributes its local clips as a hub provider. The
69
+ // org admin toggles it via PATCH /machines/{id}/provider-enabled; default true.
70
+ machineProviderEnabled = true;
71
+ // Externally-reachable ProviderStream endpoint delivered by the server via
72
+ // GET /machines/me (grey-cloud clip-rpc host). Null for hosted machines or
73
+ // pre-field servers; applyClipProviderState falls back to config.apiUrl.
74
+ machineClipProviderUrl = null;
75
+ // The serviceUrl the live clipProvider is currently connected to — lets
76
+ // applyClipProviderState detect an endpoint change and reconnect.
77
+ connectedClipServiceUrl = null;
64
78
  stopResolve = null;
65
79
  updater = null;
66
80
  healthConfirmed = false;
81
+ browserProfileManager = null;
67
82
  clipManager = null;
68
83
  clipProvider = null;
84
+ clipReconcileTimer = null;
85
+ clipReconcileInFlight = null;
86
+ // Execution-side hub client for cross-machine dependency resolution
87
+ // (GetBindings + Invoke). Cached by resolved endpoint so a clip_provider_url
88
+ // rollout transparently rebuilds it against the new grey-cloud host.
89
+ hubClient = null;
90
+ hubClientUrl = null;
69
91
  constructor(config, client, log) {
70
92
  this.config = config;
71
93
  this.client = client;
@@ -98,39 +120,41 @@ export class DaemonSupervisor {
98
120
  this.migrateFlatLayout();
99
121
  // Initialize Clip runtime manager (optional, for Pinix Clip subprocess management)
100
122
  if (process.env.PRLL_CLIP_RUNTIME_ENABLED === 'true') {
123
+ this.browserProfileManager = new BrowserProfileManager({
124
+ homeDir: path.join(this.config.rootStateDir, 'bb-browser'),
125
+ log: this.log,
126
+ reportStatus: (profileId, status, errorMsg) => {
127
+ this.client
128
+ .reportBrowserProfileStatus(profileId, status, errorMsg)
129
+ .catch((err) => this.log.warn(`browser profile status report failed: ${String(err)}`));
130
+ },
131
+ });
101
132
  this.clipManager = new ClipProcessManager({
102
133
  clipsDir: path.join(this.config.rootStateDir, 'clips'),
103
134
  dataDir: path.join(this.config.rootStateDir, 'clip-data'),
135
+ browserProfileManager: this.browserProfileManager,
136
+ // Execution side: nested browser dependency invokes resolve their
137
+ // binding and route through the hub (no local shortcut).
138
+ hubClient: () => this.getHubClient(),
139
+ ensureInstalled: (config) => this.ensureClipInstalled(config),
104
140
  });
105
- try {
106
- await this.clipManager.loadInstalledClips();
107
- }
108
- catch (err) {
109
- this.log.warn(`clip runtime init failed: ${String(err)}`);
110
- }
111
- // Connect to Clip Service as a Provider when URL is configured
112
- const clipServiceUrl = process.env.PRLL_CLIP_SERVICE_URL?.trim();
113
- if (clipServiceUrl && this.machineOrgId && this.machineId) {
114
- this.clipProvider = new ClipProvider({
115
- serviceUrl: clipServiceUrl,
116
- authKey: this.config.apiKey,
117
- orgId: this.machineOrgId,
118
- providerName: `daemon-${this.machineId}`,
119
- clipManager: this.clipManager,
120
- log: this.log,
121
- });
122
- this.clipProvider
123
- .connect()
124
- .catch((err) => this.log.warn(`clip provider connect failed: ${String(err)}`));
125
- }
126
- }
127
- // Report daemon version via heartbeat (best-effort)
128
- const daemonVersion = this.updater?.getLocalVersion();
129
- if (daemonVersion) {
130
- this.client
131
- .postMachineHeartbeat(daemonVersion)
132
- .catch((err) => this.log.warn(`daemon version report failed: ${String(err)}`));
141
+ await this.reconcileMachineClips();
142
+ // Register as a hub Provider unless the machine opted out
143
+ // (provider_enabled=false). See applyClipProviderState.
144
+ await this.applyClipProviderState();
145
+ this.startClipReconcileTimer();
133
146
  }
147
+ // Report daemon version + self-update capability via heartbeat (best-effort).
148
+ // Capability tracks whether a service manager supervises us: the updater is
149
+ // created only in that case (see isSelfUpdateManaged in index.ts). A bare
150
+ // `npx` foreground run reports false so the server/UI won't offer a manual
151
+ // update it would silently ignore.
152
+ this.client
153
+ .postMachineHeartbeat({
154
+ daemonVersion: this.updater?.getLocalVersion(),
155
+ selfUpdateCapable: this.updater != null,
156
+ })
157
+ .catch((err) => this.log.warn(`daemon heartbeat report failed: ${String(err)}`));
134
158
  await this.fullReconcile();
135
159
  this.ws = new ParallWs({
136
160
  getTicket: () => this.client.getMachineWsTicket(),
@@ -185,6 +209,12 @@ export class DaemonSupervisor {
185
209
  this.machineLlmSource = newSource;
186
210
  void this.respawnAllChildren();
187
211
  }
212
+ if (data.provider_enabled !== undefined &&
213
+ data.provider_enabled !== this.machineProviderEnabled) {
214
+ this.log.info(`WS: provider_enabled changed ${this.machineProviderEnabled} → ${data.provider_enabled}`);
215
+ this.machineProviderEnabled = data.provider_enabled;
216
+ void this.applyClipProviderState();
217
+ }
188
218
  });
189
219
  this.ws.on('machine.workspace.setup.requested', (data) => {
190
220
  this.log.info(`WS: workspace setup requested for agent ${data.agent_id}`);
@@ -198,9 +228,13 @@ export class DaemonSupervisor {
198
228
  this.log.info(`WS: machine.stop received (reason=${data.reason ?? 'none'})`);
199
229
  void this.stop();
200
230
  });
201
- this.ws.on('machine.clip.install', (data) => {
202
- this.log.info(`WS: clip install requested — alias=${data.alias} source=${data.source_ref}`);
203
- void this.handleClipInstall(data);
231
+ this.ws.on('machine.clip.sync', (data) => {
232
+ this.log.info(`WS: clip sync requested — clip=${data.clip_id} action=${data.action ?? ''}`);
233
+ void this.handleClipSync(data);
234
+ });
235
+ this.ws.on('machine.browser_profile.lifecycle', (data) => {
236
+ this.log.info(`WS: browser profile lifecycle requested — profile=${data.profile_id} action=${data.action}`);
237
+ void this.enqueueBrowserProfileLifecycle(data);
204
238
  });
205
239
  this.ws.onStateChange((state) => {
206
240
  if (state === 'disconnected' || state === 'reconnecting') {
@@ -227,6 +261,7 @@ export class DaemonSupervisor {
227
261
  clearTimeout(timer);
228
262
  }
229
263
  this.workspaceSetupRetryTimers.clear();
264
+ this.stopClipReconcileTimer();
230
265
  for (const state of this.children.values()) {
231
266
  state.shuttingDown = true;
232
267
  if (state.restartTimer) {
@@ -243,6 +278,10 @@ export class DaemonSupervisor {
243
278
  exits.push(this.clipManager.stopAll());
244
279
  this.clipManager = null;
245
280
  }
281
+ if (this.browserProfileManager) {
282
+ exits.push(this.browserProfileManager.stop());
283
+ this.browserProfileManager = null;
284
+ }
246
285
  await Promise.allSettled(exits);
247
286
  this.children.clear();
248
287
  this.log.info('daemon supervisor stopped');
@@ -260,6 +299,8 @@ export class DaemonSupervisor {
260
299
  this.machineId = machine.id;
261
300
  this.machineOrgId = machine.org_id;
262
301
  this.machineLlmSource = machine.llm_source ?? 'parall';
302
+ this.machineProviderEnabled = machine.provider_enabled ?? true;
303
+ this.machineClipProviderUrl = machine.clip_provider_url ?? null;
263
304
  this.log.info(`daemon online — machine_id=${machine.id} org=${machine.org_id} label=${machine.label}`);
264
305
  return true;
265
306
  }
@@ -268,7 +309,7 @@ export class DaemonSupervisor {
268
309
  this.log.error(`getMachineSelf failed: ${String(err)} (fail-fast mode)`);
269
310
  throw err;
270
311
  }
271
- const delay = Math.min(this.config.bootstrapBackoffMs * Math.pow(2, attempt), this.config.bootstrapBackoffMaxMs);
312
+ const delay = Math.min(this.config.bootstrapBackoffMs * 2 ** attempt, this.config.bootstrapBackoffMaxMs);
272
313
  attempt += 1;
273
314
  this.log.warn(`getMachineSelf failed (attempt ${attempt}): ${String(err)} — retrying in ${delay}ms`);
274
315
  const slept = await sleepCancellable(delay, signal);
@@ -331,9 +372,41 @@ export class DaemonSupervisor {
331
372
  this.children.delete(userId);
332
373
  }
333
374
  }
334
- // Reconcile desired clip installs alongside agents (best-effort; never
335
- // blocks agent reconcile).
336
- await this.reconcileClipInstalls();
375
+ // Reconcile executable Clip definitions alongside agents (best-effort;
376
+ // local package install remains lazy at invoke time).
377
+ await this.reconcileMachineClips();
378
+ await this.reconcileBrowserProfiles();
379
+ }
380
+ async reconcileBrowserProfiles() {
381
+ if (!this.browserProfileManager)
382
+ return;
383
+ let profiles;
384
+ try {
385
+ profiles = await this.client.listMachineBrowserProfiles();
386
+ }
387
+ catch (err) {
388
+ this.log.warn(`browser profile reconcile: list failed: ${String(err)}`);
389
+ return;
390
+ }
391
+ for (const profile of profiles) {
392
+ if (profile.status !== 'running' && profile.status !== 'pending')
393
+ continue;
394
+ try {
395
+ if (profile.status === 'pending') {
396
+ await this.enqueueBrowserProfileLifecycle({
397
+ machine_id: profile.machine_id,
398
+ profile_id: profile.id,
399
+ action: 'open',
400
+ });
401
+ }
402
+ else {
403
+ await this.browserProfileManager.ensureRuntime(profile.id);
404
+ }
405
+ }
406
+ catch (err) {
407
+ this.log.warn(`browser profile reconcile failed for ${profile.id}: ${String(err)}`);
408
+ }
409
+ }
337
410
  }
338
411
  // ---- Flat layout migration (self-hosted → daemon) ----
339
412
  /**
@@ -461,96 +534,256 @@ export class DaemonSupervisor {
461
534
  }
462
535
  }
463
536
  }
464
- async handleClipInstall(data) {
465
- if (!this.clipManager) {
466
- this.log.warn('clip install event received but clip runtime is disabled');
467
- return false;
537
+ async handleBrowserProfileLifecycle(data) {
538
+ if (!this.browserProfileManager) {
539
+ this.log.warn('browser profile lifecycle event received but clip runtime is disabled');
540
+ const status = data.action === 'stop' ? 'stopped' : 'error';
541
+ const error = data.action === 'stop' ? undefined : 'Browser profile runtime is disabled on this daemon';
542
+ await this.client.reportBrowserProfileStatus(data.profile_id, status, error).catch((err) => {
543
+ this.log.warn(`browser profile lifecycle negative ACK failed for ${data.profile_id}: ${String(err)}`);
544
+ });
545
+ return;
468
546
  }
469
547
  try {
470
- // Idempotency guard: if the clip is already installed and running
471
- // locally, a prior install succeeded but its server ACK
472
- // (reportClipInstall) may have failed — the desired-state row is still
473
- // pending, so reconcile re-sent the same directive. Don't re-download or
474
- // restart a healthy clip; just retry the report so pending → installed.
475
- if (this.clipManager.isRunning(data.alias)) {
476
- this.log.info(`clip already running, re-reporting install: ${data.alias}`);
477
- await this.client.reportClipInstall(data.clip_id, data.version).catch((err) => {
478
- this.log.warn(`clip install re-report failed for ${data.alias}: ${String(err)}`);
479
- });
480
- return true;
548
+ switch (data.action) {
549
+ case 'open':
550
+ await this.browserProfileManager.openProfile(data.profile_id, data.start_url);
551
+ return;
552
+ case 'stop':
553
+ await this.browserProfileManager.stopProfile(data.profile_id);
554
+ return;
555
+ case 'reset':
556
+ await this.browserProfileManager.resetProfile(data.profile_id);
557
+ return;
558
+ default:
559
+ this.log.warn(`unknown browser profile lifecycle action: ${data.action}`);
481
560
  }
482
- const clipsDir = path.join(this.config.rootStateDir, 'clips');
483
- // The clip record stores a resolved version; pin the install to it when
484
- // the source ref is unversioned, otherwise an unversioned source like
485
- // "@pinix/github-tools" would silently fetch latest instead.
486
- let source = data.source_ref;
487
- if (data.version) {
488
- let sourceHasVersion = false;
489
- try {
490
- sourceHasVersion = !!parseSource(source).version;
491
- }
492
- catch {
493
- /* fall through to append */
494
- }
495
- if (!sourceHasVersion)
496
- source = `${source}@${data.version}`;
497
- }
498
- const result = await installClip({
499
- source,
500
- alias: data.alias,
501
- clipsDir,
502
- // Pinix registry REST base; falls back to the installer default when unset.
503
- registryUrl: process.env.PRLL_PINIX_REGISTRY_URL?.trim() || undefined,
504
- });
505
- this.log.info(`clip installed: ${result.alias} v${result.version} at ${result.path}`);
506
- this.clipManager.registerClip({
507
- name: result.alias,
508
- package: result.name,
509
- version: result.version,
510
- source: result.path,
511
- path: result.path,
512
- });
513
- await this.clipManager.startClip(result.alias);
514
- this.log.info(`clip started: ${result.alias}`);
515
- // Report success so the server flips the desired-state row pending →
516
- // installed. Reporting here (not in the caller) covers both the live WS
517
- // broadcast and the durable reconcile paths with one report.
518
- await this.client.reportClipInstall(data.clip_id, data.version).catch((err) => {
519
- this.log.warn(`clip install report failed for ${data.alias}: ${String(err)}`);
520
- });
521
- return true;
522
561
  }
523
562
  catch (err) {
524
- this.log.error(`clip install failed: ${String(err)}`);
525
- return false;
526
- }
527
- }
528
- // Durable reconcile: pull the registry clips this machine should have (covers
529
- // installs broadcast while the daemon was offline, and clips targeted at
530
- // machines that joined later), install the missing ones, and report success
531
- // so the server flips the desired-state row pending installed. Mirrors the
532
- // listAttachedAgents reconcile pattern; runs on every fullReconcile.
533
- async reconcileClipInstalls() {
534
- if (!this.clipManager)
535
- return; // clip runtime disabled — nothing to reconcile
563
+ this.log.warn(`browser profile lifecycle failed (profile=${data.profile_id}, action=${data.action}): ${String(err)}`);
564
+ }
565
+ }
566
+ enqueueBrowserProfileLifecycle(data) {
567
+ const previous = this.browserProfileLifecycleQueues.get(data.profile_id) ?? Promise.resolve();
568
+ const next = previous
569
+ .catch(() => {
570
+ // Keep the queue moving even if a prior operation failed unexpectedly.
571
+ })
572
+ .then(() => this.handleBrowserProfileLifecycle(data));
573
+ this.browserProfileLifecycleQueues.set(data.profile_id, next);
574
+ void next.finally(() => {
575
+ if (this.browserProfileLifecycleQueues.get(data.profile_id) === next) {
576
+ this.browserProfileLifecycleQueues.delete(data.profile_id);
577
+ }
578
+ });
579
+ return next;
580
+ }
581
+ async handleClipSync(_data) {
582
+ await this.reconcileMachineClips();
583
+ }
584
+ async reconcileMachineClips() {
585
+ if (this.clipReconcileInFlight) {
586
+ return this.clipReconcileInFlight;
587
+ }
588
+ const run = this.reconcileMachineClipsNow();
589
+ this.clipReconcileInFlight = run;
590
+ try {
591
+ await run;
592
+ }
593
+ finally {
594
+ if (this.clipReconcileInFlight === run) {
595
+ this.clipReconcileInFlight = null;
596
+ }
597
+ }
598
+ }
599
+ async reconcileMachineClipsNow() {
600
+ if (!this.running) {
601
+ return;
602
+ }
603
+ const clipManager = this.clipManager;
604
+ if (!clipManager)
605
+ return;
536
606
  let desired;
537
607
  try {
538
- desired = await this.client.listClipInstalls();
608
+ desired = await this.client.listMachineClips();
539
609
  }
540
610
  catch (err) {
541
- this.log.warn(`reconcileClipInstalls: list failed: ${String(err)}`);
611
+ this.log.warn(`reconcileMachineClips: list failed: ${String(err)}`);
542
612
  return;
543
613
  }
544
- for (const d of desired) {
545
- // handleClipInstall reports success to the server on its own, so the
546
- // reconcile loop just drives the install for each desired clip.
547
- await this.handleClipInstall({
548
- clip_id: d.clip_id,
549
- alias: d.alias,
550
- source_ref: d.source_ref ?? '',
551
- version: d.version,
614
+ if (!this.running)
615
+ return;
616
+ const desiredByAlias = new Map(desired.map((clip) => [clip.alias, clip]));
617
+ const registeredByName = new Map(clipManager.getRegisteredClips().map((c) => [c.name, c]));
618
+ let changed = false;
619
+ for (const registered of clipManager.getRegisteredClips()) {
620
+ if (desiredByAlias.has(registered.name))
621
+ continue;
622
+ await clipManager.unregisterClip(registered.name);
623
+ registeredByName.delete(registered.name);
624
+ changed = true;
625
+ }
626
+ for (const clip of desired) {
627
+ const config = this.machineClipToConfig(clip);
628
+ const existing = registeredByName.get(config.name);
629
+ if (!existing) {
630
+ clipManager.registerClip(config);
631
+ registeredByName.set(config.name, config);
632
+ changed = true;
633
+ }
634
+ else if (this.clipConfigSignature(existing) !== this.clipConfigSignature(config)) {
635
+ await clipManager.replaceClipConfig(config);
636
+ registeredByName.set(config.name, config);
637
+ changed = true;
638
+ }
639
+ }
640
+ if (changed) {
641
+ this.log.info(`machine clips synced — count=${desired.length}`);
642
+ await this.reconnectClipProvider();
643
+ }
644
+ }
645
+ startClipReconcileTimer() {
646
+ if (this.clipReconcileTimer || !this.clipManager)
647
+ return;
648
+ this.clipReconcileTimer = setInterval(() => {
649
+ void this.reconcileMachineClips().catch((err) => {
650
+ this.log.warn(`periodic clip reconcile failed: ${String(err)}`);
552
651
  });
652
+ }, CLIP_RECONCILE_INTERVAL_MS);
653
+ this.clipReconcileTimer.unref?.();
654
+ }
655
+ stopClipReconcileTimer() {
656
+ if (!this.clipReconcileTimer)
657
+ return;
658
+ clearInterval(this.clipReconcileTimer);
659
+ this.clipReconcileTimer = null;
660
+ }
661
+ machineClipToConfig(clip) {
662
+ const clipPath = path.join(this.config.rootStateDir, 'clips', clip.alias);
663
+ return {
664
+ clipId: clip.clip_id,
665
+ name: clip.alias,
666
+ package: clip.name,
667
+ version: clip.version,
668
+ source: clip.source_ref ?? clip.name,
669
+ sourceType: clip.source_type,
670
+ sourceRef: clip.source_ref,
671
+ path: clipPath,
672
+ manifest: this.manifestFromMachineClip(clip),
673
+ };
674
+ }
675
+ manifestFromMachineClip(clip) {
676
+ const raw = clip.manifest ?? {};
677
+ const commands = parseIpcCommands(raw.commands ?? raw.commandDetails ?? raw.command_details ?? raw.tools);
678
+ return {
679
+ name: clip.alias,
680
+ package: String(raw.package ?? raw.name ?? clip.name),
681
+ version: String(raw.version ?? clip.version ?? ''),
682
+ domain: typeof raw.domain === 'string' ? raw.domain : undefined,
683
+ description: typeof raw.description === 'string' ? raw.description : (clip.description ?? undefined),
684
+ commands: commands.map((c) => c.name),
685
+ commandDetails: commands,
686
+ hasWeb: Boolean(raw.hasWeb ?? raw.has_web ?? false),
687
+ dependencies: this.dependencyMap(raw.dependencies),
688
+ dependencySlots: this.dependencyMap(raw.dependencySlots ?? raw.dependency_slots),
689
+ patterns: Array.isArray(raw.patterns) ? raw.patterns.map(String) : undefined,
690
+ entities: raw.entities && typeof raw.entities === 'object'
691
+ ? raw.entities
692
+ : undefined,
693
+ };
694
+ }
695
+ dependencyMap(value) {
696
+ if (!value || typeof value !== 'object' || Array.isArray(value))
697
+ return undefined;
698
+ const out = {};
699
+ for (const [key, raw] of Object.entries(value)) {
700
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
701
+ continue;
702
+ const item = raw;
703
+ out[key] = {
704
+ package: typeof item.package === 'string' ? item.package : undefined,
705
+ version: typeof item.version === 'string' ? item.version : undefined,
706
+ };
707
+ }
708
+ return out;
709
+ }
710
+ clipConfigSignature(config) {
711
+ return JSON.stringify({
712
+ clipId: config.clipId,
713
+ name: config.name,
714
+ package: config.package,
715
+ version: config.version,
716
+ sourceType: config.sourceType,
717
+ sourceRef: config.sourceRef,
718
+ path: config.path,
719
+ manifest: config.manifest,
720
+ });
721
+ }
722
+ async ensureClipInstalled(config) {
723
+ if (config.sourceType !== 'registry') {
724
+ return config;
725
+ }
726
+ const sourceRef = config.sourceRef?.trim();
727
+ if (!sourceRef) {
728
+ throw new Error(`registry clip "${config.name}" is missing source_ref`);
729
+ }
730
+ const expectedPath = path.join(this.config.rootStateDir, 'clips', config.name);
731
+ const localVersion = this.readInstalledClipVersion(expectedPath);
732
+ if (localVersion && (!config.version || localVersion === config.version)) {
733
+ return { ...config, path: expectedPath, source: expectedPath };
734
+ }
735
+ let source = sourceRef;
736
+ if (config.version) {
737
+ let sourceHasVersion = false;
738
+ try {
739
+ sourceHasVersion = !!parseSource(source).version;
740
+ }
741
+ catch {
742
+ /* fall through to append */
743
+ }
744
+ if (!sourceHasVersion)
745
+ source = `${source}@${config.version}`;
553
746
  }
747
+ const result = await installClip({
748
+ source,
749
+ alias: config.name,
750
+ clipsDir: path.join(this.config.rootStateDir, 'clips'),
751
+ registryUrl: process.env.PRLL_PINIX_REGISTRY_URL?.trim() || undefined,
752
+ });
753
+ this.log.info(`clip ensured: ${result.alias} v${result.version} at ${result.path}`);
754
+ return {
755
+ ...config,
756
+ package: result.name,
757
+ version: result.version,
758
+ source: result.path,
759
+ path: result.path,
760
+ };
761
+ }
762
+ readInstalledClipVersion(dir) {
763
+ for (const file of ['clip.json', 'package.json']) {
764
+ try {
765
+ const raw = fs.readFileSync(path.join(dir, file), 'utf-8');
766
+ const parsed = JSON.parse(raw);
767
+ if (typeof parsed.version === 'string' && parsed.version.trim()) {
768
+ return parsed.version.trim();
769
+ }
770
+ }
771
+ catch {
772
+ // try the next metadata file
773
+ }
774
+ }
775
+ return null;
776
+ }
777
+ async reconnectClipProvider() {
778
+ if (!this.clipProvider)
779
+ return;
780
+ const provider = this.clipProvider;
781
+ this.clipProvider = null;
782
+ this.connectedClipServiceUrl = null;
783
+ await provider
784
+ .disconnect()
785
+ .catch((err) => this.log.warn(`clip provider reconnect disconnect failed: ${String(err)}`));
786
+ await this.applyClipProviderState();
554
787
  }
555
788
  async handleWorkspaceSetupRequested(agentId) {
556
789
  if (this.spawningAgents.has(agentId)) {
@@ -617,11 +850,109 @@ export class DaemonSupervisor {
617
850
  this.machineLlmSource = newSource;
618
851
  await this.respawnAllChildren();
619
852
  }
853
+ const newProviderEnabled = machine.provider_enabled ?? true;
854
+ const newClipProviderUrl = machine.clip_provider_url ?? null;
855
+ const providerEnabledChanged = newProviderEnabled !== this.machineProviderEnabled;
856
+ const clipProviderUrlChanged = newClipProviderUrl !== this.machineClipProviderUrl;
857
+ if (providerEnabledChanged) {
858
+ this.log.info(`machine config refreshed: provider_enabled ${this.machineProviderEnabled} → ${newProviderEnabled}`);
859
+ this.machineProviderEnabled = newProviderEnabled;
860
+ }
861
+ if (clipProviderUrlChanged) {
862
+ this.log.info(`machine config refreshed: clip_provider_url ${this.machineClipProviderUrl ?? '(none)'} → ${newClipProviderUrl ?? '(none)'}`);
863
+ this.machineClipProviderUrl = newClipProviderUrl;
864
+ }
865
+ if (providerEnabledChanged || clipProviderUrlChanged) {
866
+ await this.applyClipProviderState();
867
+ }
620
868
  }
621
869
  catch (err) {
622
870
  this.log.warn(`refreshMachineConfig failed: ${String(err)}`);
623
871
  }
624
872
  }
873
+ /**
874
+ * Resolve the ProviderStream endpoint the daemon registers clips against.
875
+ * Priority: PRLL_CLIP_SERVICE_URL (hosted/cluster DNS or explicit operator
876
+ * override) > machine.clip_provider_url (server-delivered grey-cloud
877
+ * clip-rpc host for BYOC) > config.apiUrl (legacy fallback for servers
878
+ * predating clip_provider_url). The grey-cloud host bypasses Cloudflare's
879
+ * proxy, which does not pass Connect-RPC bidi streams (#1430).
880
+ */
881
+ resolveClipServiceUrl() {
882
+ return (process.env.PRLL_CLIP_SERVICE_URL?.trim() ||
883
+ this.machineClipProviderUrl?.trim() ||
884
+ this.config.apiUrl);
885
+ }
886
+ /**
887
+ * Execution-side hub client used to resolve a Clip's dependency
888
+ * binding (GetBindings) and forward the dependency invoke (Invoke). It targets
889
+ * the same grey-cloud clip-rpc endpoint as the provider stream and auths with
890
+ * the daemon's mck_ (the hub scope-gates calls to instances this machine
891
+ * executes). Rebuilt transparently when the resolved endpoint changes.
892
+ */
893
+ getHubClient() {
894
+ const serviceUrl = this.resolveClipServiceUrl();
895
+ if (!serviceUrl)
896
+ return null;
897
+ if (this.hubClient && this.hubClientUrl === serviceUrl)
898
+ return this.hubClient;
899
+ this.hubClient = new HubClient({ serviceUrl, authKey: this.config.apiKey });
900
+ this.hubClientUrl = serviceUrl;
901
+ return this.hubClient;
902
+ }
903
+ /**
904
+ * Connect, reconnect, or disconnect the clip Provider to match
905
+ * machineProviderEnabled and the resolved ProviderStream endpoint. An org
906
+ * admin toggles provider_enabled to withdraw the machine from the org clip
907
+ * provider pool without uninstalling clips; a changed clip_provider_url
908
+ * triggers a transparent reconnect to the new endpoint. No-op when the clip
909
+ * runtime is disabled or identity is not yet resolved. See
910
+ * docs/engineering-design/clip-provider-sharing-design.md.
911
+ */
912
+ async applyClipProviderState() {
913
+ if (!this.clipManager || !this.machineOrgId || !this.machineId)
914
+ return;
915
+ if (this.machineProviderEnabled) {
916
+ const serviceUrl = this.resolveClipServiceUrl();
917
+ if (this.clipProvider) {
918
+ if (this.connectedClipServiceUrl === serviceUrl)
919
+ return; // already on the right endpoint
920
+ // Endpoint changed → tear down the stale stream before reconnecting.
921
+ this.log.info(`clip provider endpoint changed → reconnecting (${serviceUrl})`);
922
+ const stale = this.clipProvider;
923
+ this.clipProvider = null;
924
+ await stale
925
+ .disconnect()
926
+ .catch((err) => this.log.warn(`clip provider disconnect (endpoint change) failed: ${String(err)}`));
927
+ }
928
+ this.connectedClipServiceUrl = serviceUrl;
929
+ this.clipProvider = new ClipProvider({
930
+ serviceUrl,
931
+ authKey: this.config.apiKey,
932
+ orgId: this.machineOrgId,
933
+ providerName: `daemon-${this.machineId}`,
934
+ clipManager: this.clipManager,
935
+ log: this.log,
936
+ // Self-heal: if the provider can't reach its endpoint for a while, the
937
+ // server may have rolled out a (new) clip_provider_url after we booted
938
+ // (our ws-gateway WS never bounced to re-trigger machine.hello). Refetch
939
+ // machine config; applyClipProviderState rebuilds us if the URL moved.
940
+ onPersistentFailure: () => void this.refreshMachineConfig(),
941
+ });
942
+ this.clipProvider
943
+ .connect()
944
+ .catch((err) => this.log.warn(`clip provider connect failed: ${String(err)}`));
945
+ }
946
+ else if (this.clipProvider) {
947
+ this.log.info('clip provider disabled (provider_enabled=false) — disconnecting');
948
+ const provider = this.clipProvider;
949
+ this.clipProvider = null;
950
+ this.connectedClipServiceUrl = null;
951
+ await provider
952
+ .disconnect()
953
+ .catch((err) => this.log.warn(`clip provider disconnect failed: ${String(err)}`));
954
+ }
955
+ }
625
956
  async respawnAllChildren() {
626
957
  const states = [...this.children.values()];
627
958
  for (const state of states) {
@@ -744,9 +1075,10 @@ export class DaemonSupervisor {
744
1075
  workspaceDir: state.workspacePath,
745
1076
  claudeHome: state.claudeHome,
746
1077
  };
747
- const baseEnv = { ...process.env, PRLL_API_URL: this.config.apiUrl };
1078
+ let baseEnv = { ...process.env, PRLL_API_URL: this.config.apiUrl };
748
1079
  if (this.machineId)
749
1080
  baseEnv.PRLL_MACHINE_ID = this.machineId;
1081
+ baseEnv = applyRuntimeBinaryEnv(state.runtimeType, baseEnv, this.log);
750
1082
  // An explicit per-agent provider_config (llm_source or BYO creds) wins;
751
1083
  // otherwise defer to the CURRENT machine-level llm_source. The server sends
752
1084
  // an empty `{}` for self-hosted agents (per-agent provider_config is
@@ -783,7 +1115,7 @@ export class DaemonSupervisor {
783
1115
  }
784
1116
  if (wasShutting || !this.running)
785
1117
  return;
786
- const delay = Math.min(this.config.restartBackoffMs * Math.pow(2, state.restartAttempts), this.config.restartBackoffMaxMs);
1118
+ const delay = Math.min(this.config.restartBackoffMs * 2 ** state.restartAttempts, this.config.restartBackoffMaxMs);
787
1119
  state.restartAttempts += 1;
788
1120
  this.log.warn(`agent ${state.agentId} will restart in ${delay}ms`);
789
1121
  state.restartTimer = setTimeout(() => {