@parall/daemon 1.34.0 → 1.36.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.
@@ -27118,6 +27118,7 @@ var ENDPOINTS = {
27118
27118
  // Org-scoped
27119
27119
  ORG: (orgId) => `${API_BASE}/orgs/${orgId}`,
27120
27120
  ORG_MEMBERS: (orgId) => `${API_BASE}/orgs/${orgId}/members`,
27121
+ TEAMS: (orgId) => `${API_BASE}/orgs/${orgId}/teams`,
27121
27122
  ORG_MEMBERS_ONLINE: (orgId) => `${API_BASE}/orgs/${orgId}/members/online`,
27122
27123
  ORG_MEMBER: (orgId, userId) => `${API_BASE}/orgs/${orgId}/members/${userId}`,
27123
27124
  ORG_MEMBER_CHATS: (orgId, memberId) => `${API_BASE}/orgs/${orgId}/members/${memberId}/chats`,
@@ -27162,6 +27163,7 @@ var ENDPOINTS = {
27162
27163
  AGENT: (orgId, agentId) => `${API_BASE}/orgs/${orgId}/agents/${agentId}`,
27163
27164
  AGENT_API_KEYS: (orgId, agentId) => `${API_BASE}/orgs/${orgId}/agents/${agentId}/api-keys`,
27164
27165
  AGENT_API_KEY: (orgId, agentId, key) => `${API_BASE}/orgs/${orgId}/agents/${agentId}/api-keys/${key}`,
27166
+ AGENT_API_KEY_REGENERATE: (orgId, agentId) => `${API_BASE}/orgs/${orgId}/agents/${agentId}/api-keys/regenerate`,
27165
27167
  AGENT_AVATAR: (orgId, agentId) => `${API_BASE}/orgs/${orgId}/agents/${agentId}/avatar`,
27166
27168
  AGENT_ACTIVITY: (orgId, agentId) => `${API_BASE}/orgs/${orgId}/agents/${agentId}/activity`,
27167
27169
  AGENT_MONITOR: (orgId, agentId) => `${API_BASE}/orgs/${orgId}/agents/${agentId}/monitor`,
@@ -27201,6 +27203,7 @@ var ENDPOINTS = {
27201
27203
  MACHINE_AGENT_WORKSPACE_SETUP: (orgId, machineId, agentId) => `${API_BASE}/orgs/${orgId}/machines/${machineId}/agents/${agentId}/workspace/setup`,
27202
27204
  MACHINE_LLM_SOURCE: (orgId, machineId) => `${API_BASE}/orgs/${orgId}/machines/${machineId}/llm-source`,
27203
27205
  MACHINE_PROVIDER_ENABLED: (orgId, machineId) => `${API_BASE}/orgs/${orgId}/machines/${machineId}/provider-enabled`,
27206
+ MACHINE_CAPABILITIES: (orgId, machineId) => `${API_BASE}/orgs/${orgId}/machines/${machineId}/capabilities`,
27204
27207
  MACHINE_RUNTIME_AUTH: (orgId, machineId) => `${API_BASE}/orgs/${orgId}/machines/${machineId}/runtime-auth`,
27205
27208
  MACHINE_KEYS: (orgId, machineId) => `${API_BASE}/orgs/${orgId}/machines/${machineId}/keys`,
27206
27209
  MACHINE_KEY: (orgId, machineId, keyId) => `${API_BASE}/orgs/${orgId}/machines/${machineId}/keys/${keyId}`,
@@ -27221,6 +27224,12 @@ var ENDPOINTS = {
27221
27224
  MACHINES_ME_AGENT_WORKSPACE_STATE: (agentId) => `${API_BASE}/machines/me/agents/${agentId}/workspace-state`,
27222
27225
  MACHINES_ME_WS_TICKET: `${API_BASE}/machines/me/ws/ticket`,
27223
27226
  MACHINES_ME_BROWSE_RESPONSE: (requestId) => `${API_BASE}/machines/me/browse-response/${requestId}`,
27227
+ // Hosted browser live-viewer control plane (api-server, NOT clip-service):
27228
+ // the web client drives WebRTC signaling + tab nav through VIEWER_COMMAND;
27229
+ // api-server brokers each command to the host daemon via the machine:{id}
27230
+ // request/reply bridge (mirrors filesystem browse), and the daemon replies on
27231
+ // VIEWER_RESPONSE. See docs/engineering-design/hosted-browser-provider-design.md.
27232
+ MACHINES_ME_BROWSER_PROFILE_VIEWER_RESPONSE: (requestId) => `${API_BASE}/machines/me/browser-profiles/viewer-response/${requestId}`,
27224
27233
  // Tasks (org-scoped)
27225
27234
  TASKS: (orgId) => `${API_BASE}/orgs/${orgId}/tasks`,
27226
27235
  TASK: (orgId, taskId) => `${API_BASE}/orgs/${orgId}/tasks/${taskId}`,
@@ -27247,6 +27256,17 @@ var ENDPOINTS = {
27247
27256
  SCHEDULE_CANCEL: (orgId, id) => `${API_BASE}/orgs/${orgId}/schedules/${id}/cancel`,
27248
27257
  SCHEDULE_RUNS: (orgId, id) => `${API_BASE}/orgs/${orgId}/schedules/${id}/runs`,
27249
27258
  SCHEDULE_RUN: (orgId, runId) => `${API_BASE}/orgs/${orgId}/schedule_runs/${runId}`,
27259
+ // External triggers (org-scoped incoming integration primitive)
27260
+ EXTERNAL_CONNECTIONS: (orgId) => `${API_BASE}/orgs/${orgId}/external-connections`,
27261
+ EXTERNAL_CONNECTION: (orgId, connectionId) => `${API_BASE}/orgs/${orgId}/external-connections/${connectionId}`,
27262
+ EXTERNAL_CONNECTION_INGRESS_TOKEN_REGENERATE: (orgId, connectionId) => `${API_BASE}/orgs/${orgId}/external-connections/${connectionId}/ingress-token/regenerate`,
27263
+ EXTERNAL_TRIGGER_SCHEMA: (orgId, connectionId) => `${API_BASE}/orgs/${orgId}/external-connections/${connectionId}/trigger-schema`,
27264
+ EXTERNAL_INGRESS_EVENTS: (orgId) => `${API_BASE}/orgs/${orgId}/external-ingress-events`,
27265
+ EXTERNAL_INGRESS_EVENT: (orgId, eventId) => `${API_BASE}/orgs/${orgId}/external-ingress-events/${eventId}`,
27266
+ EXTERNAL_TRIGGERS: (orgId) => `${API_BASE}/orgs/${orgId}/external-triggers`,
27267
+ EXTERNAL_TRIGGER: (orgId, triggerId) => `${API_BASE}/orgs/${orgId}/external-triggers/${triggerId}`,
27268
+ EXTERNAL_TRIGGER_RUNS: (orgId) => `${API_BASE}/orgs/${orgId}/external-trigger-runs`,
27269
+ EXTERNAL_TRIGGER_RUN: (orgId, runId) => `${API_BASE}/orgs/${orgId}/external-trigger-runs/${runId}`,
27250
27270
  // Invitations (org-scoped, admin)
27251
27271
  ORG_INVITATIONS: (orgId) => `${API_BASE}/orgs/${orgId}/invitations`,
27252
27272
  ORG_INVITATION: (orgId, invId) => `${API_BASE}/orgs/${orgId}/invitations/${invId}`,
@@ -27286,6 +27306,9 @@ var ENDPOINTS = {
27286
27306
  // Wiki Path Scopes (AFCS ACL)
27287
27307
  WIKI_PATH_SCOPES: (orgId, wikiId) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/path-scopes`,
27288
27308
  WIKI_PATH_SCOPE: (orgId, wikiId, scopeId) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/path-scopes/${scopeId}`,
27309
+ // Wiki Path Restrictions (AFCS narrowing ACL — private subtrees)
27310
+ WIKI_RESTRICTIONS: (orgId, wikiId) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/restrictions`,
27311
+ WIKI_RESTRICTION: (orgId, wikiId, restrictionId) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/restrictions/${restrictionId}`,
27289
27312
  WIKI_ACCESS_STATUS: (orgId, wikiId) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/access-status`,
27290
27313
  WIKI_ACCESS_REQUESTS: (orgId, wikiId) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/access-requests`,
27291
27314
  // Wiki History (commits, file commits, blame)
@@ -27351,6 +27374,7 @@ var ENDPOINTS = {
27351
27374
  // Clips (org-scoped, served by clip-service)
27352
27375
  CLIPS: (orgId) => `${CLIP_BASE}/orgs/${orgId}/clips`,
27353
27376
  CLIP: (orgId, clipId) => `${CLIP_BASE}/orgs/${orgId}/clips/${clipId}`,
27377
+ CLIPS_BULK_METADATA: (orgId) => `${CLIP_BASE}/orgs/${orgId}/clips/bulk-metadata`,
27354
27378
  CLIP_AGENTS: (orgId, clipId) => `${CLIP_BASE}/orgs/${orgId}/clips/${clipId}/agents`,
27355
27379
  AGENT_CLIPS: (orgId, agentId) => `${CLIP_BASE}/orgs/${orgId}/agents/${agentId}/clips`,
27356
27380
  AGENT_CLIP: (orgId, agentId, clipId) => `${CLIP_BASE}/orgs/${orgId}/agents/${agentId}/clips/${clipId}`,
@@ -27363,6 +27387,9 @@ var ENDPOINTS = {
27363
27387
  BROWSER_PROFILE_RESET: (orgId, profileId) => `${CLIP_BASE}/orgs/${orgId}/browser-profiles/${profileId}/reset`,
27364
27388
  BROWSER_PROFILE_CONSENTS: (orgId, profileId) => `${CLIP_BASE}/orgs/${orgId}/browser-profiles/${profileId}/consents`,
27365
27389
  BROWSER_PROFILE_CONSENT: (orgId, profileId, clipId) => `${CLIP_BASE}/orgs/${orgId}/browser-profiles/${profileId}/consents/${clipId}`,
27390
+ // Live viewer command — on api-server (API_BASE), not clip-service: it rides
27391
+ // the machine control-plane request/reply bridge that lives in api-server.
27392
+ BROWSER_PROFILE_VIEWER_COMMAND: (orgId, profileId) => `${API_BASE}/orgs/${orgId}/browser-profiles/${profileId}/viewer/command`,
27366
27393
  // Clip registry (global, served by clip-service → Pinix Hub proxy)
27367
27394
  CLIP_REGISTRY: () => `${CLIP_BASE}/registry/clips`
27368
27395
  };
@@ -27439,6 +27466,7 @@ var WS_EVENTS = {
27439
27466
  MACHINE_CONFIG_UPDATED: "machine.config.updated",
27440
27467
  MACHINE_CLIP_SYNC: "machine.clip.sync",
27441
27468
  MACHINE_BROWSER_PROFILE_LIFECYCLE: "machine.browser_profile.lifecycle",
27469
+ MACHINE_BROWSER_PROFILE_VIEWER: "machine.browser_profile.viewer",
27442
27470
  AGENT_NEW_SESSION: "agent.new_session",
27443
27471
  CLIP_CREATED: "clip.created",
27444
27472
  CLIP_REMOVED: "clip.removed",
@@ -27448,6 +27476,7 @@ var WS_EVENTS = {
27448
27476
  // ts/sdk/dist/client.js
27449
27477
  var ParallClient = class _ParallClient {
27450
27478
  baseUrl;
27479
+ wikiBaseUrl;
27451
27480
  token;
27452
27481
  onTokenExpired;
27453
27482
  getRefreshToken;
@@ -27499,12 +27528,22 @@ var ParallClient = class _ParallClient {
27499
27528
  }
27500
27529
  constructor(options = {}) {
27501
27530
  this.baseUrl = options.baseUrl ?? "";
27531
+ this.wikiBaseUrl = options.wikiBaseUrl ?? this.baseUrl;
27502
27532
  this.token = options.token ?? null;
27503
27533
  this.onTokenExpired = options.onTokenExpired;
27504
27534
  this.getRefreshToken = options.getRefreshToken;
27505
27535
  this.setTokens = options.setTokens;
27506
27536
  this.swimlaneName = options.swimlaneName;
27507
27537
  }
27538
+ /**
27539
+ * Pick the origin for a request path: wiki-service base for `/wiki/v1`
27540
+ * endpoints, api base for everything else. The path itself (from ENDPOINTS)
27541
+ * is authoritative, so wiki vs api routing can't drift from how a caller
27542
+ * happens to invoke the client.
27543
+ */
27544
+ baseUrlFor(path15) {
27545
+ return path15.startsWith(WIKI_BASE) ? this.wikiBaseUrl : this.baseUrl;
27546
+ }
27508
27547
  setToken(token) {
27509
27548
  this.token = token;
27510
27549
  }
@@ -27569,7 +27608,7 @@ var ParallClient = class _ParallClient {
27569
27608
  if (!retried) {
27570
27609
  await this.ensureFreshToken(path15);
27571
27610
  }
27572
- let url = `${this.baseUrl}${path15}`;
27611
+ let url = `${this.baseUrlFor(path15)}${path15}`;
27573
27612
  if (query) {
27574
27613
  const params = new URLSearchParams();
27575
27614
  for (const [key, value] of Object.entries(query)) {
@@ -27590,7 +27629,10 @@ var ParallClient = class _ParallClient {
27590
27629
  method,
27591
27630
  headers,
27592
27631
  body: body ? JSON.stringify(body) : void 0,
27593
- signal
27632
+ signal,
27633
+ // keepalive lets a request fired during page unload (e.g. the browser
27634
+ // viewer's stream.close on pagehide) outlive the document.
27635
+ keepalive: opts?.keepalive
27594
27636
  });
27595
27637
  } catch (err) {
27596
27638
  throw _ParallClient.normalizeFetchError(err);
@@ -27638,7 +27680,7 @@ var ParallClient = class _ParallClient {
27638
27680
  void _drop;
27639
27681
  let res;
27640
27682
  try {
27641
- res = await fetch(`${this.baseUrl}${path15}`, {
27683
+ res = await fetch(`${this.baseUrlFor(path15)}${path15}`, {
27642
27684
  method,
27643
27685
  headers,
27644
27686
  body,
@@ -27761,6 +27803,10 @@ var ParallClient = class _ParallClient {
27761
27803
  const res = await this.request("GET", ENDPOINTS.ORG_MEMBERS(orgId));
27762
27804
  return res.data;
27763
27805
  }
27806
+ async getTeams(orgId) {
27807
+ const res = await this.request("GET", ENDPOINTS.TEAMS(orgId));
27808
+ return res.data;
27809
+ }
27764
27810
  async getOnlineMembers(orgId) {
27765
27811
  const res = await this.request("GET", ENDPOINTS.ORG_MEMBERS_ONLINE(orgId));
27766
27812
  return res.user_ids ?? [];
@@ -27794,6 +27840,21 @@ var ParallClient = class _ParallClient {
27794
27840
  q.limit = String(params.limit);
27795
27841
  return this.request("GET", ENDPOINTS.ORG_MEMBER_TASKS(orgId, memberId), void 0, q);
27796
27842
  }
27843
+ // Auto-paginated variant of getMemberTasks: fetches ALL pending tasks
27844
+ // (todo + in_progress) assigned to a member, including subtasks (the
27845
+ // endpoint does not filter parent_id). Powers the CLI `tasks assigned`
27846
+ // command so an agent answering "what's on X's plate" sees the full
27847
+ // backlog, not just the first page.
27848
+ async getMemberTasksAll(orgId, memberId) {
27849
+ const all = [];
27850
+ let cursor;
27851
+ do {
27852
+ const res = await this.getMemberTasks(orgId, memberId, { cursor, limit: 100 });
27853
+ all.push(...res.data);
27854
+ cursor = res.has_more ? res.next_cursor : void 0;
27855
+ } while (cursor);
27856
+ return all;
27857
+ }
27797
27858
  // ---- Invitations ----
27798
27859
  async createInvitation(orgId, email, role) {
27799
27860
  return this.request("POST", ENDPOINTS.ORG_INVITATIONS(orgId), { email, role });
@@ -27999,6 +28060,10 @@ var ParallClient = class _ParallClient {
27999
28060
  async createAgentApiKey(orgId, agentId) {
28000
28061
  return this.request("POST", ENDPOINTS.AGENT_API_KEYS(orgId, agentId));
28001
28062
  }
28063
+ /** Revokes all of the agent's active API keys and mints a replacement. */
28064
+ async regenerateAgentApiKey(orgId, agentId) {
28065
+ return this.request("POST", ENDPOINTS.AGENT_API_KEY_REGENERATE(orgId, agentId));
28066
+ }
28002
28067
  async revokeAgentApiKey(orgId, agentId, key) {
28003
28068
  return this.request("DELETE", ENDPOINTS.AGENT_API_KEY(orgId, agentId, key));
28004
28069
  }
@@ -28170,6 +28235,17 @@ var ParallClient = class _ParallClient {
28170
28235
  provider_enabled: providerEnabled
28171
28236
  });
28172
28237
  }
28238
+ /**
28239
+ * Replace the machine's capability set (admin). Primary use: healing a
28240
+ * machine that registered without `browser_provider` during the capability
28241
+ * migration's rolling-deploy window. Removing `agent_host` is rejected by the
28242
+ * server while agents are attached (409 AGENTS_STILL_ATTACHED).
28243
+ */
28244
+ async patchMachineCapabilities(orgId, machineId, capabilities) {
28245
+ return this.request("PATCH", ENDPOINTS.MACHINE_CAPABILITIES(orgId, machineId), {
28246
+ capabilities
28247
+ });
28248
+ }
28173
28249
  /** Get machine-level runtime auth state. */
28174
28250
  async getMachineRuntimeAuth(orgId, machineId) {
28175
28251
  return this.request("GET", ENDPOINTS.MACHINE_RUNTIME_AUTH(orgId, machineId));
@@ -28260,6 +28336,22 @@ var ParallClient = class _ParallClient {
28260
28336
  async postBrowseResponse(requestId, response) {
28261
28337
  return this.request("POST", ENDPOINTS.MACHINES_ME_BROWSE_RESPONSE(requestId), response);
28262
28338
  }
28339
+ /**
28340
+ * `POST /machines/me/browser-profiles/viewer-response/{requestId}` — daemon
28341
+ * reply to a `machine.browser_profile.viewer` control command. Wakes the
28342
+ * api-server request/reply bridge (mirrors {@link postBrowseResponse}).
28343
+ */
28344
+ async postBrowserProfileViewerResponse(requestId, response) {
28345
+ return this.request("POST", ENDPOINTS.MACHINES_ME_BROWSER_PROFILE_VIEWER_RESPONSE(requestId), response);
28346
+ }
28347
+ /**
28348
+ * `POST /orgs/{orgId}/browser-profiles/{profileId}/viewer/command` — drive the
28349
+ * hosted browser live viewer (WebRTC signaling + tab nav). Authz: profile
28350
+ * owner or org admin. api-server brokers the command to the host daemon.
28351
+ */
28352
+ async browserViewerCommand(orgId, profileId, req, opts) {
28353
+ return this.request("POST", ENDPOINTS.BROWSER_PROFILE_VIEWER_COMMAND(orgId, profileId), req, void 0, false, opts);
28354
+ }
28263
28355
  async resizeMachine(orgId, machineId, spec) {
28264
28356
  return this.request("PATCH", ENDPOINTS.MACHINE_SPEC(orgId, machineId), spec);
28265
28357
  }
@@ -28352,7 +28444,7 @@ var ParallClient = class _ParallClient {
28352
28444
  * Returns null when the server responds with 304 (config unchanged).
28353
28445
  */
28354
28446
  async getPlatformConfig(currentVersion) {
28355
- const url = `${this.baseUrl}${ENDPOINTS.PLATFORM_CONFIG}`;
28447
+ const url = `${this.baseUrlFor(ENDPOINTS.PLATFORM_CONFIG)}${ENDPOINTS.PLATFORM_CONFIG}`;
28356
28448
  const extra = {};
28357
28449
  if (currentVersion !== void 0) {
28358
28450
  extra["If-None-Match"] = currentVersion;
@@ -28518,6 +28610,55 @@ var ParallClient = class _ParallClient {
28518
28610
  async getScheduleRun(orgId, runId) {
28519
28611
  return this.request("GET", ENDPOINTS.SCHEDULE_RUN(orgId, runId));
28520
28612
  }
28613
+ // ---- External triggers (org-scoped) ----
28614
+ async createExternalConnection(orgId, input) {
28615
+ return this.request("POST", ENDPOINTS.EXTERNAL_CONNECTIONS(orgId), input);
28616
+ }
28617
+ async listExternalConnections(orgId, filters) {
28618
+ return this.request("GET", ENDPOINTS.EXTERNAL_CONNECTIONS(orgId), void 0, filters);
28619
+ }
28620
+ async getExternalConnection(orgId, connectionId) {
28621
+ return this.request("GET", ENDPOINTS.EXTERNAL_CONNECTION(orgId, connectionId));
28622
+ }
28623
+ async updateExternalConnection(orgId, connectionId, patch) {
28624
+ return this.request("PATCH", ENDPOINTS.EXTERNAL_CONNECTION(orgId, connectionId), patch);
28625
+ }
28626
+ async regenerateExternalConnectionIngressToken(orgId, connectionId) {
28627
+ return this.request("POST", ENDPOINTS.EXTERNAL_CONNECTION_INGRESS_TOKEN_REGENERATE(orgId, connectionId));
28628
+ }
28629
+ async deleteExternalConnection(orgId, connectionId) {
28630
+ return this.request("DELETE", ENDPOINTS.EXTERNAL_CONNECTION(orgId, connectionId));
28631
+ }
28632
+ async getExternalTriggerSchema(orgId, connectionId) {
28633
+ return this.request("GET", ENDPOINTS.EXTERNAL_TRIGGER_SCHEMA(orgId, connectionId));
28634
+ }
28635
+ async listExternalIngressEvents(orgId, filters) {
28636
+ return this.request("GET", ENDPOINTS.EXTERNAL_INGRESS_EVENTS(orgId), void 0, filters);
28637
+ }
28638
+ async getExternalIngressEvent(orgId, eventId) {
28639
+ return this.request("GET", ENDPOINTS.EXTERNAL_INGRESS_EVENT(orgId, eventId));
28640
+ }
28641
+ async createExternalTrigger(orgId, input) {
28642
+ return this.request("POST", ENDPOINTS.EXTERNAL_TRIGGERS(orgId), input);
28643
+ }
28644
+ async listExternalTriggers(orgId, filters) {
28645
+ return this.request("GET", ENDPOINTS.EXTERNAL_TRIGGERS(orgId), void 0, filters);
28646
+ }
28647
+ async getExternalTrigger(orgId, triggerId) {
28648
+ return this.request("GET", ENDPOINTS.EXTERNAL_TRIGGER(orgId, triggerId));
28649
+ }
28650
+ async updateExternalTrigger(orgId, triggerId, patch) {
28651
+ return this.request("PATCH", ENDPOINTS.EXTERNAL_TRIGGER(orgId, triggerId), patch);
28652
+ }
28653
+ async deleteExternalTrigger(orgId, triggerId) {
28654
+ return this.request("DELETE", ENDPOINTS.EXTERNAL_TRIGGER(orgId, triggerId));
28655
+ }
28656
+ async listExternalTriggerRuns(orgId, filters) {
28657
+ return this.request("GET", ENDPOINTS.EXTERNAL_TRIGGER_RUNS(orgId), void 0, filters);
28658
+ }
28659
+ async getExternalTriggerRun(orgId, runId) {
28660
+ return this.request("GET", ENDPOINTS.EXTERNAL_TRIGGER_RUN(orgId, runId));
28661
+ }
28521
28662
  // ---- Wikis (org-scoped) ----
28522
28663
  async createWiki(orgId, data) {
28523
28664
  return this.request("POST", ENDPOINTS.WIKIS(orgId), data);
@@ -28532,8 +28673,35 @@ var ParallClient = class _ParallClient {
28532
28673
  async getWikiTree(orgId, wikiId, params) {
28533
28674
  return this.request("GET", ENDPOINTS.WIKI_TREE(orgId, wikiId), void 0, params);
28534
28675
  }
28676
+ /**
28677
+ * Resolve a server-returned, host-relative media URL (a wiki `signed_url`
28678
+ * like `/wiki/v1/signed/files?token=...`) against this client's base origin,
28679
+ * so it can be dropped straight into a browser `<img>`/`<video>`/`<iframe>`
28680
+ * `src`.
28681
+ *
28682
+ * wiki-service returns these relative on purpose — it doesn't know its own
28683
+ * public origin. A relative `src` resolves against the *page* origin, which
28684
+ * only works when the page and wiki-service share an origin (local dev:
28685
+ * same-origin + Next.js `/wiki/*` proxy). In deployed envs the app
28686
+ * (app.parall.com) and wiki-service (api.parall.com) are different origins,
28687
+ * so `app.parall.com/wiki/v1/signed/files` hits the SPA's own `/wiki/[...]`
28688
+ * catch-all route — an `<iframe>` then recursively renders the whole app
28689
+ * instead of the file. Prefixing with the wiki base (the exact origin every
28690
+ * wiki API request already uses — `baseUrlFor` resolves `/wiki/v1` paths to
28691
+ * `wikiBaseUrl`) makes the URL absolute against the origin that actually
28692
+ * serves the bytes. An empty base (local dev, same-origin proxy) leaves it
28693
+ * relative, preserving the proxy path.
28694
+ */
28695
+ absoluteMediaUrl(url) {
28696
+ if (/^https?:\/\//i.test(url))
28697
+ return url;
28698
+ return `${this.baseUrlFor(url)}${url}`;
28699
+ }
28535
28700
  async getWikiBlob(orgId, wikiId, params) {
28536
- return this.request("GET", ENDPOINTS.WIKI_BLOB(orgId, wikiId), void 0, params);
28701
+ const blob = await this.request("GET", ENDPOINTS.WIKI_BLOB(orgId, wikiId), void 0, params);
28702
+ if (blob.signed_url)
28703
+ blob.signed_url = this.absoluteMediaUrl(blob.signed_url);
28704
+ return blob;
28537
28705
  }
28538
28706
  async getWikiNodeSections(orgId, wikiId, params) {
28539
28707
  return this.request("GET", ENDPOINTS.WIKI_NODE_SECTIONS(orgId, wikiId), void 0, params);
@@ -28623,7 +28791,10 @@ var ParallClient = class _ParallClient {
28623
28791
  * token — don't leak it.
28624
28792
  */
28625
28793
  async getWikiFilePreviewUrl(orgId, wikiId, params) {
28626
- return this.request("POST", ENDPOINTS.WIKI_FILE_PREVIEW_URL(orgId, wikiId), params);
28794
+ const res = await this.request("POST", ENDPOINTS.WIKI_FILE_PREVIEW_URL(orgId, wikiId), params);
28795
+ if (res.url)
28796
+ res.url = this.absoluteMediaUrl(res.url);
28797
+ return res;
28627
28798
  }
28628
28799
  // ---- Wiki Path Scopes (AFCS ACL) ----
28629
28800
  async getWikiPathScopes(orgId, wikiId) {
@@ -28636,6 +28807,17 @@ var ParallClient = class _ParallClient {
28636
28807
  async deleteWikiPathScope(orgId, wikiId, scopeId) {
28637
28808
  await this.request("DELETE", ENDPOINTS.WIKI_PATH_SCOPE(orgId, wikiId, scopeId));
28638
28809
  }
28810
+ // ---- Wiki Path Restrictions (narrowing ACL — private subtrees) ----
28811
+ async getWikiRestrictions(orgId, wikiId) {
28812
+ const res = await this.request("GET", ENDPOINTS.WIKI_RESTRICTIONS(orgId, wikiId));
28813
+ return res.data;
28814
+ }
28815
+ async createWikiRestriction(orgId, wikiId, data) {
28816
+ return this.request("POST", ENDPOINTS.WIKI_RESTRICTIONS(orgId, wikiId), data);
28817
+ }
28818
+ async deleteWikiRestriction(orgId, wikiId, restrictionId) {
28819
+ await this.request("DELETE", ENDPOINTS.WIKI_RESTRICTION(orgId, wikiId, restrictionId));
28820
+ }
28639
28821
  async getWikiAccessStatus(orgId, wikiId, path15) {
28640
28822
  return this.request("GET", ENDPOINTS.WIKI_ACCESS_STATUS(orgId, wikiId), void 0, path15 ? { path: path15 } : void 0);
28641
28823
  }
@@ -28798,6 +28980,10 @@ var ParallClient = class _ParallClient {
28798
28980
  async updateClip(orgId, clipId, req) {
28799
28981
  return this.request("PATCH", ENDPOINTS.CLIP(orgId, clipId), req);
28800
28982
  }
28983
+ /** Atomically set display_name and/or description on multiple clip instances (application metadata). */
28984
+ async bulkUpdateClipMetadata(orgId, req) {
28985
+ return this.request("POST", ENDPOINTS.CLIPS_BULK_METADATA(orgId), req);
28986
+ }
28801
28987
  async deleteClip(orgId, clipId) {
28802
28988
  await this.request("DELETE", ENDPOINTS.CLIP(orgId, clipId));
28803
28989
  }
@@ -28824,6 +29010,9 @@ var ParallClient = class _ParallClient {
28824
29010
  const resp = await this.request("GET", ENDPOINTS.CLIP_ONLINE(orgId));
28825
29011
  return resp.data;
28826
29012
  }
29013
+ /** Org-wide browser-profile discovery list. Returns the sanitized
29014
+ * {@link BrowserProfileListItem} shape (not the full domain model), each row
29015
+ * carrying a per-viewer `can_open` control hint. */
28827
29016
  async listBrowserProfiles(orgId) {
28828
29017
  const resp = await this.request("GET", ENDPOINTS.BROWSER_PROFILES(orgId));
28829
29018
  return resp.data;
@@ -29698,7 +29887,7 @@ async function runCLI(args) {
29698
29887
  init_config();
29699
29888
 
29700
29889
  // ts/daemon/dist/supervisor.js
29701
- import { spawn as spawn5 } from "node:child_process";
29890
+ import { spawn as spawn6 } from "node:child_process";
29702
29891
  import * as fs12 from "node:fs";
29703
29892
  import * as path13 from "node:path";
29704
29893
 
@@ -31622,6 +31811,10 @@ function encodeEnvelope2(msg) {
31622
31811
  header.writeUInt32BE(json.length, 1);
31623
31812
  return Buffer.concat([header, json]);
31624
31813
  }
31814
+ function registrationKey(clips) {
31815
+ const sorted = [...clips].sort((a, b) => (a.alias ?? "").localeCompare(b.alias ?? ""));
31816
+ return JSON.stringify(sorted);
31817
+ }
31625
31818
  var EnvelopeDecoder2 = class _EnvelopeDecoder {
31626
31819
  buf = Buffer.alloc(0);
31627
31820
  push(chunk) {
@@ -31657,7 +31850,15 @@ var ClipProvider = class _ClipProvider {
31657
31850
  heartbeatTimer = null;
31658
31851
  statusUnsubscribe = null;
31659
31852
  manifestUnsubscribe = null;
31853
+ // Set when the clip manifest/set changed and the hub's copy is stale. Drained
31854
+ // by flushReregister(), which re-registers IN-STREAM (no reconnect).
31660
31855
  needsReregister = false;
31856
+ // Canonical key of the clip-registration set last sent to the hub on the
31857
+ // CURRENT stream. flushReregister diff-gates against it so a cold-start that
31858
+ // re-derives an identical set (the common case — a clip's command surface is
31859
+ // stable across spawns) does not emit a redundant re-register. Reset per
31860
+ // stream; the first-frame register in openStream re-seeds it.
31861
+ lastRegisteredKey = null;
31661
31862
  // Monotonic stream id, bumped by openStream(). Handlers from a stream that a
31662
31863
  // newer openStream() has superseded bail in handleDisconnect(gen), so their
31663
31864
  // late close/end/error can't reconnect against the healthy replacement.
@@ -31698,6 +31899,7 @@ var ClipProvider = class _ClipProvider {
31698
31899
  if (this.stopped)
31699
31900
  return;
31700
31901
  const gen = ++this.streamGeneration;
31902
+ this.lastRegisteredKey = null;
31701
31903
  try {
31702
31904
  this.closeStream();
31703
31905
  this.closeSession();
@@ -31796,21 +31998,57 @@ var ClipProvider = class _ClipProvider {
31796
31998
  startHeartbeat() {
31797
31999
  this.clearHeartbeatTimer();
31798
32000
  this.heartbeatTimer = setInterval(() => {
31799
- if (this.needsReregister) {
31800
- this.needsReregister = false;
31801
- this.opts.log.info("[clip-provider] manifest updated \u2014 reconnecting to re-register clips");
31802
- this.connected = false;
31803
- this.clearHeartbeatTimer();
31804
- this.clearReconnectTimer();
31805
- void this.openStream();
31806
- return;
31807
- }
32001
+ this.flushReregister();
31808
32002
  this.sendProviderMessage({ ping: { sentAtUnixMs: Date.now() } }).catch((err) => {
31809
32003
  this.opts.log.warn(`[clip-provider] heartbeat send failed: ${String(err)}`);
31810
32004
  });
31811
32005
  }, _ClipProvider.HEARTBEAT_INTERVAL_MS);
31812
32006
  this.heartbeatTimer.unref?.();
31813
32007
  }
32008
+ /**
32009
+ * Re-register the current clip set on the OPEN stream — the daemon's response
32010
+ * to a manifest/clip-set change. The hub overwrites its stored set from this
32011
+ * full register (clip-provider-sharing-design §11), so a clip install / wake /
32012
+ * sleep no longer requires tearing down and reopening the ProviderStream,
32013
+ * which used to drop any invoke in flight on that stream (in-flight requests
32014
+ * are keyed by request_id on the hub and survive a clip-set swap).
32015
+ *
32016
+ * No-op when the stream is not currently writable; needsReregister stays set
32017
+ * so the next heartbeat tick (or the post-connect flush) retries.
32018
+ *
32019
+ * Against an older hub that still rejects an in-stream duplicate register, the
32020
+ * write reaches a stream the hub then resets; the resulting stream error runs
32021
+ * the normal reconnect path, which re-registers as the first frame — i.e. it
32022
+ * degrades to the previous reconnect-to-re-register behavior, so no capability
32023
+ * negotiation is needed (deploy the hub first).
32024
+ */
32025
+ flushReregister() {
32026
+ if (!this.needsReregister)
32027
+ return;
32028
+ if (!this.connected || !this.stream || this.stream.closed || this.stream.destroyed)
32029
+ return;
32030
+ const clips = this.buildClipRegistrations();
32031
+ if (registrationKey(clips) === this.lastRegisteredKey) {
32032
+ this.needsReregister = false;
32033
+ return;
32034
+ }
32035
+ this.needsReregister = false;
32036
+ this.opts.log.info("[clip-provider] re-registering clips in-stream");
32037
+ this.sendRegister(clips).catch((err) => {
32038
+ this.opts.log.warn(`[clip-provider] in-stream re-register failed: ${String(err)}`);
32039
+ this.needsReregister = true;
32040
+ });
32041
+ }
32042
+ /**
32043
+ * Re-register the current clip set on the open stream — public entry for the
32044
+ * supervisor when machine clip assignments change. Same in-stream path as a
32045
+ * manifest update (no reconnect, in-flight invokes survive). Safe when
32046
+ * disconnected: the flag is flushed on the next heartbeat / post-connect.
32047
+ */
32048
+ reregister() {
32049
+ this.needsReregister = true;
32050
+ this.flushReregister();
32051
+ }
31814
32052
  closeStream() {
31815
32053
  if (this.stream) {
31816
32054
  try {
@@ -31845,7 +32083,7 @@ var ClipProvider = class _ClipProvider {
31845
32083
  });
31846
32084
  });
31847
32085
  this.manifestUnsubscribe = mgr.addManifestListener(() => {
31848
- this.needsReregister = true;
32086
+ this.reregister();
31849
32087
  });
31850
32088
  }
31851
32089
  unsubscribeStatusChanges() {
@@ -31862,6 +32100,8 @@ var ClipProvider = class _ClipProvider {
31862
32100
  this.handleRegistered(msg.registerResponse);
31863
32101
  } else if (msg.invokeCommand) {
31864
32102
  void this.handleInvokeCommand(msg.invokeCommand);
32103
+ } else if (msg.viewerCommand) {
32104
+ void this.handleViewerCommand(msg.viewerCommand);
31865
32105
  } else if (msg.pong !== void 0) {
31866
32106
  }
31867
32107
  }
@@ -31871,6 +32111,7 @@ var ClipProvider = class _ClipProvider {
31871
32111
  this.connected = true;
31872
32112
  this.reconnectAttempt = 0;
31873
32113
  this.startHeartbeat();
32114
+ this.flushReregister();
31874
32115
  } else {
31875
32116
  this.opts.log.error(`[clip-provider] registration rejected: ${reg.message}`);
31876
32117
  this.stopped = true;
@@ -31924,17 +32165,64 @@ var ClipProvider = class _ClipProvider {
31924
32165
  });
31925
32166
  }
31926
32167
  }
32168
+ /**
32169
+ * Handle a hosted-browser live-viewer command relayed by clip-service over the
32170
+ * stream and reply on the same stream (design §3.4). Always answers the
32171
+ * request/reply bridge exactly once — `{result}` on success, `{error:{message}}`
32172
+ * on failure — and never throws into the message loop, mirroring the BYOC
32173
+ * daemon's handleBrowserProfileViewer (which replies over HTTP instead).
32174
+ */
32175
+ async handleViewerCommand(cmd) {
32176
+ const { requestId } = cmd;
32177
+ if (!this.opts.onViewerCommand) {
32178
+ await this.sendViewerReply(requestId, {
32179
+ error: { message: "live viewer is not supported by this provider" }
32180
+ }).catch(() => {
32181
+ });
32182
+ return;
32183
+ }
32184
+ try {
32185
+ let input;
32186
+ if (cmd.input) {
32187
+ const decoded = Buffer.from(cmd.input, "base64").toString("utf-8");
32188
+ try {
32189
+ input = JSON.parse(decoded);
32190
+ } catch {
32191
+ input = void 0;
32192
+ }
32193
+ }
32194
+ const result = await this.opts.onViewerCommand({
32195
+ profileId: cmd.profileId,
32196
+ sessionId: cmd.sessionId,
32197
+ command: cmd.command,
32198
+ input,
32199
+ turn: cmd.turn
32200
+ });
32201
+ await this.sendViewerReply(requestId, { result });
32202
+ } catch (err) {
32203
+ const message = err instanceof Error ? err.message : String(err);
32204
+ this.opts.log.warn(`[clip-provider] viewer command failed (profile=${cmd.profileId}, command=${cmd.command}): ${message}`);
32205
+ await this.sendViewerReply(requestId, { error: { message } }).catch(() => {
32206
+ });
32207
+ }
32208
+ }
32209
+ /** Send a hosted-viewer reply (base64-encoded JSON body) on the stream. */
32210
+ async sendViewerReply(requestId, reply) {
32211
+ const payload = Buffer.from(JSON.stringify(reply), "utf-8").toString("base64");
32212
+ await this.sendProviderMessage({ viewerResult: { requestId, payload } });
32213
+ }
31927
32214
  // ---------------------------------------------------------------------------
31928
32215
  // Sending
31929
32216
  // ---------------------------------------------------------------------------
31930
- async sendRegister() {
31931
- const clips = this.buildClipRegistrations();
32217
+ async sendRegister(prebuilt) {
32218
+ const clips = prebuilt ?? this.buildClipRegistrations();
31932
32219
  await this.sendProviderMessage({
31933
32220
  register: {
31934
32221
  providerName: this.opts.providerName,
31935
32222
  clips
31936
32223
  }
31937
32224
  });
32225
+ this.lastRegisteredKey = registrationKey(clips);
31938
32226
  }
31939
32227
  async sendProviderMessage(msg) {
31940
32228
  if (!this.stream || this.stream.closed || this.stream.destroyed) {
@@ -32001,13 +32289,619 @@ var ClipProvider = class _ClipProvider {
32001
32289
  };
32002
32290
 
32003
32291
  // ts/daemon/dist/clip-runtime/browser-profile-manager.js
32004
- import { spawn as spawn3 } from "node:child_process";
32292
+ import { spawn as spawn4 } from "node:child_process";
32005
32293
  import { randomBytes } from "node:crypto";
32006
32294
  import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync7 } from "node:fs";
32007
32295
  import { createRequire } from "node:module";
32008
- import * as net from "node:net";
32009
32296
  import * as path9 from "node:path";
32010
32297
  import { fileURLToPath } from "node:url";
32298
+
32299
+ // ts/daemon/dist/clip-runtime/browser-viewer-streamer.js
32300
+ import { spawn as spawn3 } from "node:child_process";
32301
+
32302
+ // ts/daemon/dist/clip-runtime/subprocess.js
32303
+ import * as net from "node:net";
32304
+ function formatErrorForLog(err) {
32305
+ const message = err instanceof Error ? err.message : String(err);
32306
+ return message.replace(/\s+/g, " ").slice(0, 300);
32307
+ }
32308
+ async function findFreePort() {
32309
+ return new Promise((resolve8, reject) => {
32310
+ const server = net.createServer();
32311
+ server.unref();
32312
+ server.on("error", reject);
32313
+ server.listen(0, "127.0.0.1", () => {
32314
+ const address = server.address();
32315
+ server.close(() => {
32316
+ if (address && typeof address === "object") {
32317
+ resolve8(address.port);
32318
+ } else {
32319
+ reject(new Error("failed to allocate free port"));
32320
+ }
32321
+ });
32322
+ });
32323
+ });
32324
+ }
32325
+ function sleep(ms) {
32326
+ return new Promise((resolve8) => setTimeout(resolve8, ms));
32327
+ }
32328
+
32329
+ // ts/daemon/dist/clip-runtime/browser-viewer-streamer.js
32330
+ var BB_VIEWER_HEALTH_TIMEOUT_MS = 1e4;
32331
+ var BB_VIEWER_COMMAND_TIMEOUT_MS = 15e3;
32332
+ var BB_VIEWER_UNANSWERED_REAP_MS = 9e4;
32333
+ var BB_VIEWER_TERM_GRACE_MS = 5e3;
32334
+ function isLoopbackBindHost(host) {
32335
+ return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
32336
+ }
32337
+ function kickHitsCurrentSession(requested, currentSessionId) {
32338
+ if (currentSessionId === void 0)
32339
+ return false;
32340
+ return requested === void 0 || requested === currentSessionId;
32341
+ }
32342
+ var BrowserViewerStreamer = class {
32343
+ host;
32344
+ // Live bb-viewer (WebRTC streamer) subprocess per profile. One per profile;
32345
+ // see StreamerState. Cleaned up on stream.close, stopProfile, resetProfile,
32346
+ // and stop().
32347
+ streamers = /* @__PURE__ */ new Map();
32348
+ // The most-recently server-side-kicked viewer session per profile (design §3.6
32349
+ // PR7). After a kick the streamer is gone, so the session-staleness guards
32350
+ // (which key off a live streamer) can no longer reject the kicked viewer's
32351
+ // straggler nav commands — this map does. Reset whenever the profile's streamer
32352
+ // is (re)killed via killProfileStreamer, so a fresh stream.start clears it. One
32353
+ // entry per profile (overwritten per kick); bounded, no leak.
32354
+ kickedSessions = /* @__PURE__ */ new Map();
32355
+ // Set by shutdown(): an in-flight spawnStreamer has no map entry yet, so a
32356
+ // shutdown during its health poll would otherwise let the bb-viewer child
32357
+ // survive daemon stop (on BYOC nothing else reaps it).
32358
+ stopping = false;
32359
+ constructor(host) {
32360
+ this.host = host;
32361
+ }
32362
+ /**
32363
+ * Handle a viewer control command for a profile. Throws on any failure (the
32364
+ * supervisor maps a throw to a `{error:{message}}` reply); never returns a
32365
+ * partial result. `data.input` / `data.turn` / `data.session_id` come from
32366
+ * MachineBrowserProfileViewerData.
32367
+ */
32368
+ async handleViewerCommand(profileId, sessionId, command, input, turn) {
32369
+ if (!profileId)
32370
+ throw new Error("browser profile id is required");
32371
+ if (command !== "stream.start" && sessionId && this.kickedSessions.get(profileId) === sessionId) {
32372
+ throw new Error("viewer session was terminated");
32373
+ }
32374
+ switch (command) {
32375
+ case "stream.start":
32376
+ return this.viewerStreamStart(profileId, sessionId, turn);
32377
+ case "stream.answer":
32378
+ return this.viewerStreamAnswer(profileId, sessionId, input);
32379
+ case "stream.close":
32380
+ return this.viewerStreamClose(profileId, sessionId, input);
32381
+ case "stream.switch":
32382
+ return this.viewerStreamSwitch(profileId, sessionId, input);
32383
+ case "kick":
32384
+ return this.viewerKick(profileId, sessionId, input);
32385
+ case "close":
32386
+ return this.viewerCloseTab(profileId, sessionId, input);
32387
+ case "tab_list":
32388
+ case "tab_new":
32389
+ case "open":
32390
+ case "reload":
32391
+ case "back":
32392
+ case "forward":
32393
+ return this.viewerForwardNav(profileId, sessionId, command, input);
32394
+ default:
32395
+ throw new Error(`unknown viewer command: ${command}`);
32396
+ }
32397
+ }
32398
+ /**
32399
+ * kick — server-side per-viewer disconnect (design §3.6 PR7). Terminates ONE
32400
+ * viewer's WebRTC session: stop its bb-viewer streamer (killing the process
32401
+ * tears down the peer connection + datachannel) and reject the kicked session's
32402
+ * subsequent commands — while bb-browser + Chromium (the agent's live browser)
32403
+ * keep running. This is explicitly DISTINCT from stopping the pod / profile,
32404
+ * which would kill the agent's browser too; a viewer session is a sub-session of
32405
+ * the profile lease, so a kick does NOT release the lease.
32406
+ *
32407
+ * Targets `input.session_id` if given, else the current streamer's session.
32408
+ * Idempotent: kicking with no live streamer still records the kicked session.
32409
+ */
32410
+ viewerKick(profileId, sessionId, input) {
32411
+ const streamer = this.streamers.get(profileId);
32412
+ const requested = typeof input?.session_id === "string" ? input.session_id : void 0;
32413
+ const target = requested || streamer?.sessionId || sessionId;
32414
+ const killsCurrent = kickHitsCurrentSession(requested, streamer?.sessionId);
32415
+ if (killsCurrent) {
32416
+ this.killProfileStreamer(profileId);
32417
+ }
32418
+ if (target)
32419
+ this.kickedSessions.set(profileId, target);
32420
+ this.host.log.info(`[bb-viewer] kicked viewer session ${target || "(none)"} for profile ${profileId}; pod + agent browser keep running`);
32421
+ return target ? { ok: true, kicked: true, session_id: target } : { ok: true, kicked: true };
32422
+ }
32423
+ /**
32424
+ * stream.start — spawn a FRESH bb-viewer for this profile (killing any prior
32425
+ * one), resolve the profile's account-scoped page-target CDP ws URL, and run
32426
+ * the streamer's `connect` command. Records sessionId on the streamer entry.
32427
+ */
32428
+ async viewerStreamStart(profileId, sessionId, turn) {
32429
+ this.killProfileStreamer(profileId);
32430
+ const cdpUrl = await this.host.withDaemonRecovery(() => this.resolveAccountPageCdpUrl(profileId), `viewer stream.start ${profileId}`);
32431
+ await this.bringTargetToFront(cdpUrl);
32432
+ const streamer = await this.spawnStreamer(profileId, sessionId, turn);
32433
+ try {
32434
+ const result = await this.streamerCommand(streamer, "connect", { cdpUrl });
32435
+ streamer.streamedTabId = targetIdFromCdpUrl(cdpUrl);
32436
+ const reapTimer = setTimeout(() => {
32437
+ const current = this.streamers.get(profileId);
32438
+ if (current === streamer && !current.answered) {
32439
+ this.host.log.warn(`[bb-viewer] no stream.answer for ${profileId} within ${BB_VIEWER_UNANSWERED_REAP_MS / 1e3}s; reaping abandoned streamer`);
32440
+ this.killProfileStreamer(profileId);
32441
+ }
32442
+ }, BB_VIEWER_UNANSWERED_REAP_MS);
32443
+ reapTimer.unref?.();
32444
+ return { ...result, streamed_tab_id: streamer.streamedTabId };
32445
+ } catch (err) {
32446
+ this.killProfileStreamer(profileId);
32447
+ throw err;
32448
+ }
32449
+ }
32450
+ /**
32451
+ * stream.answer — apply the client's WebRTC answer to the profile's streamer.
32452
+ * Rejects if the streamer's sessionId no longer matches (a stale viewer
32453
+ * session from a superseded stream.start).
32454
+ */
32455
+ async viewerStreamAnswer(profileId, sessionId, input) {
32456
+ const streamer = this.streamers.get(profileId);
32457
+ if (!streamer)
32458
+ throw new Error("no active viewer streamer for profile");
32459
+ if (streamer.sessionId !== sessionId)
32460
+ throw new Error("stale viewer session");
32461
+ streamer.answered = true;
32462
+ return this.streamerCommand(streamer, "answer", input ?? {});
32463
+ }
32464
+ /**
32465
+ * stream.close — best-effort stop the streamer, then kill + remove it. A
32466
+ * stale session's close (independent HTTP legs can arrive out of order after
32467
+ * a retry) must NOT tear down a successor session's live streamer, so a
32468
+ * mismatched sessionId is ignored. An EMPTY sessionId gets the same stale
32469
+ * treatment — a client closed mid-handshake never received a session id, and
32470
+ * its unscoped close racing the next session's stream.start would kill that
32471
+ * session's live streamer (React StrictMode's dev double-mount exercises
32472
+ * exactly this ordering). The only unscoped close is an explicit
32473
+ * `{force:true}` input, reserved for a server-initiated admin disconnect.
32474
+ */
32475
+ async viewerStreamClose(profileId, sessionId, input) {
32476
+ const streamer = this.streamers.get(profileId);
32477
+ if (streamer) {
32478
+ const force = input?.force === true;
32479
+ if (!force && streamer.sessionId !== sessionId) {
32480
+ return { ok: true, stale: true };
32481
+ }
32482
+ try {
32483
+ await this.streamerCommand(streamer, "stop", {});
32484
+ } catch (err) {
32485
+ this.host.log.warn(`[bb-viewer] stop command failed for ${profileId} (killing anyway): ${formatErrorForLog(err)}`);
32486
+ }
32487
+ this.killProfileStreamer(profileId);
32488
+ }
32489
+ return { ok: true };
32490
+ }
32491
+ /**
32492
+ * stream.switch — point the existing streamer at a different account-scoped
32493
+ * tab. input {tab} is the bb-browser short tab id / target id. Guarded by
32494
+ * sessionId like stream.answer: a superseded session must not steer the
32495
+ * current streamer, and an EMPTY sessionId is rejected the same way — every
32496
+ * real caller has its session id by the time it can switch (it arrives with
32497
+ * the stream.start response), so a session-less switch is by definition not
32498
+ * the current viewer.
32499
+ */
32500
+ async viewerStreamSwitch(profileId, sessionId, input) {
32501
+ const streamer = this.streamers.get(profileId);
32502
+ if (!streamer)
32503
+ throw new Error("no active viewer streamer for profile");
32504
+ if (!sessionId || streamer.sessionId !== sessionId)
32505
+ throw new Error("stale viewer session");
32506
+ const tab = input?.tab;
32507
+ if (tab === void 0 || tab === null || tab === "") {
32508
+ throw new Error("stream.switch requires input.tab");
32509
+ }
32510
+ const cdpUrl = await this.host.withDaemonRecovery(() => this.resolveAccountPageCdpUrl(profileId, tab), `viewer stream.switch ${profileId}`);
32511
+ await this.bringTargetToFront(cdpUrl);
32512
+ const result = await this.streamerCommand(streamer, "switch", { cdpUrl });
32513
+ streamer.streamedTabId = targetIdFromCdpUrl(cdpUrl);
32514
+ return { ...result, streamed_tab_id: streamer.streamedTabId };
32515
+ }
32516
+ /**
32517
+ * close — close an account tab via bb-browser, then, if the closed tab was
32518
+ * the one being streamed, re-point the streamer at the profile's next active
32519
+ * tab. Without the re-switch the streamer stays bound to a destroyed CDP
32520
+ * target and the viewer goes black. Returns `streamed_tab_id` when a
32521
+ * re-switch happened so the client can adopt the new active tab.
32522
+ */
32523
+ async viewerCloseTab(profileId, sessionId, input) {
32524
+ const ref = viewerTabRef(input ?? {});
32525
+ const closedTargetId = ref !== void 0 ? await this.accountTabTargetId(profileId, ref) : await this.accountActivePageTargetId(profileId);
32526
+ const result = await this.viewerForwardNav(profileId, sessionId, "close", {
32527
+ ...input ?? {},
32528
+ tabId: closedTargetId
32529
+ });
32530
+ const streamer = this.streamers.get(profileId);
32531
+ if (!streamer || !streamer.streamedTabId || streamer.streamedTabId !== closedTargetId) {
32532
+ return result;
32533
+ }
32534
+ const cdpUrl = await this.host.withDaemonRecovery(() => this.resolveAccountPageCdpUrl(profileId), `viewer close re-switch ${profileId}`);
32535
+ await this.bringTargetToFront(cdpUrl);
32536
+ const sw = await this.streamerCommand(streamer, "switch", { cdpUrl });
32537
+ streamer.streamedTabId = targetIdFromCdpUrl(cdpUrl);
32538
+ return { ...result, ...sw, streamed_tab_id: streamer.streamedTabId };
32539
+ }
32540
+ /**
32541
+ * Bring a CDP page target to the foreground via Chrome's DevTools HTTP
32542
+ * endpoint (`/json/activate/{targetId}`, same host:port as the CDP ws). Chrome
32543
+ * does not composite backgrounded tabs, so `Page.startScreencast` on a
32544
+ * background tab yields zero frames (black viewer). Best-effort: a failure
32545
+ * must not block the stream. host:port + targetId are parsed from the cdpUrl
32546
+ * (`ws://host:port/devtools/page/<targetId>`) to avoid an extra /status call.
32547
+ */
32548
+ async bringTargetToFront(cdpUrl) {
32549
+ const m = /^ws:\/\/([^/]+)\/devtools\/page\/(.+)$/.exec(cdpUrl);
32550
+ if (!m)
32551
+ return;
32552
+ const [, hostPort, targetId] = m;
32553
+ try {
32554
+ const resp = await fetch(`http://${hostPort}/json/activate/${targetId}`, {
32555
+ method: "GET",
32556
+ signal: AbortSignal.timeout(5e3)
32557
+ });
32558
+ if (!resp.ok) {
32559
+ this.host.log.warn(`[bb-viewer] activate target ${targetId} returned ${resp.status} (continuing)`);
32560
+ }
32561
+ } catch (err) {
32562
+ this.host.log.warn(`[bb-viewer] activate target ${targetId} failed (continuing): ${formatErrorForLog(err)}`);
32563
+ }
32564
+ }
32565
+ /**
32566
+ * Forward a navigation command (tab_list/tab_new/open/reload/back/forward/
32567
+ * close) to bb-browser scoped to the profile's account. This lets the human
32568
+ * in the live viewer navigate and handle OAuth popups directly. Reuses the
32569
+ * same account-scoped sendCommand path the agent invokes use.
32570
+ *
32571
+ * SECURITY — the `account` field alone does NOT scope tab addressing:
32572
+ * bb-browser's ensurePageTarget resolves a client-supplied tab/tabId against
32573
+ * EVERY page target in the shared Chrome (all accounts), and tab_list
32574
+ * returns every tab with `account` as a mere annotation (verified in
32575
+ * @pinixai/bb-browser-pro@0.15.0 dist/daemon.js ensurePageTarget/tab_list).
32576
+ * On a multi-profile host that would let one profile's viewer list,
32577
+ * navigate, and close other profiles' logged-in tabs. So before forwarding:
32578
+ * (a) any tab ref must resolve through accountTabTargetId, which throws on
32579
+ * tabs the profile's account does not own; (b) ref-less tab-addressed
32580
+ * commands are pinned to the profile's own active tab instead of
32581
+ * bb-browser's account-blind global current tab; (c) tab_list output is
32582
+ * filtered to the profile's own rows; and (d) open/tab_new URLs must be
32583
+ * http(s)/about:blank — file:// or chrome:// would read the host
32584
+ * filesystem / browser internals straight into the video stream.
32585
+ */
32586
+ async viewerForwardNav(profileId, sessionId, command, input) {
32587
+ if (command !== "tab_list") {
32588
+ const streamer = this.streamers.get(profileId);
32589
+ if (streamer && streamer.sessionId !== sessionId) {
32590
+ throw new Error("stale viewer session");
32591
+ }
32592
+ }
32593
+ return this.host.withDaemonRecovery(async () => {
32594
+ await this.host.ensureAccount(profileId);
32595
+ const request3 = { ...input ?? {} };
32596
+ if (command === "open" || command === "tab_new") {
32597
+ assertViewerNavigableUrl(request3.url);
32598
+ }
32599
+ const ref = viewerTabRef(request3);
32600
+ if (ref !== void 0) {
32601
+ request3.tabId = await this.accountTabTargetId(profileId, ref);
32602
+ delete request3.tab;
32603
+ } else if (REFLESS_PIN_NAV.has(command)) {
32604
+ request3.tabId = await this.accountActivePageTargetId(profileId);
32605
+ }
32606
+ const result = await this.host.sendBrowserCommand({
32607
+ ...request3,
32608
+ method: command,
32609
+ account: profileId
32610
+ });
32611
+ return command === "tab_list" ? filterTabListToAccount(result, profileId) : result;
32612
+ }, `viewer ${command} ${profileId}`);
32613
+ }
32614
+ /**
32615
+ * Resolve `ws://<cdpHost>:<cdpPort>/devtools/page/<targetId>` for a PAGE
32616
+ * target owned by THIS profile's account — NOT bb-browser's global current
32617
+ * tab (which `getCurrentTabCdpUrl`/`getTabCdpUrl` in the bundled daemon use
32618
+ * via `cdp.currentTargetId`, an account-blind global; see daemon.js
32619
+ * line ~10389/10410). When `tabRef` is given (stream.switch) we resolve that
32620
+ * specific account tab; otherwise the profile's active page tab.
32621
+ *
32622
+ * Evidence from the installed @pinixai/bb-browser-pro@0.15.0 dist/daemon.js:
32623
+ * - GET /status (handleStatus, line ~1659-1668) returns `cdpHost` + `cdpPort`
32624
+ * — the CDP host/port the daemon's Chrome listens on.
32625
+ * - The CDP page ws URL format is `ws://<cdp.host>:<cdp.port>/devtools/page/<page.id>`
32626
+ * (getCurrentTabCdpUrl, line ~10392), where `page.id` is the full CDP
32627
+ * targetId (getTargets maps `t.targetId` → `.id`, line ~2461-2466).
32628
+ * - tab_list (command handler, line ~1041-1059) returns per-tab
32629
+ * `{ tabId: t.id, tab: <shortId>, account: <accountName>, url, ... }`, so
32630
+ * `tabId` is exactly the full targetId for the devtools URL and `account`
32631
+ * is the per-tab attribution we filter on (reused by findAccountTabOnHost).
32632
+ */
32633
+ async resolveAccountPageCdpUrl(profileId, tabRef) {
32634
+ await this.host.ensureAccount(profileId);
32635
+ const { host, port } = await this.host.cdpEndpoint();
32636
+ const targetId = tabRef !== void 0 ? await this.accountTabTargetId(profileId, tabRef) : await this.accountActivePageTargetId(profileId);
32637
+ return `ws://${host}:${port}/devtools/page/${targetId}`;
32638
+ }
32639
+ /**
32640
+ * Full CDP targetId of the profile account's active page tab (the owned tab
32641
+ * Chrome marks active, else the first owned tab). If the account owns no
32642
+ * page tab yet, open `about:blank` for it first (mirrors the agent-invoke
32643
+ * path's "always give a fresh account an owned tab" rule).
32644
+ */
32645
+ async accountActivePageTargetId(profileId) {
32646
+ let targetId = await this.firstAccountPageTargetId(profileId);
32647
+ if (targetId !== void 0)
32648
+ return targetId;
32649
+ await this.host.sendBrowserCommand({
32650
+ method: "tab_new",
32651
+ account: profileId,
32652
+ url: "about:blank"
32653
+ });
32654
+ targetId = await this.firstAccountPageTargetId(profileId);
32655
+ if (targetId === void 0) {
32656
+ throw new Error(`no page tab found for profile account ${profileId}`);
32657
+ }
32658
+ return targetId;
32659
+ }
32660
+ /**
32661
+ * Full CDP targetId (`tabId`) of the account's active page tab: the owned tab
32662
+ * Chrome marks `active` when it belongs to this account, else the first owned
32663
+ * tab (the global active flag is account-blind — another profile's tab may
32664
+ * hold it, which must not leak here).
32665
+ */
32666
+ async firstAccountPageTargetId(profileId) {
32667
+ const tabs = await this.accountTabs(profileId);
32668
+ let firstOwned;
32669
+ for (const t of tabs) {
32670
+ if (t.account !== profileId)
32671
+ continue;
32672
+ const tabId = t.tabId;
32673
+ if (typeof tabId !== "string" || !tabId)
32674
+ continue;
32675
+ if (t.active === true)
32676
+ return tabId;
32677
+ firstOwned ??= tabId;
32678
+ }
32679
+ return firstOwned;
32680
+ }
32681
+ /**
32682
+ * Full CDP targetId for a specific account tab, addressed by the bb-browser
32683
+ * short tab id (`tab`) or full target id (`tabId`). Used by stream.switch.
32684
+ */
32685
+ async accountTabTargetId(profileId, tabRef) {
32686
+ const ref = String(tabRef);
32687
+ const tabs = await this.accountTabs(profileId);
32688
+ for (const t of tabs) {
32689
+ if (t.account !== profileId)
32690
+ continue;
32691
+ const tabId = typeof t.tabId === "string" ? t.tabId : void 0;
32692
+ const shortTab = t.tab === void 0 ? void 0 : String(t.tab);
32693
+ if (tabId && tabId === ref || shortTab && shortTab === ref) {
32694
+ if (!tabId)
32695
+ break;
32696
+ return tabId;
32697
+ }
32698
+ }
32699
+ throw new Error(`tab ${ref} not found for profile account ${profileId}`);
32700
+ }
32701
+ /** `tab_list {account}` rows, typed to the fields we read (tabId/tab/account/active). */
32702
+ async accountTabs(profileId) {
32703
+ const list = await this.host.sendBrowserCommand({ method: "tab_list", account: profileId });
32704
+ return Array.isArray(list.tabs) ? list.tabs : [];
32705
+ }
32706
+ // ---- bb-viewer streamer lifecycle ----
32707
+ /**
32708
+ * Spawn a bb-viewer streamer for the profile and wait until healthy. Binary
32709
+ * is PRLL_BB_VIEWER_BIN (our image sets it to /usr/local/bin/bb-viewer),
32710
+ * defaulting to `bb-viewer` on PATH — we NEVER download it. A free port is
32711
+ * allocated per streamer. Stores the entry in `this.streamers` once healthy.
32712
+ */
32713
+ async spawnStreamer(profileId, sessionId, turn) {
32714
+ if (this.stopping)
32715
+ throw new Error("viewer streamer is shutting down");
32716
+ const bin = process.env.PRLL_BB_VIEWER_BIN ?? "bb-viewer";
32717
+ const port = await findFreePort();
32718
+ const args = ["--api-only", "--port", String(port)];
32719
+ const bindHost = process.env.PRLL_BB_VIEWER_HOST?.trim();
32720
+ if (bindHost) {
32721
+ if (!isLoopbackBindHost(bindHost)) {
32722
+ throw new Error(`PRLL_BB_VIEWER_HOST must be loopback (127.0.0.1, localhost, ::1, [::1]) \u2014 refusing to bind bb-viewer's unauthenticated /command to ${bindHost}`);
32723
+ }
32724
+ args.push("--host", bindHost);
32725
+ }
32726
+ if (turn?.url) {
32727
+ args.push("--turn-url", turn.url, "--turn-user", turn.username ?? "", "--turn-cred", turn.credential ?? "");
32728
+ }
32729
+ const streamerEnv = {};
32730
+ for (const key of ["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "DISPLAY"]) {
32731
+ const val = process.env[key];
32732
+ if (val !== void 0)
32733
+ streamerEnv[key] = val;
32734
+ }
32735
+ this.host.log.info(`[bb-viewer] spawning for profile ${profileId} on port ${port}`);
32736
+ const child = spawn3(bin, args, { stdio: ["ignore", "pipe", "pipe"], env: streamerEnv });
32737
+ let childError = null;
32738
+ child.once("error", (err) => {
32739
+ childError = err;
32740
+ });
32741
+ child.once("exit", () => {
32742
+ const current = this.streamers.get(profileId);
32743
+ if (current?.child === child) {
32744
+ this.streamers.delete(profileId);
32745
+ this.host.log.warn(`[bb-viewer] streamer for ${profileId} exited; cleared stale entry`);
32746
+ }
32747
+ });
32748
+ child.stdout?.on("data", (chunk) => {
32749
+ const text = chunk.toString("utf8").trim();
32750
+ if (text)
32751
+ this.host.log.info(`[bb-viewer] ${text}`);
32752
+ });
32753
+ child.stderr?.on("data", (chunk) => {
32754
+ const text = chunk.toString("utf8").trim();
32755
+ if (text)
32756
+ this.host.log.warn(`[bb-viewer] ${text}`);
32757
+ });
32758
+ const deadline = Date.now() + BB_VIEWER_HEALTH_TIMEOUT_MS;
32759
+ while (Date.now() < deadline) {
32760
+ if (this.stopping) {
32761
+ this.killChild(child, "streamer for stopping daemon");
32762
+ throw new Error("viewer streamer is shutting down");
32763
+ }
32764
+ if (childError) {
32765
+ throw childError;
32766
+ }
32767
+ if (child.exitCode !== null || child.signalCode !== null) {
32768
+ throw new Error("bb-viewer exited during startup");
32769
+ }
32770
+ try {
32771
+ const resp = await fetch(`http://127.0.0.1:${port}/health`, {
32772
+ signal: AbortSignal.timeout(2e3)
32773
+ });
32774
+ if (resp.ok) {
32775
+ const streamer = { child, port, sessionId };
32776
+ this.streamers.set(profileId, streamer);
32777
+ this.host.log.info(`[bb-viewer] ready for profile ${profileId} on port ${port}`);
32778
+ return streamer;
32779
+ }
32780
+ } catch {
32781
+ }
32782
+ await sleep(200);
32783
+ }
32784
+ this.killChild(child, "unhealthy streamer");
32785
+ throw new Error("bb-viewer did not become healthy in time");
32786
+ }
32787
+ /**
32788
+ * SIGTERM with a SIGKILL escalation: a bb-viewer that hangs or ignores TERM
32789
+ * would otherwise outlive its teardown holding the port + CDP session (the
32790
+ * shell-level pattern-kill only exists on the hosted pod, not BYOC). The
32791
+ * escalation timer is unref'd so it never holds the daemon open; it is
32792
+ * cleared on exit. Caveat: a daemon process that exits immediately after
32793
+ * shutdown() abandons the timer — acceptable, the TERM was still sent.
32794
+ */
32795
+ killChild(child, label) {
32796
+ if (child.exitCode !== null || child.signalCode !== null)
32797
+ return;
32798
+ try {
32799
+ child.kill("SIGTERM");
32800
+ } catch {
32801
+ return;
32802
+ }
32803
+ const killTimer = setTimeout(() => {
32804
+ if (child.exitCode === null && child.signalCode === null) {
32805
+ this.host.log.warn(`[bb-viewer] ${label} ignored SIGTERM; escalating to SIGKILL`);
32806
+ try {
32807
+ child.kill("SIGKILL");
32808
+ } catch {
32809
+ }
32810
+ }
32811
+ }, BB_VIEWER_TERM_GRACE_MS);
32812
+ killTimer.unref?.();
32813
+ child.once("exit", () => clearTimeout(killTimer));
32814
+ }
32815
+ /**
32816
+ * POST a command to the profile's bb-viewer `/command` endpoint. Parses the
32817
+ * `{result}` / `{error:{message}}` envelope (bb-viewer api.go) and returns
32818
+ * the inner result, throwing the error message on failure.
32819
+ */
32820
+ async streamerCommand(streamer, method, params) {
32821
+ const controller = new AbortController();
32822
+ const timer = setTimeout(() => controller.abort(), BB_VIEWER_COMMAND_TIMEOUT_MS);
32823
+ try {
32824
+ const resp = await fetch(`http://127.0.0.1:${streamer.port}/command`, {
32825
+ method: "POST",
32826
+ headers: { "Content-Type": "application/json" },
32827
+ body: JSON.stringify({ method, params }),
32828
+ signal: controller.signal
32829
+ });
32830
+ const text = await resp.text();
32831
+ const parsed = text ? JSON.parse(text) : {};
32832
+ if (parsed.error) {
32833
+ throw new Error(parsed.error.message || `bb-viewer ${method} failed`);
32834
+ }
32835
+ if (!resp.ok) {
32836
+ throw new Error(`bb-viewer ${method} returned ${resp.status}: ${text}`);
32837
+ }
32838
+ return parsed.result ?? {};
32839
+ } finally {
32840
+ clearTimeout(timer);
32841
+ }
32842
+ }
32843
+ /**
32844
+ * Kill + remove the profile's bb-viewer streamer, if any. Idempotent. Also
32845
+ * clears any kick marker for the profile: a (re)kill establishes a clean
32846
+ * streamer slot, so a subsequent stream.start starts un-kicked. viewerKick
32847
+ * re-records the kicked session AFTER calling this.
32848
+ */
32849
+ killProfileStreamer(profileId) {
32850
+ this.kickedSessions.delete(profileId);
32851
+ const streamer = this.streamers.get(profileId);
32852
+ if (!streamer)
32853
+ return;
32854
+ this.streamers.delete(profileId);
32855
+ this.killChild(streamer.child, `streamer for ${profileId}`);
32856
+ }
32857
+ /**
32858
+ * Tear down every profile streamer and refuse new spawns (daemon stop).
32859
+ * Idempotent.
32860
+ */
32861
+ shutdown() {
32862
+ this.stopping = true;
32863
+ for (const profileId of [...this.streamers.keys()]) {
32864
+ this.killProfileStreamer(profileId);
32865
+ }
32866
+ }
32867
+ };
32868
+ function targetIdFromCdpUrl(cdpUrl) {
32869
+ return /\/devtools\/page\/(.+)$/.exec(cdpUrl)?.[1];
32870
+ }
32871
+ var REFLESS_PIN_NAV = /* @__PURE__ */ new Set(["reload", "back", "forward", "close"]);
32872
+ function viewerTabRef(request3) {
32873
+ for (const key of ["tabId", "tab"]) {
32874
+ const v = request3[key];
32875
+ if (typeof v === "string" && v !== "" || typeof v === "number")
32876
+ return String(v);
32877
+ }
32878
+ return void 0;
32879
+ }
32880
+ var VIEWER_NAVIGABLE_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
32881
+ function assertViewerNavigableUrl(raw) {
32882
+ if (raw === void 0 || raw === null || raw === "")
32883
+ return;
32884
+ if (typeof raw !== "string")
32885
+ throw new Error("url must be a string");
32886
+ if (raw === "about:blank")
32887
+ return;
32888
+ let parsed;
32889
+ try {
32890
+ parsed = new URL(raw);
32891
+ } catch {
32892
+ throw new Error(`invalid url: ${raw}`);
32893
+ }
32894
+ if (!VIEWER_NAVIGABLE_PROTOCOLS.has(parsed.protocol)) {
32895
+ throw new Error(`url scheme not allowed in the live viewer: ${parsed.protocol.replace(/:$/, "")}`);
32896
+ }
32897
+ }
32898
+ function filterTabListToAccount(result, account) {
32899
+ const tabs = Array.isArray(result.tabs) ? result.tabs : [];
32900
+ const owned = tabs.filter((t) => t?.account === account);
32901
+ return { ...result, tabs: owned, activeIndex: owned.findIndex((t) => t?.active === true) };
32902
+ }
32903
+
32904
+ // ts/daemon/dist/clip-runtime/browser-profile-manager.js
32011
32905
  var BB_BROWSER_DAEMON_START_TIMEOUT_MS = 15e3;
32012
32906
  var BrowserCommandError = class extends Error {
32013
32907
  method;
@@ -32027,9 +32921,20 @@ var BrowserProfileManager = class {
32027
32921
  ensuredAccounts = /* @__PURE__ */ new Set();
32028
32922
  ensuringAccounts = /* @__PURE__ */ new Map();
32029
32923
  reportedStatuses = /* @__PURE__ */ new Map();
32924
+ // Live viewer orchestration (bb-viewer streamer subprocesses + viewer nav)
32925
+ // lives in BrowserViewerStreamer; the manager hands it the account-scoped
32926
+ // bb-browser command path via the host interface.
32927
+ viewer;
32030
32928
  constructor(opts) {
32031
32929
  this.opts = opts;
32032
32930
  mkdirSync5(this.opts.homeDir, { recursive: true });
32931
+ this.viewer = new BrowserViewerStreamer({
32932
+ log: this.opts.log,
32933
+ sendBrowserCommand: (request3) => this.sendCommand(request3),
32934
+ ensureAccount: (account) => this.ensureAccount(account),
32935
+ withDaemonRecovery: (operation, label) => this.withDaemonRecovery(operation, label),
32936
+ cdpEndpoint: () => this.cdpEndpoint()
32937
+ });
32033
32938
  }
32034
32939
  async invoke({ profileId, account, command, input }) {
32035
32940
  if (!profileId)
@@ -32096,12 +33001,14 @@ var BrowserProfileManager = class {
32096
33001
  if (!profileId)
32097
33002
  throw new Error("browser profile id is required");
32098
33003
  this.reportedStatuses.delete(profileId);
33004
+ this.viewer.killProfileStreamer(profileId);
32099
33005
  this.reportStatus(profileId, "stopped");
32100
33006
  }
32101
33007
  async resetProfile(profileId) {
32102
33008
  if (!profileId)
32103
33009
  throw new Error("browser profile id is required");
32104
33010
  this.reportedStatuses.delete(profileId);
33011
+ this.viewer.killProfileStreamer(profileId);
32105
33012
  try {
32106
33013
  await this.withDaemonRecovery(() => this.resetProfileOnce(profileId), `reset profile ${profileId}`);
32107
33014
  this.reportStatus(profileId, "stopped");
@@ -32112,6 +33019,7 @@ var BrowserProfileManager = class {
32112
33019
  }
32113
33020
  }
32114
33021
  async stop() {
33022
+ this.viewer.shutdown();
32115
33023
  const daemon = this.daemon;
32116
33024
  this.daemon = null;
32117
33025
  this.starting = null;
@@ -32128,6 +33036,25 @@ var BrowserProfileManager = class {
32128
33036
  daemon.child.kill("SIGTERM");
32129
33037
  }
32130
33038
  }
33039
+ /**
33040
+ * Viewer control command entrypoint — delegates to BrowserViewerStreamer
33041
+ * (browser-viewer-streamer.ts), which owns the per-profile bb-viewer
33042
+ * subprocesses and viewer tab navigation.
33043
+ */
33044
+ async handleViewerCommand(profileId, sessionId, command, input, turn) {
33045
+ return this.viewer.handleViewerCommand(profileId, sessionId, command, input, turn);
33046
+ }
33047
+ /** Read cdpHost/cdpPort from the bb-browser daemon `GET /status`. */
33048
+ async cdpEndpoint() {
33049
+ const daemon = await this.ensureDaemon();
33050
+ const status = await this.post("/status", daemon, void 0, 5e3);
33051
+ const host = status.cdpHost?.trim();
33052
+ const port = status.cdpPort;
33053
+ if (!host || typeof port !== "number" || !Number.isFinite(port)) {
33054
+ throw new Error("bb-browser /status did not report a CDP endpoint");
33055
+ }
33056
+ return { host, port };
33057
+ }
32131
33058
  async prepareOpenProfile(profileId, url) {
32132
33059
  const accountCreated = await this.ensureAccount(profileId, url !== "about:blank" ? url : void 0);
32133
33060
  await this.sendCommand({ method: "tab_list", account: profileId });
@@ -32386,7 +33313,7 @@ var BrowserProfileManager = class {
32386
33313
  const port = await findFreePort();
32387
33314
  const token = randomToken();
32388
33315
  const daemonPath = resolveBbBrowserDaemonPath();
32389
- const child = spawn3(process.execPath, [daemonPath, "--host", host, "--port", String(port), "--token", token], {
33316
+ const child = spawn4(process.execPath, [daemonPath, "--host", host, "--port", String(port), "--token", token], {
32390
33317
  env: {
32391
33318
  ...process.env,
32392
33319
  BB_BROWSER_HOME: this.opts.homeDir
@@ -32500,10 +33427,6 @@ function comparableUrl(url) {
32500
33427
  return null;
32501
33428
  }
32502
33429
  }
32503
- function formatErrorForLog(err) {
32504
- const message = err instanceof Error ? err.message : String(err);
32505
- return message.replace(/\s+/g, " ").slice(0, 300);
32506
- }
32507
33430
  function resolveBbBrowserDaemonPath() {
32508
33431
  const sibling = path9.join(path9.dirname(fileURLToPath(import.meta.url)), "bb-browser-daemon.js");
32509
33432
  if (existsSync8(sibling))
@@ -32518,29 +33441,9 @@ function resolveBbBrowserDaemonPath() {
32518
33441
  }
32519
33442
  return full;
32520
33443
  }
32521
- async function findFreePort() {
32522
- return new Promise((resolve8, reject) => {
32523
- const server = net.createServer();
32524
- server.unref();
32525
- server.on("error", reject);
32526
- server.listen(0, "127.0.0.1", () => {
32527
- const address = server.address();
32528
- server.close(() => {
32529
- if (address && typeof address === "object") {
32530
- resolve8(address.port);
32531
- } else {
32532
- reject(new Error("failed to allocate free port"));
32533
- }
32534
- });
32535
- });
32536
- });
32537
- }
32538
33444
  function randomToken() {
32539
33445
  return randomBytes(16).toString("hex");
32540
33446
  }
32541
- function sleep(ms) {
32542
- return new Promise((resolve8) => setTimeout(resolve8, ms));
32543
- }
32544
33447
 
32545
33448
  // ts/daemon/dist/supervisor.js
32546
33449
  init_config();
@@ -32940,7 +33843,7 @@ function shellQuote(value) {
32940
33843
  }
32941
33844
 
32942
33845
  // ts/daemon/dist/workspace.js
32943
- import { spawn as spawn4 } from "node:child_process";
33846
+ import { spawn as spawn5 } from "node:child_process";
32944
33847
  import { createHash as createHash3 } from "node:crypto";
32945
33848
  import * as fs11 from "node:fs";
32946
33849
  import * as path12 from "node:path";
@@ -33244,7 +34147,7 @@ function runCommand(cmd, args, cwd, timeoutMs = 12e4, env = process.env) {
33244
34147
  clearTimeout(killTimer);
33245
34148
  fn();
33246
34149
  };
33247
- const child = spawn4(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
34150
+ const child = spawn5(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
33248
34151
  timeoutTimer = setTimeout(() => {
33249
34152
  timedOut = true;
33250
34153
  child.kill("SIGTERM");
@@ -33397,7 +34300,12 @@ var DaemonSupervisor = class {
33397
34300
  children = /* @__PURE__ */ new Map();
33398
34301
  spawningAgents = /* @__PURE__ */ new Set();
33399
34302
  pendingWorkspaceSetup = /* @__PURE__ */ new Set();
33400
- browserProfileLifecycleQueues = /* @__PURE__ */ new Map();
34303
+ // ONE queue per profile for BOTH lifecycle (open/stop/reset) and viewer
34304
+ // (stream.*/nav) commands. They mutate the same streamer + account state, so
34305
+ // two independent queues would let a stop/reset interleave with an in-flight
34306
+ // stream.start (the kill fires before the streamer registers and misses it,
34307
+ // leaving a live streamer on a profile the server just marked stopped).
34308
+ browserProfileOpQueues = /* @__PURE__ */ new Map();
33401
34309
  workspaceSetupRetryTimers = /* @__PURE__ */ new Map();
33402
34310
  cancelledSpawns = /* @__PURE__ */ new Set();
33403
34311
  ws = null;
@@ -33558,6 +34466,10 @@ var DaemonSupervisor = class {
33558
34466
  this.log.info(`WS: browser profile lifecycle requested \u2014 profile=${data.profile_id} action=${data.action}`);
33559
34467
  void this.enqueueBrowserProfileLifecycle(data);
33560
34468
  });
34469
+ this.ws.on("machine.browser_profile.viewer", (data) => {
34470
+ this.log.info(`WS: browser profile viewer command \u2014 profile=${data.profile_id} command=${data.command}`);
34471
+ this.enqueueBrowserProfileViewer(data);
34472
+ });
33561
34473
  this.ws.onStateChange((state) => {
33562
34474
  if (state === "disconnected" || state === "reconnecting") {
33563
34475
  this.log.warn(`machine WS state: ${state}`);
@@ -33707,10 +34619,13 @@ var DaemonSupervisor = class {
33707
34619
  for (const profile of profiles) {
33708
34620
  if (profile.status !== "running" && profile.status !== "pending")
33709
34621
  continue;
34622
+ if (!profile.machine_id)
34623
+ continue;
34624
+ const machineId = profile.machine_id;
33710
34625
  try {
33711
34626
  if (profile.status === "pending") {
33712
34627
  await this.enqueueBrowserProfileLifecycle({
33713
- machine_id: profile.machine_id,
34628
+ machine_id: machineId,
33714
34629
  profile_id: profile.id,
33715
34630
  action: "open"
33716
34631
  });
@@ -33869,17 +34784,45 @@ var DaemonSupervisor = class {
33869
34784
  }
33870
34785
  }
33871
34786
  enqueueBrowserProfileLifecycle(data) {
33872
- const previous = this.browserProfileLifecycleQueues.get(data.profile_id) ?? Promise.resolve();
34787
+ return this.enqueueBrowserProfileOp(data.profile_id, () => this.handleBrowserProfileLifecycle(data));
34788
+ }
34789
+ enqueueBrowserProfileOp(profileId, op) {
34790
+ const previous = this.browserProfileOpQueues.get(profileId) ?? Promise.resolve();
33873
34791
  const next = previous.catch(() => {
33874
- }).then(() => this.handleBrowserProfileLifecycle(data));
33875
- this.browserProfileLifecycleQueues.set(data.profile_id, next);
34792
+ }).then(op);
34793
+ this.browserProfileOpQueues.set(profileId, next);
33876
34794
  void next.finally(() => {
33877
- if (this.browserProfileLifecycleQueues.get(data.profile_id) === next) {
33878
- this.browserProfileLifecycleQueues.delete(data.profile_id);
34795
+ if (this.browserProfileOpQueues.get(profileId) === next) {
34796
+ this.browserProfileOpQueues.delete(profileId);
33879
34797
  }
33880
34798
  });
33881
34799
  return next;
33882
34800
  }
34801
+ /**
34802
+ * Handle one viewer control command and reply via REST. Always answers the
34803
+ * request/reply bridge exactly once ({result} on success, {error:{message}}
34804
+ * on failure) and never throws into the WS event loop — mirrors
34805
+ * handleFilesystemBrowse. Serialized per profile by enqueueBrowserProfileViewer.
34806
+ */
34807
+ async handleBrowserProfileViewer(data) {
34808
+ if (!this.browserProfileManager) {
34809
+ await this.client.postBrowserProfileViewerResponse(data.request_id, {
34810
+ error: { message: "Browser profile runtime is disabled on this daemon" }
34811
+ }).catch((err) => this.log.warn(`browser profile viewer negative ACK failed: ${String(err)}`));
34812
+ return;
34813
+ }
34814
+ try {
34815
+ const result = await this.browserProfileManager.handleViewerCommand(data.profile_id, data.session_id, data.command, data.input, data.turn);
34816
+ await this.client.postBrowserProfileViewerResponse(data.request_id, { result });
34817
+ } catch (err) {
34818
+ const message = err instanceof Error ? err.message : String(err);
34819
+ this.log.warn(`browser profile viewer command failed (profile=${data.profile_id}, command=${data.command}): ${message}`);
34820
+ await this.client.postBrowserProfileViewerResponse(data.request_id, { error: { message } }).catch((replyErr) => this.log.warn(`browser profile viewer error ACK failed: ${String(replyErr)}`));
34821
+ }
34822
+ }
34823
+ enqueueBrowserProfileViewer(data) {
34824
+ void this.enqueueBrowserProfileOp(data.profile_id, () => this.handleBrowserProfileViewer(data));
34825
+ }
33883
34826
  async handleClipSync(_data) {
33884
34827
  await this.reconcileMachineClips();
33885
34828
  }
@@ -33938,7 +34881,7 @@ var DaemonSupervisor = class {
33938
34881
  }
33939
34882
  if (changed) {
33940
34883
  this.log.info(`machine clips synced \u2014 count=${desired.length}`);
33941
- await this.reconnectClipProvider();
34884
+ this.clipProvider?.reregister();
33942
34885
  }
33943
34886
  }
33944
34887
  startClipReconcileTimer() {
@@ -34067,15 +35010,6 @@ var DaemonSupervisor = class {
34067
35010
  }
34068
35011
  return null;
34069
35012
  }
34070
- async reconnectClipProvider() {
34071
- if (!this.clipProvider)
34072
- return;
34073
- const provider = this.clipProvider;
34074
- this.clipProvider = null;
34075
- this.connectedClipServiceUrl = null;
34076
- await provider.disconnect().catch((err) => this.log.warn(`clip provider reconnect disconnect failed: ${String(err)}`));
34077
- await this.applyClipProviderState();
34078
- }
34079
35013
  async handleWorkspaceSetupRequested(agentId) {
34080
35014
  if (this.spawningAgents.has(agentId)) {
34081
35015
  this.log.info(`agent ${agentId}: workspace setup already in progress; queueing one restart`);
@@ -34356,7 +35290,7 @@ var DaemonSupervisor = class {
34356
35290
  const env = adapter.buildEnv(baseEnv, state.agentId, state.orgId, state.credential.api_key, dirs, effectiveProviderConfig);
34357
35291
  const spawnCmd = adapter.args.length > 0 ? `${adapter.bin} ${adapter.args.join(" ")}` : adapter.bin;
34358
35292
  this.log.info(`spawning agent ${state.agentId} runtime=${state.runtimeType} cmd=${spawnCmd} (attempt ${state.restartAttempts + 1})`);
34359
- const child = spawn5(adapter.bin, adapter.args, {
35293
+ const child = spawn6(adapter.bin, adapter.args, {
34360
35294
  env,
34361
35295
  stdio: ["ignore", "inherit", "inherit"],
34362
35296
  detached: false