@parall/daemon 1.35.0 → 1.36.1

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.
@@ -27256,6 +27256,17 @@ var ENDPOINTS = {
27256
27256
  SCHEDULE_CANCEL: (orgId, id) => `${API_BASE}/orgs/${orgId}/schedules/${id}/cancel`,
27257
27257
  SCHEDULE_RUNS: (orgId, id) => `${API_BASE}/orgs/${orgId}/schedules/${id}/runs`,
27258
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}`,
27259
27270
  // Invitations (org-scoped, admin)
27260
27271
  ORG_INVITATIONS: (orgId) => `${API_BASE}/orgs/${orgId}/invitations`,
27261
27272
  ORG_INVITATION: (orgId, invId) => `${API_BASE}/orgs/${orgId}/invitations/${invId}`,
@@ -27337,6 +27348,7 @@ var ENDPOINTS = {
27337
27348
  // References (org-scoped)
27338
27349
  REFS_RESOLVE: (orgId) => `${API_BASE}/orgs/${orgId}/refs/resolve`,
27339
27350
  REFS_BACKLINKS: (orgId) => `${API_BASE}/orgs/${orgId}/refs/backlinks`,
27351
+ REFS_GRAPH: (orgId) => `${API_BASE}/orgs/${orgId}/refs/graph`,
27340
27352
  REFS_CHECK: (orgId) => `${API_BASE}/orgs/${orgId}/refs/check`,
27341
27353
  // Platform config (agent-scoped, not org-scoped)
27342
27354
  PLATFORM_CONFIG: `${API_BASE}/agents/platform-config`,
@@ -27465,6 +27477,7 @@ var WS_EVENTS = {
27465
27477
  // ts/sdk/dist/client.js
27466
27478
  var ParallClient = class _ParallClient {
27467
27479
  baseUrl;
27480
+ wikiBaseUrl;
27468
27481
  token;
27469
27482
  onTokenExpired;
27470
27483
  getRefreshToken;
@@ -27516,12 +27529,22 @@ var ParallClient = class _ParallClient {
27516
27529
  }
27517
27530
  constructor(options = {}) {
27518
27531
  this.baseUrl = options.baseUrl ?? "";
27532
+ this.wikiBaseUrl = options.wikiBaseUrl ?? this.baseUrl;
27519
27533
  this.token = options.token ?? null;
27520
27534
  this.onTokenExpired = options.onTokenExpired;
27521
27535
  this.getRefreshToken = options.getRefreshToken;
27522
27536
  this.setTokens = options.setTokens;
27523
27537
  this.swimlaneName = options.swimlaneName;
27524
27538
  }
27539
+ /**
27540
+ * Pick the origin for a request path: wiki-service base for `/wiki/v1`
27541
+ * endpoints, api base for everything else. The path itself (from ENDPOINTS)
27542
+ * is authoritative, so wiki vs api routing can't drift from how a caller
27543
+ * happens to invoke the client.
27544
+ */
27545
+ baseUrlFor(path15) {
27546
+ return path15.startsWith(WIKI_BASE) ? this.wikiBaseUrl : this.baseUrl;
27547
+ }
27525
27548
  setToken(token) {
27526
27549
  this.token = token;
27527
27550
  }
@@ -27586,7 +27609,7 @@ var ParallClient = class _ParallClient {
27586
27609
  if (!retried) {
27587
27610
  await this.ensureFreshToken(path15);
27588
27611
  }
27589
- let url = `${this.baseUrl}${path15}`;
27612
+ let url = `${this.baseUrlFor(path15)}${path15}`;
27590
27613
  if (query) {
27591
27614
  const params = new URLSearchParams();
27592
27615
  for (const [key, value] of Object.entries(query)) {
@@ -27658,7 +27681,7 @@ var ParallClient = class _ParallClient {
27658
27681
  void _drop;
27659
27682
  let res;
27660
27683
  try {
27661
- res = await fetch(`${this.baseUrl}${path15}`, {
27684
+ res = await fetch(`${this.baseUrlFor(path15)}${path15}`, {
27662
27685
  method,
27663
27686
  headers,
27664
27687
  body,
@@ -27818,6 +27841,21 @@ var ParallClient = class _ParallClient {
27818
27841
  q.limit = String(params.limit);
27819
27842
  return this.request("GET", ENDPOINTS.ORG_MEMBER_TASKS(orgId, memberId), void 0, q);
27820
27843
  }
27844
+ // Auto-paginated variant of getMemberTasks: fetches ALL pending tasks
27845
+ // (todo + in_progress) assigned to a member, including subtasks (the
27846
+ // endpoint does not filter parent_id). Powers the CLI `tasks assigned`
27847
+ // command so an agent answering "what's on X's plate" sees the full
27848
+ // backlog, not just the first page.
27849
+ async getMemberTasksAll(orgId, memberId) {
27850
+ const all = [];
27851
+ let cursor;
27852
+ do {
27853
+ const res = await this.getMemberTasks(orgId, memberId, { cursor, limit: 100 });
27854
+ all.push(...res.data);
27855
+ cursor = res.has_more ? res.next_cursor : void 0;
27856
+ } while (cursor);
27857
+ return all;
27858
+ }
27821
27859
  // ---- Invitations ----
27822
27860
  async createInvitation(orgId, email, role) {
27823
27861
  return this.request("POST", ENDPOINTS.ORG_INVITATIONS(orgId), { email, role });
@@ -28247,7 +28285,8 @@ var ParallClient = class _ParallClient {
28247
28285
  const res = await this.request("GET", ENDPOINTS.MACHINES_ME_CLIPS);
28248
28286
  return res.data;
28249
28287
  }
28250
- /** `GET /machines/me/browser-profiles` — browser profiles hosted by this machine. */
28288
+ /** `GET /machines/me/browser-profiles` — browser profiles hosted by this machine.
28289
+ * Returns the daemon DTO (carries proxy_password for bb-browser replay). */
28251
28290
  async listMachineBrowserProfiles() {
28252
28291
  const res = await this.request("GET", ENDPOINTS.MACHINES_ME_BROWSER_PROFILES);
28253
28292
  return res.data;
@@ -28407,7 +28446,7 @@ var ParallClient = class _ParallClient {
28407
28446
  * Returns null when the server responds with 304 (config unchanged).
28408
28447
  */
28409
28448
  async getPlatformConfig(currentVersion) {
28410
- const url = `${this.baseUrl}${ENDPOINTS.PLATFORM_CONFIG}`;
28449
+ const url = `${this.baseUrlFor(ENDPOINTS.PLATFORM_CONFIG)}${ENDPOINTS.PLATFORM_CONFIG}`;
28411
28450
  const extra = {};
28412
28451
  if (currentVersion !== void 0) {
28413
28452
  extra["If-None-Match"] = currentVersion;
@@ -28573,6 +28612,55 @@ var ParallClient = class _ParallClient {
28573
28612
  async getScheduleRun(orgId, runId) {
28574
28613
  return this.request("GET", ENDPOINTS.SCHEDULE_RUN(orgId, runId));
28575
28614
  }
28615
+ // ---- External triggers (org-scoped) ----
28616
+ async createExternalConnection(orgId, input) {
28617
+ return this.request("POST", ENDPOINTS.EXTERNAL_CONNECTIONS(orgId), input);
28618
+ }
28619
+ async listExternalConnections(orgId, filters) {
28620
+ return this.request("GET", ENDPOINTS.EXTERNAL_CONNECTIONS(orgId), void 0, filters);
28621
+ }
28622
+ async getExternalConnection(orgId, connectionId) {
28623
+ return this.request("GET", ENDPOINTS.EXTERNAL_CONNECTION(orgId, connectionId));
28624
+ }
28625
+ async updateExternalConnection(orgId, connectionId, patch) {
28626
+ return this.request("PATCH", ENDPOINTS.EXTERNAL_CONNECTION(orgId, connectionId), patch);
28627
+ }
28628
+ async regenerateExternalConnectionIngressToken(orgId, connectionId) {
28629
+ return this.request("POST", ENDPOINTS.EXTERNAL_CONNECTION_INGRESS_TOKEN_REGENERATE(orgId, connectionId));
28630
+ }
28631
+ async deleteExternalConnection(orgId, connectionId) {
28632
+ return this.request("DELETE", ENDPOINTS.EXTERNAL_CONNECTION(orgId, connectionId));
28633
+ }
28634
+ async getExternalTriggerSchema(orgId, connectionId) {
28635
+ return this.request("GET", ENDPOINTS.EXTERNAL_TRIGGER_SCHEMA(orgId, connectionId));
28636
+ }
28637
+ async listExternalIngressEvents(orgId, filters) {
28638
+ return this.request("GET", ENDPOINTS.EXTERNAL_INGRESS_EVENTS(orgId), void 0, filters);
28639
+ }
28640
+ async getExternalIngressEvent(orgId, eventId) {
28641
+ return this.request("GET", ENDPOINTS.EXTERNAL_INGRESS_EVENT(orgId, eventId));
28642
+ }
28643
+ async createExternalTrigger(orgId, input) {
28644
+ return this.request("POST", ENDPOINTS.EXTERNAL_TRIGGERS(orgId), input);
28645
+ }
28646
+ async listExternalTriggers(orgId, filters) {
28647
+ return this.request("GET", ENDPOINTS.EXTERNAL_TRIGGERS(orgId), void 0, filters);
28648
+ }
28649
+ async getExternalTrigger(orgId, triggerId) {
28650
+ return this.request("GET", ENDPOINTS.EXTERNAL_TRIGGER(orgId, triggerId));
28651
+ }
28652
+ async updateExternalTrigger(orgId, triggerId, patch) {
28653
+ return this.request("PATCH", ENDPOINTS.EXTERNAL_TRIGGER(orgId, triggerId), patch);
28654
+ }
28655
+ async deleteExternalTrigger(orgId, triggerId) {
28656
+ return this.request("DELETE", ENDPOINTS.EXTERNAL_TRIGGER(orgId, triggerId));
28657
+ }
28658
+ async listExternalTriggerRuns(orgId, filters) {
28659
+ return this.request("GET", ENDPOINTS.EXTERNAL_TRIGGER_RUNS(orgId), void 0, filters);
28660
+ }
28661
+ async getExternalTriggerRun(orgId, runId) {
28662
+ return this.request("GET", ENDPOINTS.EXTERNAL_TRIGGER_RUN(orgId, runId));
28663
+ }
28576
28664
  // ---- Wikis (org-scoped) ----
28577
28665
  async createWiki(orgId, data) {
28578
28666
  return this.request("POST", ENDPOINTS.WIKIS(orgId), data);
@@ -28587,8 +28675,35 @@ var ParallClient = class _ParallClient {
28587
28675
  async getWikiTree(orgId, wikiId, params) {
28588
28676
  return this.request("GET", ENDPOINTS.WIKI_TREE(orgId, wikiId), void 0, params);
28589
28677
  }
28678
+ /**
28679
+ * Resolve a server-returned, host-relative media URL (a wiki `signed_url`
28680
+ * like `/wiki/v1/signed/files?token=...`) against this client's base origin,
28681
+ * so it can be dropped straight into a browser `<img>`/`<video>`/`<iframe>`
28682
+ * `src`.
28683
+ *
28684
+ * wiki-service returns these relative on purpose — it doesn't know its own
28685
+ * public origin. A relative `src` resolves against the *page* origin, which
28686
+ * only works when the page and wiki-service share an origin (local dev:
28687
+ * same-origin + Next.js `/wiki/*` proxy). In deployed envs the app
28688
+ * (app.parall.com) and wiki-service (api.parall.com) are different origins,
28689
+ * so `app.parall.com/wiki/v1/signed/files` hits the SPA's own `/wiki/[...]`
28690
+ * catch-all route — an `<iframe>` then recursively renders the whole app
28691
+ * instead of the file. Prefixing with the wiki base (the exact origin every
28692
+ * wiki API request already uses — `baseUrlFor` resolves `/wiki/v1` paths to
28693
+ * `wikiBaseUrl`) makes the URL absolute against the origin that actually
28694
+ * serves the bytes. An empty base (local dev, same-origin proxy) leaves it
28695
+ * relative, preserving the proxy path.
28696
+ */
28697
+ absoluteMediaUrl(url) {
28698
+ if (/^https?:\/\//i.test(url))
28699
+ return url;
28700
+ return `${this.baseUrlFor(url)}${url}`;
28701
+ }
28590
28702
  async getWikiBlob(orgId, wikiId, params) {
28591
- return this.request("GET", ENDPOINTS.WIKI_BLOB(orgId, wikiId), void 0, params);
28703
+ const blob = await this.request("GET", ENDPOINTS.WIKI_BLOB(orgId, wikiId), void 0, params);
28704
+ if (blob.signed_url)
28705
+ blob.signed_url = this.absoluteMediaUrl(blob.signed_url);
28706
+ return blob;
28592
28707
  }
28593
28708
  async getWikiNodeSections(orgId, wikiId, params) {
28594
28709
  return this.request("GET", ENDPOINTS.WIKI_NODE_SECTIONS(orgId, wikiId), void 0, params);
@@ -28678,7 +28793,10 @@ var ParallClient = class _ParallClient {
28678
28793
  * token — don't leak it.
28679
28794
  */
28680
28795
  async getWikiFilePreviewUrl(orgId, wikiId, params) {
28681
- return this.request("POST", ENDPOINTS.WIKI_FILE_PREVIEW_URL(orgId, wikiId), params);
28796
+ const res = await this.request("POST", ENDPOINTS.WIKI_FILE_PREVIEW_URL(orgId, wikiId), params);
28797
+ if (res.url)
28798
+ res.url = this.absoluteMediaUrl(res.url);
28799
+ return res;
28682
28800
  }
28683
28801
  // ---- Wiki Path Scopes (AFCS ACL) ----
28684
28802
  async getWikiPathScopes(orgId, wikiId) {
@@ -28755,6 +28873,16 @@ var ParallClient = class _ParallClient {
28755
28873
  async getBacklinks(orgId, params) {
28756
28874
  return this.request("GET", ENDPOINTS.REFS_BACKLINKS(orgId), void 0, params);
28757
28875
  }
28876
+ /**
28877
+ * Bounded multi-hop walk of the prll:// reference graph around `uri`. `uri`
28878
+ * must be an entity-level prll:// URI — a refined URI (path/query/fragment) is
28879
+ * rejected with 400 UNSUPPORTED_REFINED_URI. `depth` is clamped server-side to
28880
+ * [1, 4]; breadth (node/edge counts) is capped server-side and surfaced via
28881
+ * `truncated`. ACL is applied per hop (chat membership + wiki path scope).
28882
+ */
28883
+ async getRefsGraph(orgId, params) {
28884
+ return this.request("GET", ENDPOINTS.REFS_GRAPH(orgId), void 0, params);
28885
+ }
28758
28886
  async checkBrokenRefs(orgId) {
28759
28887
  return this.request("GET", ENDPOINTS.REFS_CHECK(orgId));
28760
28888
  }
@@ -28894,6 +29022,9 @@ var ParallClient = class _ParallClient {
28894
29022
  const resp = await this.request("GET", ENDPOINTS.CLIP_ONLINE(orgId));
28895
29023
  return resp.data;
28896
29024
  }
29025
+ /** Org-wide browser-profile discovery list. Returns the sanitized
29026
+ * {@link BrowserProfileListItem} shape (not the full domain model), each row
29027
+ * carrying a per-viewer `can_open` control hint. */
28897
29028
  async listBrowserProfiles(orgId) {
28898
29029
  const resp = await this.request("GET", ENDPOINTS.BROWSER_PROFILES(orgId));
28899
29030
  return resp.data;
@@ -31692,6 +31823,10 @@ function encodeEnvelope2(msg) {
31692
31823
  header.writeUInt32BE(json.length, 1);
31693
31824
  return Buffer.concat([header, json]);
31694
31825
  }
31826
+ function registrationKey(clips) {
31827
+ const sorted = [...clips].sort((a, b) => (a.alias ?? "").localeCompare(b.alias ?? ""));
31828
+ return JSON.stringify(sorted);
31829
+ }
31695
31830
  var EnvelopeDecoder2 = class _EnvelopeDecoder {
31696
31831
  buf = Buffer.alloc(0);
31697
31832
  push(chunk) {
@@ -31727,7 +31862,15 @@ var ClipProvider = class _ClipProvider {
31727
31862
  heartbeatTimer = null;
31728
31863
  statusUnsubscribe = null;
31729
31864
  manifestUnsubscribe = null;
31865
+ // Set when the clip manifest/set changed and the hub's copy is stale. Drained
31866
+ // by flushReregister(), which re-registers IN-STREAM (no reconnect).
31730
31867
  needsReregister = false;
31868
+ // Canonical key of the clip-registration set last sent to the hub on the
31869
+ // CURRENT stream. flushReregister diff-gates against it so a cold-start that
31870
+ // re-derives an identical set (the common case — a clip's command surface is
31871
+ // stable across spawns) does not emit a redundant re-register. Reset per
31872
+ // stream; the first-frame register in openStream re-seeds it.
31873
+ lastRegisteredKey = null;
31731
31874
  // Monotonic stream id, bumped by openStream(). Handlers from a stream that a
31732
31875
  // newer openStream() has superseded bail in handleDisconnect(gen), so their
31733
31876
  // late close/end/error can't reconnect against the healthy replacement.
@@ -31768,6 +31911,7 @@ var ClipProvider = class _ClipProvider {
31768
31911
  if (this.stopped)
31769
31912
  return;
31770
31913
  const gen = ++this.streamGeneration;
31914
+ this.lastRegisteredKey = null;
31771
31915
  try {
31772
31916
  this.closeStream();
31773
31917
  this.closeSession();
@@ -31866,21 +32010,57 @@ var ClipProvider = class _ClipProvider {
31866
32010
  startHeartbeat() {
31867
32011
  this.clearHeartbeatTimer();
31868
32012
  this.heartbeatTimer = setInterval(() => {
31869
- if (this.needsReregister) {
31870
- this.needsReregister = false;
31871
- this.opts.log.info("[clip-provider] manifest updated \u2014 reconnecting to re-register clips");
31872
- this.connected = false;
31873
- this.clearHeartbeatTimer();
31874
- this.clearReconnectTimer();
31875
- void this.openStream();
31876
- return;
31877
- }
32013
+ this.flushReregister();
31878
32014
  this.sendProviderMessage({ ping: { sentAtUnixMs: Date.now() } }).catch((err) => {
31879
32015
  this.opts.log.warn(`[clip-provider] heartbeat send failed: ${String(err)}`);
31880
32016
  });
31881
32017
  }, _ClipProvider.HEARTBEAT_INTERVAL_MS);
31882
32018
  this.heartbeatTimer.unref?.();
31883
32019
  }
32020
+ /**
32021
+ * Re-register the current clip set on the OPEN stream — the daemon's response
32022
+ * to a manifest/clip-set change. The hub overwrites its stored set from this
32023
+ * full register (clip-provider-sharing-design §11), so a clip install / wake /
32024
+ * sleep no longer requires tearing down and reopening the ProviderStream,
32025
+ * which used to drop any invoke in flight on that stream (in-flight requests
32026
+ * are keyed by request_id on the hub and survive a clip-set swap).
32027
+ *
32028
+ * No-op when the stream is not currently writable; needsReregister stays set
32029
+ * so the next heartbeat tick (or the post-connect flush) retries.
32030
+ *
32031
+ * Against an older hub that still rejects an in-stream duplicate register, the
32032
+ * write reaches a stream the hub then resets; the resulting stream error runs
32033
+ * the normal reconnect path, which re-registers as the first frame — i.e. it
32034
+ * degrades to the previous reconnect-to-re-register behavior, so no capability
32035
+ * negotiation is needed (deploy the hub first).
32036
+ */
32037
+ flushReregister() {
32038
+ if (!this.needsReregister)
32039
+ return;
32040
+ if (!this.connected || !this.stream || this.stream.closed || this.stream.destroyed)
32041
+ return;
32042
+ const clips = this.buildClipRegistrations();
32043
+ if (registrationKey(clips) === this.lastRegisteredKey) {
32044
+ this.needsReregister = false;
32045
+ return;
32046
+ }
32047
+ this.needsReregister = false;
32048
+ this.opts.log.info("[clip-provider] re-registering clips in-stream");
32049
+ this.sendRegister(clips).catch((err) => {
32050
+ this.opts.log.warn(`[clip-provider] in-stream re-register failed: ${String(err)}`);
32051
+ this.needsReregister = true;
32052
+ });
32053
+ }
32054
+ /**
32055
+ * Re-register the current clip set on the open stream — public entry for the
32056
+ * supervisor when machine clip assignments change. Same in-stream path as a
32057
+ * manifest update (no reconnect, in-flight invokes survive). Safe when
32058
+ * disconnected: the flag is flushed on the next heartbeat / post-connect.
32059
+ */
32060
+ reregister() {
32061
+ this.needsReregister = true;
32062
+ this.flushReregister();
32063
+ }
31884
32064
  closeStream() {
31885
32065
  if (this.stream) {
31886
32066
  try {
@@ -31915,7 +32095,7 @@ var ClipProvider = class _ClipProvider {
31915
32095
  });
31916
32096
  });
31917
32097
  this.manifestUnsubscribe = mgr.addManifestListener(() => {
31918
- this.needsReregister = true;
32098
+ this.reregister();
31919
32099
  });
31920
32100
  }
31921
32101
  unsubscribeStatusChanges() {
@@ -31932,6 +32112,8 @@ var ClipProvider = class _ClipProvider {
31932
32112
  this.handleRegistered(msg.registerResponse);
31933
32113
  } else if (msg.invokeCommand) {
31934
32114
  void this.handleInvokeCommand(msg.invokeCommand);
32115
+ } else if (msg.viewerCommand) {
32116
+ void this.handleViewerCommand(msg.viewerCommand);
31935
32117
  } else if (msg.pong !== void 0) {
31936
32118
  }
31937
32119
  }
@@ -31941,6 +32123,7 @@ var ClipProvider = class _ClipProvider {
31941
32123
  this.connected = true;
31942
32124
  this.reconnectAttempt = 0;
31943
32125
  this.startHeartbeat();
32126
+ this.flushReregister();
31944
32127
  } else {
31945
32128
  this.opts.log.error(`[clip-provider] registration rejected: ${reg.message}`);
31946
32129
  this.stopped = true;
@@ -31994,17 +32177,64 @@ var ClipProvider = class _ClipProvider {
31994
32177
  });
31995
32178
  }
31996
32179
  }
32180
+ /**
32181
+ * Handle a hosted-browser live-viewer command relayed by clip-service over the
32182
+ * stream and reply on the same stream (design §3.4). Always answers the
32183
+ * request/reply bridge exactly once — `{result}` on success, `{error:{message}}`
32184
+ * on failure — and never throws into the message loop, mirroring the BYOC
32185
+ * daemon's handleBrowserProfileViewer (which replies over HTTP instead).
32186
+ */
32187
+ async handleViewerCommand(cmd) {
32188
+ const { requestId } = cmd;
32189
+ if (!this.opts.onViewerCommand) {
32190
+ await this.sendViewerReply(requestId, {
32191
+ error: { message: "live viewer is not supported by this provider" }
32192
+ }).catch(() => {
32193
+ });
32194
+ return;
32195
+ }
32196
+ try {
32197
+ let input;
32198
+ if (cmd.input) {
32199
+ const decoded = Buffer.from(cmd.input, "base64").toString("utf-8");
32200
+ try {
32201
+ input = JSON.parse(decoded);
32202
+ } catch {
32203
+ input = void 0;
32204
+ }
32205
+ }
32206
+ const result = await this.opts.onViewerCommand({
32207
+ profileId: cmd.profileId,
32208
+ sessionId: cmd.sessionId,
32209
+ command: cmd.command,
32210
+ input,
32211
+ turn: cmd.turn
32212
+ });
32213
+ await this.sendViewerReply(requestId, { result });
32214
+ } catch (err) {
32215
+ const message = err instanceof Error ? err.message : String(err);
32216
+ this.opts.log.warn(`[clip-provider] viewer command failed (profile=${cmd.profileId}, command=${cmd.command}): ${message}`);
32217
+ await this.sendViewerReply(requestId, { error: { message } }).catch(() => {
32218
+ });
32219
+ }
32220
+ }
32221
+ /** Send a hosted-viewer reply (base64-encoded JSON body) on the stream. */
32222
+ async sendViewerReply(requestId, reply) {
32223
+ const payload = Buffer.from(JSON.stringify(reply), "utf-8").toString("base64");
32224
+ await this.sendProviderMessage({ viewerResult: { requestId, payload } });
32225
+ }
31997
32226
  // ---------------------------------------------------------------------------
31998
32227
  // Sending
31999
32228
  // ---------------------------------------------------------------------------
32000
- async sendRegister() {
32001
- const clips = this.buildClipRegistrations();
32229
+ async sendRegister(prebuilt) {
32230
+ const clips = prebuilt ?? this.buildClipRegistrations();
32002
32231
  await this.sendProviderMessage({
32003
32232
  register: {
32004
32233
  providerName: this.opts.providerName,
32005
32234
  clips
32006
32235
  }
32007
32236
  });
32237
+ this.lastRegisteredKey = registrationKey(clips);
32008
32238
  }
32009
32239
  async sendProviderMessage(msg) {
32010
32240
  if (!this.stream || this.stream.closed || this.stream.destroyed) {
@@ -32113,12 +32343,27 @@ var BB_VIEWER_HEALTH_TIMEOUT_MS = 1e4;
32113
32343
  var BB_VIEWER_COMMAND_TIMEOUT_MS = 15e3;
32114
32344
  var BB_VIEWER_UNANSWERED_REAP_MS = 9e4;
32115
32345
  var BB_VIEWER_TERM_GRACE_MS = 5e3;
32346
+ function isLoopbackBindHost(host) {
32347
+ return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
32348
+ }
32349
+ function kickHitsCurrentSession(requested, currentSessionId) {
32350
+ if (currentSessionId === void 0)
32351
+ return false;
32352
+ return requested === void 0 || requested === currentSessionId;
32353
+ }
32116
32354
  var BrowserViewerStreamer = class {
32117
32355
  host;
32118
32356
  // Live bb-viewer (WebRTC streamer) subprocess per profile. One per profile;
32119
32357
  // see StreamerState. Cleaned up on stream.close, stopProfile, resetProfile,
32120
32358
  // and stop().
32121
32359
  streamers = /* @__PURE__ */ new Map();
32360
+ // The most-recently server-side-kicked viewer session per profile (design §3.6
32361
+ // PR7). After a kick the streamer is gone, so the session-staleness guards
32362
+ // (which key off a live streamer) can no longer reject the kicked viewer's
32363
+ // straggler nav commands — this map does. Reset whenever the profile's streamer
32364
+ // is (re)killed via killProfileStreamer, so a fresh stream.start clears it. One
32365
+ // entry per profile (overwritten per kick); bounded, no leak.
32366
+ kickedSessions = /* @__PURE__ */ new Map();
32122
32367
  // Set by shutdown(): an in-flight spawnStreamer has no map entry yet, so a
32123
32368
  // shutdown during its health poll would otherwise let the bb-viewer child
32124
32369
  // survive daemon stop (on BYOC nothing else reaps it).
@@ -32135,6 +32380,9 @@ var BrowserViewerStreamer = class {
32135
32380
  async handleViewerCommand(profileId, sessionId, command, input, turn) {
32136
32381
  if (!profileId)
32137
32382
  throw new Error("browser profile id is required");
32383
+ if (command !== "stream.start" && sessionId && this.kickedSessions.get(profileId) === sessionId) {
32384
+ throw new Error("viewer session was terminated");
32385
+ }
32138
32386
  switch (command) {
32139
32387
  case "stream.start":
32140
32388
  return this.viewerStreamStart(profileId, sessionId, turn);
@@ -32144,6 +32392,8 @@ var BrowserViewerStreamer = class {
32144
32392
  return this.viewerStreamClose(profileId, sessionId, input);
32145
32393
  case "stream.switch":
32146
32394
  return this.viewerStreamSwitch(profileId, sessionId, input);
32395
+ case "kick":
32396
+ return this.viewerKick(profileId, sessionId, input);
32147
32397
  case "close":
32148
32398
  return this.viewerCloseTab(profileId, sessionId, input);
32149
32399
  case "tab_list":
@@ -32157,6 +32407,31 @@ var BrowserViewerStreamer = class {
32157
32407
  throw new Error(`unknown viewer command: ${command}`);
32158
32408
  }
32159
32409
  }
32410
+ /**
32411
+ * kick — server-side per-viewer disconnect (design §3.6 PR7). Terminates ONE
32412
+ * viewer's WebRTC session: stop its bb-viewer streamer (killing the process
32413
+ * tears down the peer connection + datachannel) and reject the kicked session's
32414
+ * subsequent commands — while bb-browser + Chromium (the agent's live browser)
32415
+ * keep running. This is explicitly DISTINCT from stopping the pod / profile,
32416
+ * which would kill the agent's browser too; a viewer session is a sub-session of
32417
+ * the profile lease, so a kick does NOT release the lease.
32418
+ *
32419
+ * Targets `input.session_id` if given, else the current streamer's session.
32420
+ * Idempotent: kicking with no live streamer still records the kicked session.
32421
+ */
32422
+ viewerKick(profileId, sessionId, input) {
32423
+ const streamer = this.streamers.get(profileId);
32424
+ const requested = typeof input?.session_id === "string" ? input.session_id : void 0;
32425
+ const target = requested || streamer?.sessionId || sessionId;
32426
+ const killsCurrent = kickHitsCurrentSession(requested, streamer?.sessionId);
32427
+ if (killsCurrent) {
32428
+ this.killProfileStreamer(profileId);
32429
+ }
32430
+ if (target)
32431
+ this.kickedSessions.set(profileId, target);
32432
+ this.host.log.info(`[bb-viewer] kicked viewer session ${target || "(none)"} for profile ${profileId}; pod + agent browser keep running`);
32433
+ return target ? { ok: true, kicked: true, session_id: target } : { ok: true, kicked: true };
32434
+ }
32160
32435
  /**
32161
32436
  * stream.start — spawn a FRESH bb-viewer for this profile (killing any prior
32162
32437
  * one), resolve the profile's account-scoped page-target CDP ws URL, and run
@@ -32453,6 +32728,13 @@ var BrowserViewerStreamer = class {
32453
32728
  const bin = process.env.PRLL_BB_VIEWER_BIN ?? "bb-viewer";
32454
32729
  const port = await findFreePort();
32455
32730
  const args = ["--api-only", "--port", String(port)];
32731
+ const bindHost = process.env.PRLL_BB_VIEWER_HOST?.trim();
32732
+ if (bindHost) {
32733
+ if (!isLoopbackBindHost(bindHost)) {
32734
+ 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}`);
32735
+ }
32736
+ args.push("--host", bindHost);
32737
+ }
32456
32738
  if (turn?.url) {
32457
32739
  args.push("--turn-url", turn.url, "--turn-user", turn.username ?? "", "--turn-cred", turn.credential ?? "");
32458
32740
  }
@@ -32570,8 +32852,14 @@ var BrowserViewerStreamer = class {
32570
32852
  clearTimeout(timer);
32571
32853
  }
32572
32854
  }
32573
- /** Kill + remove the profile's bb-viewer streamer, if any. Idempotent. */
32855
+ /**
32856
+ * Kill + remove the profile's bb-viewer streamer, if any. Idempotent. Also
32857
+ * clears any kick marker for the profile: a (re)kill establishes a clean
32858
+ * streamer slot, so a subsequent stream.start starts un-kicked. viewerKick
32859
+ * re-records the kicked session AFTER calling this.
32860
+ */
32574
32861
  killProfileStreamer(profileId) {
32862
+ this.kickedSessions.delete(profileId);
32575
32863
  const streamer = this.streamers.get(profileId);
32576
32864
  if (!streamer)
32577
32865
  return;
@@ -32908,10 +33196,12 @@ var BrowserProfileManager = class {
32908
33196
  if (!isBrowserAccountInfoUnauthenticatedError(err)) {
32909
33197
  if (!isBrowserAccountNotFoundError(err))
32910
33198
  throw err;
33199
+ const proxy = this.opts.resolveProxy ? await this.opts.resolveProxy(account) : null;
32911
33200
  await this.sendCommand({
32912
33201
  method: "account_create",
32913
33202
  account,
32914
- ...accountUrl ? { accountUrl } : {}
33203
+ ...accountUrl ? { accountUrl } : {},
33204
+ ...proxyCreateFields(proxy)
32915
33205
  });
32916
33206
  accountCreated = true;
32917
33207
  }
@@ -33168,6 +33458,16 @@ function resolveBbBrowserDaemonPath() {
33168
33458
  function randomToken() {
33169
33459
  return randomBytes(16).toString("hex");
33170
33460
  }
33461
+ function proxyCreateFields(proxy) {
33462
+ if (!proxy?.server)
33463
+ return {};
33464
+ const fields = { proxyServer: proxy.server };
33465
+ if (proxy.username)
33466
+ fields.proxyUsername = proxy.username;
33467
+ if (proxy.password)
33468
+ fields.proxyPassword = proxy.password;
33469
+ return fields;
33470
+ }
33171
33471
 
33172
33472
  // ts/daemon/dist/supervisor.js
33173
33473
  init_config();
@@ -34095,7 +34395,8 @@ var DaemonSupervisor = class {
34095
34395
  log: this.log,
34096
34396
  reportStatus: (profileId, status, errorMsg) => {
34097
34397
  this.client.reportBrowserProfileStatus(profileId, status, errorMsg).catch((err) => this.log.warn(`browser profile status report failed: ${String(err)}`));
34098
- }
34398
+ },
34399
+ resolveProxy: (profileId) => this.resolveBrowserProfileProxy(profileId)
34099
34400
  });
34100
34401
  this.clipManager = new ClipProcessManager({
34101
34402
  clipsDir: path13.join(this.config.rootStateDir, "clips"),
@@ -34343,10 +34644,13 @@ var DaemonSupervisor = class {
34343
34644
  for (const profile of profiles) {
34344
34645
  if (profile.status !== "running" && profile.status !== "pending")
34345
34646
  continue;
34647
+ if (!profile.machine_id)
34648
+ continue;
34649
+ const machineId = profile.machine_id;
34346
34650
  try {
34347
34651
  if (profile.status === "pending") {
34348
34652
  await this.enqueueBrowserProfileLifecycle({
34349
- machine_id: profile.machine_id,
34653
+ machine_id: machineId,
34350
34654
  profile_id: profile.id,
34351
34655
  action: "open"
34352
34656
  });
@@ -34358,6 +34662,30 @@ var DaemonSupervisor = class {
34358
34662
  }
34359
34663
  }
34360
34664
  }
34665
+ /**
34666
+ * Resolve a profile's outbound proxy for bb-browser account creation (BYOC).
34667
+ * Fetched fresh — account creation is rare (once per profile until reset), so a
34668
+ * proxy edit applies on the next account_create. The mck_-authed machine list is
34669
+ * the only path that carries proxy_password (for replay to bb-browser).
34670
+ *
34671
+ * Fails closed: a fetch failure or a vanished profile THROWS, so the manager never
34672
+ * creates an account that would egress from the host's real IP for a
34673
+ * proxy-configured profile (the caller reports `error` and retries next tick).
34674
+ */
34675
+ async resolveBrowserProfileProxy(profileId) {
34676
+ const profiles = await this.client.listMachineBrowserProfiles();
34677
+ const profile = profiles.find((p) => p.id === profileId);
34678
+ if (!profile) {
34679
+ throw new Error(`browser profile ${profileId} not found; refusing to create a bb-browser account without its proxy config`);
34680
+ }
34681
+ if (!profile.proxy_server)
34682
+ return null;
34683
+ return {
34684
+ server: profile.proxy_server,
34685
+ username: profile.proxy_username ?? void 0,
34686
+ password: profile.proxy_password ?? void 0
34687
+ };
34688
+ }
34361
34689
  // ---- Flat layout migration (self-hosted → daemon) ----
34362
34690
  /**
34363
34691
  * Detects a legacy flat state layout (no agents/ subdir) and migrates it
@@ -34602,7 +34930,7 @@ var DaemonSupervisor = class {
34602
34930
  }
34603
34931
  if (changed) {
34604
34932
  this.log.info(`machine clips synced \u2014 count=${desired.length}`);
34605
- await this.reconnectClipProvider();
34933
+ this.clipProvider?.reregister();
34606
34934
  }
34607
34935
  }
34608
34936
  startClipReconcileTimer() {
@@ -34731,15 +35059,6 @@ var DaemonSupervisor = class {
34731
35059
  }
34732
35060
  return null;
34733
35061
  }
34734
- async reconnectClipProvider() {
34735
- if (!this.clipProvider)
34736
- return;
34737
- const provider = this.clipProvider;
34738
- this.clipProvider = null;
34739
- this.connectedClipServiceUrl = null;
34740
- await provider.disconnect().catch((err) => this.log.warn(`clip provider reconnect disconnect failed: ${String(err)}`));
34741
- await this.applyClipProviderState();
34742
- }
34743
35062
  async handleWorkspaceSetupRequested(agentId) {
34744
35063
  if (this.spawningAgents.has(agentId)) {
34745
35064
  this.log.info(`agent ${agentId}: workspace setup already in progress; queueing one restart`);