@parall/daemon 1.35.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.
@@ -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}`,
@@ -27465,6 +27476,7 @@ var WS_EVENTS = {
27465
27476
  // ts/sdk/dist/client.js
27466
27477
  var ParallClient = class _ParallClient {
27467
27478
  baseUrl;
27479
+ wikiBaseUrl;
27468
27480
  token;
27469
27481
  onTokenExpired;
27470
27482
  getRefreshToken;
@@ -27516,12 +27528,22 @@ var ParallClient = class _ParallClient {
27516
27528
  }
27517
27529
  constructor(options = {}) {
27518
27530
  this.baseUrl = options.baseUrl ?? "";
27531
+ this.wikiBaseUrl = options.wikiBaseUrl ?? this.baseUrl;
27519
27532
  this.token = options.token ?? null;
27520
27533
  this.onTokenExpired = options.onTokenExpired;
27521
27534
  this.getRefreshToken = options.getRefreshToken;
27522
27535
  this.setTokens = options.setTokens;
27523
27536
  this.swimlaneName = options.swimlaneName;
27524
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
+ }
27525
27547
  setToken(token) {
27526
27548
  this.token = token;
27527
27549
  }
@@ -27586,7 +27608,7 @@ var ParallClient = class _ParallClient {
27586
27608
  if (!retried) {
27587
27609
  await this.ensureFreshToken(path15);
27588
27610
  }
27589
- let url = `${this.baseUrl}${path15}`;
27611
+ let url = `${this.baseUrlFor(path15)}${path15}`;
27590
27612
  if (query) {
27591
27613
  const params = new URLSearchParams();
27592
27614
  for (const [key, value] of Object.entries(query)) {
@@ -27658,7 +27680,7 @@ var ParallClient = class _ParallClient {
27658
27680
  void _drop;
27659
27681
  let res;
27660
27682
  try {
27661
- res = await fetch(`${this.baseUrl}${path15}`, {
27683
+ res = await fetch(`${this.baseUrlFor(path15)}${path15}`, {
27662
27684
  method,
27663
27685
  headers,
27664
27686
  body,
@@ -27818,6 +27840,21 @@ var ParallClient = class _ParallClient {
27818
27840
  q.limit = String(params.limit);
27819
27841
  return this.request("GET", ENDPOINTS.ORG_MEMBER_TASKS(orgId, memberId), void 0, q);
27820
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
+ }
27821
27858
  // ---- Invitations ----
27822
27859
  async createInvitation(orgId, email, role) {
27823
27860
  return this.request("POST", ENDPOINTS.ORG_INVITATIONS(orgId), { email, role });
@@ -28407,7 +28444,7 @@ var ParallClient = class _ParallClient {
28407
28444
  * Returns null when the server responds with 304 (config unchanged).
28408
28445
  */
28409
28446
  async getPlatformConfig(currentVersion) {
28410
- const url = `${this.baseUrl}${ENDPOINTS.PLATFORM_CONFIG}`;
28447
+ const url = `${this.baseUrlFor(ENDPOINTS.PLATFORM_CONFIG)}${ENDPOINTS.PLATFORM_CONFIG}`;
28411
28448
  const extra = {};
28412
28449
  if (currentVersion !== void 0) {
28413
28450
  extra["If-None-Match"] = currentVersion;
@@ -28573,6 +28610,55 @@ var ParallClient = class _ParallClient {
28573
28610
  async getScheduleRun(orgId, runId) {
28574
28611
  return this.request("GET", ENDPOINTS.SCHEDULE_RUN(orgId, runId));
28575
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
+ }
28576
28662
  // ---- Wikis (org-scoped) ----
28577
28663
  async createWiki(orgId, data) {
28578
28664
  return this.request("POST", ENDPOINTS.WIKIS(orgId), data);
@@ -28587,8 +28673,35 @@ var ParallClient = class _ParallClient {
28587
28673
  async getWikiTree(orgId, wikiId, params) {
28588
28674
  return this.request("GET", ENDPOINTS.WIKI_TREE(orgId, wikiId), void 0, params);
28589
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
+ }
28590
28700
  async getWikiBlob(orgId, wikiId, params) {
28591
- 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;
28592
28705
  }
28593
28706
  async getWikiNodeSections(orgId, wikiId, params) {
28594
28707
  return this.request("GET", ENDPOINTS.WIKI_NODE_SECTIONS(orgId, wikiId), void 0, params);
@@ -28678,7 +28791,10 @@ var ParallClient = class _ParallClient {
28678
28791
  * token — don't leak it.
28679
28792
  */
28680
28793
  async getWikiFilePreviewUrl(orgId, wikiId, params) {
28681
- 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;
28682
28798
  }
28683
28799
  // ---- Wiki Path Scopes (AFCS ACL) ----
28684
28800
  async getWikiPathScopes(orgId, wikiId) {
@@ -28894,6 +29010,9 @@ var ParallClient = class _ParallClient {
28894
29010
  const resp = await this.request("GET", ENDPOINTS.CLIP_ONLINE(orgId));
28895
29011
  return resp.data;
28896
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. */
28897
29016
  async listBrowserProfiles(orgId) {
28898
29017
  const resp = await this.request("GET", ENDPOINTS.BROWSER_PROFILES(orgId));
28899
29018
  return resp.data;
@@ -31692,6 +31811,10 @@ function encodeEnvelope2(msg) {
31692
31811
  header.writeUInt32BE(json.length, 1);
31693
31812
  return Buffer.concat([header, json]);
31694
31813
  }
31814
+ function registrationKey(clips) {
31815
+ const sorted = [...clips].sort((a, b) => (a.alias ?? "").localeCompare(b.alias ?? ""));
31816
+ return JSON.stringify(sorted);
31817
+ }
31695
31818
  var EnvelopeDecoder2 = class _EnvelopeDecoder {
31696
31819
  buf = Buffer.alloc(0);
31697
31820
  push(chunk) {
@@ -31727,7 +31850,15 @@ var ClipProvider = class _ClipProvider {
31727
31850
  heartbeatTimer = null;
31728
31851
  statusUnsubscribe = null;
31729
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).
31730
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;
31731
31862
  // Monotonic stream id, bumped by openStream(). Handlers from a stream that a
31732
31863
  // newer openStream() has superseded bail in handleDisconnect(gen), so their
31733
31864
  // late close/end/error can't reconnect against the healthy replacement.
@@ -31768,6 +31899,7 @@ var ClipProvider = class _ClipProvider {
31768
31899
  if (this.stopped)
31769
31900
  return;
31770
31901
  const gen = ++this.streamGeneration;
31902
+ this.lastRegisteredKey = null;
31771
31903
  try {
31772
31904
  this.closeStream();
31773
31905
  this.closeSession();
@@ -31866,21 +31998,57 @@ var ClipProvider = class _ClipProvider {
31866
31998
  startHeartbeat() {
31867
31999
  this.clearHeartbeatTimer();
31868
32000
  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
- }
32001
+ this.flushReregister();
31878
32002
  this.sendProviderMessage({ ping: { sentAtUnixMs: Date.now() } }).catch((err) => {
31879
32003
  this.opts.log.warn(`[clip-provider] heartbeat send failed: ${String(err)}`);
31880
32004
  });
31881
32005
  }, _ClipProvider.HEARTBEAT_INTERVAL_MS);
31882
32006
  this.heartbeatTimer.unref?.();
31883
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
+ }
31884
32052
  closeStream() {
31885
32053
  if (this.stream) {
31886
32054
  try {
@@ -31915,7 +32083,7 @@ var ClipProvider = class _ClipProvider {
31915
32083
  });
31916
32084
  });
31917
32085
  this.manifestUnsubscribe = mgr.addManifestListener(() => {
31918
- this.needsReregister = true;
32086
+ this.reregister();
31919
32087
  });
31920
32088
  }
31921
32089
  unsubscribeStatusChanges() {
@@ -31932,6 +32100,8 @@ var ClipProvider = class _ClipProvider {
31932
32100
  this.handleRegistered(msg.registerResponse);
31933
32101
  } else if (msg.invokeCommand) {
31934
32102
  void this.handleInvokeCommand(msg.invokeCommand);
32103
+ } else if (msg.viewerCommand) {
32104
+ void this.handleViewerCommand(msg.viewerCommand);
31935
32105
  } else if (msg.pong !== void 0) {
31936
32106
  }
31937
32107
  }
@@ -31941,6 +32111,7 @@ var ClipProvider = class _ClipProvider {
31941
32111
  this.connected = true;
31942
32112
  this.reconnectAttempt = 0;
31943
32113
  this.startHeartbeat();
32114
+ this.flushReregister();
31944
32115
  } else {
31945
32116
  this.opts.log.error(`[clip-provider] registration rejected: ${reg.message}`);
31946
32117
  this.stopped = true;
@@ -31994,17 +32165,64 @@ var ClipProvider = class _ClipProvider {
31994
32165
  });
31995
32166
  }
31996
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
+ }
31997
32214
  // ---------------------------------------------------------------------------
31998
32215
  // Sending
31999
32216
  // ---------------------------------------------------------------------------
32000
- async sendRegister() {
32001
- const clips = this.buildClipRegistrations();
32217
+ async sendRegister(prebuilt) {
32218
+ const clips = prebuilt ?? this.buildClipRegistrations();
32002
32219
  await this.sendProviderMessage({
32003
32220
  register: {
32004
32221
  providerName: this.opts.providerName,
32005
32222
  clips
32006
32223
  }
32007
32224
  });
32225
+ this.lastRegisteredKey = registrationKey(clips);
32008
32226
  }
32009
32227
  async sendProviderMessage(msg) {
32010
32228
  if (!this.stream || this.stream.closed || this.stream.destroyed) {
@@ -32113,12 +32331,27 @@ var BB_VIEWER_HEALTH_TIMEOUT_MS = 1e4;
32113
32331
  var BB_VIEWER_COMMAND_TIMEOUT_MS = 15e3;
32114
32332
  var BB_VIEWER_UNANSWERED_REAP_MS = 9e4;
32115
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
+ }
32116
32342
  var BrowserViewerStreamer = class {
32117
32343
  host;
32118
32344
  // Live bb-viewer (WebRTC streamer) subprocess per profile. One per profile;
32119
32345
  // see StreamerState. Cleaned up on stream.close, stopProfile, resetProfile,
32120
32346
  // and stop().
32121
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();
32122
32355
  // Set by shutdown(): an in-flight spawnStreamer has no map entry yet, so a
32123
32356
  // shutdown during its health poll would otherwise let the bb-viewer child
32124
32357
  // survive daemon stop (on BYOC nothing else reaps it).
@@ -32135,6 +32368,9 @@ var BrowserViewerStreamer = class {
32135
32368
  async handleViewerCommand(profileId, sessionId, command, input, turn) {
32136
32369
  if (!profileId)
32137
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
+ }
32138
32374
  switch (command) {
32139
32375
  case "stream.start":
32140
32376
  return this.viewerStreamStart(profileId, sessionId, turn);
@@ -32144,6 +32380,8 @@ var BrowserViewerStreamer = class {
32144
32380
  return this.viewerStreamClose(profileId, sessionId, input);
32145
32381
  case "stream.switch":
32146
32382
  return this.viewerStreamSwitch(profileId, sessionId, input);
32383
+ case "kick":
32384
+ return this.viewerKick(profileId, sessionId, input);
32147
32385
  case "close":
32148
32386
  return this.viewerCloseTab(profileId, sessionId, input);
32149
32387
  case "tab_list":
@@ -32157,6 +32395,31 @@ var BrowserViewerStreamer = class {
32157
32395
  throw new Error(`unknown viewer command: ${command}`);
32158
32396
  }
32159
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
+ }
32160
32423
  /**
32161
32424
  * stream.start — spawn a FRESH bb-viewer for this profile (killing any prior
32162
32425
  * one), resolve the profile's account-scoped page-target CDP ws URL, and run
@@ -32453,6 +32716,13 @@ var BrowserViewerStreamer = class {
32453
32716
  const bin = process.env.PRLL_BB_VIEWER_BIN ?? "bb-viewer";
32454
32717
  const port = await findFreePort();
32455
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
+ }
32456
32726
  if (turn?.url) {
32457
32727
  args.push("--turn-url", turn.url, "--turn-user", turn.username ?? "", "--turn-cred", turn.credential ?? "");
32458
32728
  }
@@ -32570,8 +32840,14 @@ var BrowserViewerStreamer = class {
32570
32840
  clearTimeout(timer);
32571
32841
  }
32572
32842
  }
32573
- /** Kill + remove the profile's bb-viewer streamer, if any. Idempotent. */
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
+ */
32574
32849
  killProfileStreamer(profileId) {
32850
+ this.kickedSessions.delete(profileId);
32575
32851
  const streamer = this.streamers.get(profileId);
32576
32852
  if (!streamer)
32577
32853
  return;
@@ -34343,10 +34619,13 @@ var DaemonSupervisor = class {
34343
34619
  for (const profile of profiles) {
34344
34620
  if (profile.status !== "running" && profile.status !== "pending")
34345
34621
  continue;
34622
+ if (!profile.machine_id)
34623
+ continue;
34624
+ const machineId = profile.machine_id;
34346
34625
  try {
34347
34626
  if (profile.status === "pending") {
34348
34627
  await this.enqueueBrowserProfileLifecycle({
34349
- machine_id: profile.machine_id,
34628
+ machine_id: machineId,
34350
34629
  profile_id: profile.id,
34351
34630
  action: "open"
34352
34631
  });
@@ -34602,7 +34881,7 @@ var DaemonSupervisor = class {
34602
34881
  }
34603
34882
  if (changed) {
34604
34883
  this.log.info(`machine clips synced \u2014 count=${desired.length}`);
34605
- await this.reconnectClipProvider();
34884
+ this.clipProvider?.reregister();
34606
34885
  }
34607
34886
  }
34608
34887
  startClipReconcileTimer() {
@@ -34731,15 +35010,6 @@ var DaemonSupervisor = class {
34731
35010
  }
34732
35011
  return null;
34733
35012
  }
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
35013
  async handleWorkspaceSetupRequested(agentId) {
34744
35014
  if (this.spawningAgents.has(agentId)) {
34745
35015
  this.log.info(`agent ${agentId}: workspace setup already in progress; queueing one restart`);