@apilift/cli 1.4.32 → 1.4.33

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.
@@ -19973,6 +19973,8 @@ var CaptureStore = class {
19973
19973
  `);
19974
19974
  if (!hadCandidates) this.db.exec(`INSERT INTO capture_candidates (scope_id,seq,capture_id,entry_json) SELECT e.scope_id,g.first_seq,g.capture_id,e.entry_json FROM capture_entries e JOIN (SELECT scope_id,COALESCE(json_extract(entry_json,'$.captureId'),CAST(seq AS TEXT)) AS capture_id,MIN(seq) AS first_seq,MAX(seq) AS last_seq FROM capture_entries GROUP BY scope_id,capture_id) g ON e.scope_id=g.scope_id AND e.seq=g.last_seq`);
19975
19975
  const columns = this.db.prepare("PRAGMA table_info(scopes)").all();
19976
+ if (!columns.some((column) => column.name === "owner_session")) this.db.exec("ALTER TABLE scopes ADD COLUMN owner_session TEXT");
19977
+ if (!this.db.prepare("PRAGMA table_info(bridge_status)").all().some((column) => column.name === "capabilities_json")) this.db.exec("ALTER TABLE bridge_status ADD COLUMN capabilities_json TEXT NOT NULL DEFAULT '[]'");
19976
19978
  if (!columns.some((column) => column.name === "start_url")) this.db.exec("ALTER TABLE scopes ADD COLUMN start_url TEXT");
19977
19979
  if (!columns.some((column) => column.name === "binding_mode")) this.db.exec("ALTER TABLE scopes ADD COLUMN binding_mode TEXT");
19978
19980
  if (!columns.some((column) => column.name === "retained_at")) this.db.exec("ALTER TABLE scopes ADD COLUMN retained_at REAL");
@@ -20016,8 +20018,10 @@ var CaptureStore = class {
20016
20018
  }
20017
20019
  releaseStaleScope(id2) {
20018
20020
  const capture = this.db.prepare("SELECT kind,window_json FROM scopes WHERE id=?").get(id2);
20019
- if (capture?.kind === "capture" && !capture.window_json && this.db.prepare("SELECT 1 FROM capture_candidates WHERE scope_id=? LIMIT 1").get(id2)) this.retainScope(id2);
20020
- else this.db.prepare("DELETE FROM scopes WHERE id=?").run(id2);
20021
+ if (capture?.kind === "capture" && this.db.prepare("SELECT 1 FROM capture_candidates WHERE scope_id=? LIMIT 1").get(id2)) {
20022
+ this.db.prepare("UPDATE scopes SET window_json=NULL WHERE id=?").run(id2);
20023
+ this.retainScope(id2);
20024
+ } else this.db.prepare("DELETE FROM scopes WHERE id=?").run(id2);
20021
20025
  this.emit();
20022
20026
  }
20023
20027
  row(id2, freshOwner = false) {
@@ -20059,11 +20063,12 @@ var CaptureStore = class {
20059
20063
  this.prune();
20060
20064
  return this.transaction(() => {
20061
20065
  if (input2.kind !== "inspect") {
20062
- const held = this.db.prepare("SELECT id FROM scopes WHERE page_origin=? AND retained_at IS NULL AND kind IN ('execute','capture') LIMIT 1").get(input2.pageOrigin);
20063
- if (held) throw new CaptureError("scope-busy", "This page origin already has an active execute or capture scope");
20066
+ const held = this.db.prepare("SELECT id FROM scopes WHERE page_origin=? AND retained_at IS NULL AND kind IN ('execute','capture') AND (? IS NULL OR tab_selector IS NULL OR tab_selector=?) LIMIT 1").get(input2.pageOrigin, input2.tabSelector ?? null, input2.tabSelector ?? null);
20067
+ if (held) throw new CaptureError("scope-busy", "This tab already has an active execute or capture scope");
20064
20068
  }
20065
20069
  const now = this.clock(), id2 = randomUUID(), leaseId = input2.kind === "inspect" ? null : randomUUID(), expiresAt = now + scopeTtl[input2.kind];
20066
20070
  this.db.prepare("INSERT INTO scopes (id,kind,app_id,context_id,page_origin,tab_selector,start_url,binding_mode,lease_id,owner_pid,owner_started_at,expires_at,activated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)").run(id2, input2.kind, input2.appId, input2.contextId, input2.pageOrigin, input2.tabSelector ?? null, input2.startUrl ?? null, input2.bindingMode ?? null, leaseId, input2.ownerPid, input2.ownerStartedAt, expiresAt, now);
20071
+ if (input2.ownerSession) this.db.prepare("UPDATE scopes SET owner_session=? WHERE id=?").run(input2.ownerSession, id2);
20067
20072
  this.emit();
20068
20073
  return { kind: input2.kind, id: id2, pageOrigin: input2.pageOrigin, ...leaseId ? { leaseId } : {}, ...input2.tabSelector ? { tabSelector: input2.tabSelector } : {}, ...input2.startUrl ? { startUrl: input2.startUrl } : {}, ...input2.bindingMode ? { bindingMode: input2.bindingMode } : {}, expiresAt: new Date(expiresAt).toISOString(), activatedAt: now };
20069
20074
  });
@@ -20082,8 +20087,8 @@ var CaptureStore = class {
20082
20087
  }
20083
20088
  scopes() {
20084
20089
  this.prune();
20085
- const rows = this.db.prepare("SELECT id,kind,page_origin,tab_selector,start_url,binding_mode,lease_id,expires_at,activated_at,ack_through,window_json FROM scopes WHERE retained_at IS NULL ORDER BY activated_at").all();
20086
- return rows.map((row) => ({ kind: row.kind, id: String(row.id), pageOrigin: String(row.page_origin), ...row.lease_id ? { leaseId: String(row.lease_id), ackThrough: Number(row.ack_through) } : {}, ...row.tab_selector ? { tabSelector: String(row.tab_selector) } : {}, ...row.start_url ? { startUrl: String(row.start_url) } : {}, ...row.binding_mode ? { bindingMode: row.binding_mode } : {}, expiresAt: new Date(Number(row.expires_at)).toISOString(), activatedAt: Number(row.activated_at), ...row.window_json ? { window: readJson(row.window_json) } : {} }));
20090
+ const rows = this.db.prepare("SELECT id,kind,page_origin,tab_selector,start_url,binding_mode,lease_id,expires_at,activated_at,ack_through,window_json,owner_session FROM scopes WHERE retained_at IS NULL ORDER BY activated_at").all();
20091
+ return rows.map((row) => ({ kind: row.kind, id: String(row.id), pageOrigin: String(row.page_origin), ...row.lease_id ? { leaseId: String(row.lease_id), ackThrough: Number(row.ack_through) } : {}, ...row.tab_selector ? { tabSelector: String(row.tab_selector) } : {}, ...row.start_url ? { startUrl: String(row.start_url) } : {}, ...row.binding_mode ? { bindingMode: row.binding_mode } : {}, expiresAt: new Date(Number(row.expires_at)).toISOString(), activatedAt: Number(row.activated_at), ...row.window_json ? { window: readJson(row.window_json) } : {}, ...row.owner_session ? { ownerSession: String(row.owner_session) } : {} }));
20087
20092
  }
20088
20093
  candidates(scopeId) {
20089
20094
  const row = this.row(scopeId);
@@ -20129,10 +20134,14 @@ var CaptureStore = class {
20129
20134
  }
20130
20135
  active() {
20131
20136
  this.prune();
20132
- return Boolean(this.db.prepare("SELECT 1 FROM scopes WHERE retained_at IS NULL LIMIT 1").get() || this.db.prepare("SELECT 1 FROM jobs LIMIT 1").get() || this.db.prepare("SELECT 1 FROM scopes s WHERE s.retained_at>? AND (EXISTS (SELECT 1 FROM capture_candidates c WHERE c.scope_id=s.id AND json_extract(c.entry_json,'$.pending')=1) OR EXISTS (SELECT 1 FROM capture_body_refs r LEFT JOIN blobs b ON b.scope_id=r.scope_id AND b.digest=r.digest WHERE r.scope_id=s.id AND COALESCE(b.complete,0)=0)) LIMIT 1").get(this.clock() - 2e4));
20137
+ return Boolean(this.db.prepare("SELECT 1 FROM scopes WHERE retained_at IS NULL LIMIT 1").get() || this.db.prepare("SELECT 1 FROM jobs j JOIN scopes s ON s.id=j.scope_id WHERE s.retained_at IS NULL LIMIT 1").get() || this.db.prepare("SELECT 1 FROM scopes s WHERE s.retained_at>? AND (EXISTS (SELECT 1 FROM capture_candidates c WHERE c.scope_id=s.id AND json_extract(c.entry_json,'$.pending')=1) OR EXISTS (SELECT 1 FROM capture_body_refs r LEFT JOIN blobs b ON b.scope_id=r.scope_id AND b.digest=r.digest WHERE r.scope_id=s.id AND COALESCE(b.complete,0)=0)) LIMIT 1").get(this.clock() - 2e4));
20133
20138
  }
20134
- bridgeReady(version2) {
20135
- this.db.prepare("INSERT INTO bridge_status(id,version,updated_at) VALUES(1,?,?) ON CONFLICT(id) DO UPDATE SET version=excluded.version,updated_at=excluded.updated_at").run(version2, this.clock());
20139
+ bridgeReady(version2, capabilities = []) {
20140
+ this.db.prepare("INSERT INTO bridge_status(id,version,updated_at,capabilities_json) VALUES(1,?,?,?) ON CONFLICT(id) DO UPDATE SET version=excluded.version,updated_at=excluded.updated_at,capabilities_json=excluded.capabilities_json").run(version2, this.clock(), json3(capabilities));
20141
+ }
20142
+ bridgeCapabilities() {
20143
+ const row = this.db.prepare("SELECT capabilities_json FROM bridge_status WHERE id=1 AND updated_at>?").get(this.clock() - 6e4);
20144
+ return row ? readJson(row.capabilities_json) : [];
20136
20145
  }
20137
20146
  async changes(after, waitMs) {
20138
20147
  if (this.revision !== after || waitMs <= 0) return { revision: this.revision };
@@ -20172,6 +20181,8 @@ var CaptureStore = class {
20172
20181
  if (request2.payload.origin !== row.page_origin || new URL(request2.payload.url).origin !== row.page_origin) throw new CaptureError("origin-mismatch", "Claimed tab does not match the scope origin");
20173
20182
  const found = this.candidates(scopeId).find((item) => item.selector === request2.payload.tabSelector && item.tabId === request2.payload.tabId);
20174
20183
  if (!found || row.tab_selector && row.tab_selector !== found.selector) throw new CaptureError("tab-gone", "The selected tab is no longer available");
20184
+ const held = this.db.prepare("SELECT id FROM scopes WHERE id<>? AND retained_at IS NULL AND claim_tab_id=? AND claim_client_id=? LIMIT 1").get(scopeId, found.tabId, request2.payload.clientId);
20185
+ if (held) throw new CaptureError("scope-busy", "Another live scope owns the selected physical tab");
20175
20186
  this.db.prepare("UPDATE scopes SET claim_tab_id=?,claim_selector=?,claim_client_id=? WHERE id=?").run(found.tabId, found.selector, request2.payload.clientId, scopeId);
20176
20187
  this.emit();
20177
20188
  return { ackThrough: Number(row.ack_through), captureBatchMax: 32 };
@@ -20180,7 +20191,7 @@ var CaptureStore = class {
20180
20191
  if (row.kind === "inspect") throw new CaptureError("inspect-only", "Inspect scopes have no claim heartbeat");
20181
20192
  if (row.claim_client_id && request2.payload.clientId !== row.claim_client_id) throw new CaptureError("client-mismatch", "Another extension client owns this claim");
20182
20193
  if (request2.payload.origin && request2.payload.origin !== row.page_origin) throw new CaptureError("origin-mismatch", "Heartbeat origin does not match the scope");
20183
- this.db.prepare("UPDATE scopes SET expires_at=? WHERE id=?").run(row.kind === "capture" && !row.window_json ? Math.min(Number(row.expires_at), now + 12e4) : now + scopeTtl[row.kind], scopeId);
20194
+ this.db.prepare("UPDATE scopes SET expires_at=? WHERE id=?").run(row.kind === "capture" ? Math.min(Number(row.activated_at) + scopeTtl.capture, row.window_json ? now + scopeTtl.capture : now + 12e4) : now + scopeTtl.execute, scopeId);
20184
20195
  if (request2.payload.lost) this.db.prepare("UPDATE scopes SET claim_tab_id=NULL,claim_selector=NULL WHERE id=?").run(scopeId);
20185
20196
  return { ackThrough: Number(row.ack_through), captureBatchMax: 32 };
20186
20197
  }
@@ -20319,13 +20330,13 @@ async function startCaptureServer(options) {
20319
20330
  return;
20320
20331
  }
20321
20332
  if (path === "/scopes" && request2.method === "GET") {
20322
- send(response, 200, { scopes: store.scopes() });
20333
+ send(response, 200, { scopes: store.scopes(), extensionCapabilities: store.bridgeCapabilities() });
20323
20334
  return;
20324
20335
  }
20325
20336
  if (path === "/bridge/ready" && request2.method === "POST") {
20326
20337
  const body2 = object2(await readBody(request2));
20327
- if (typeof body2.version !== "string") throw new CaptureError("invalid-bridge-version", "Native host version is required");
20328
- store.bridgeReady(body2.version);
20338
+ if (typeof body2.version !== "string" || body2.capabilities !== void 0 && (!Array.isArray(body2.capabilities) || body2.capabilities.some((value) => typeof value !== "string"))) throw new CaptureError("invalid-bridge-version", "Native host version and supported capabilities are required");
20339
+ store.bridgeReady(body2.version, body2.capabilities);
20329
20340
  send(response, 200, { ok: true });
20330
20341
  return;
20331
20342
  }
@@ -7208,6 +7208,7 @@ var LocalState = class {
7208
7208
  if (version2.user_version < 4) {
7209
7209
  this.db.exec("BEGIN EXCLUSIVE; CREATE TABLE pending_execution_report (id TEXT PRIMARY KEY, report_json TEXT NOT NULL, created_at TEXT NOT NULL); PRAGMA user_version=4; COMMIT");
7210
7210
  }
7211
+ this.db.exec("CREATE TABLE IF NOT EXISTS browser_binding (agent_session_id TEXT NOT NULL, app_id TEXT NOT NULL, tab_selector TEXT NOT NULL, PRIMARY KEY(agent_session_id,app_id))");
7211
7212
  }
7212
7213
  installation() {
7213
7214
  const row = this.db.prepare("SELECT id, token, account_id, workspace_id FROM installation LIMIT 1").get();
@@ -7232,9 +7233,16 @@ var LocalState = class {
7232
7233
  this.db.prepare("INSERT INTO use_session (agent_session_id, app_id, context_json, credentials_json) VALUES (?, ?, ?, ?) ON CONFLICT(agent_session_id, app_id) DO UPDATE SET context_json=excluded.context_json, credentials_json=excluded.credentials_json").run(value.agentSessionId, value.appId, JSON.stringify(value.context), JSON.stringify(value.credentials));
7233
7234
  }
7234
7235
  releaseUseSession(agentSessionId, appId) {
7236
+ this.db.prepare("DELETE FROM browser_binding WHERE agent_session_id=? AND app_id=?").run(agentSessionId, appId);
7235
7237
  this.db.prepare("DELETE FROM use_session WHERE agent_session_id=? AND app_id=?").run(agentSessionId, appId);
7236
7238
  this.db.prepare("DELETE FROM selected_app WHERE agent_session_id=? AND app_id=?").run(agentSessionId, appId);
7237
7239
  }
7240
+ browserTab(agentSessionId, appId) {
7241
+ return this.db.prepare("SELECT tab_selector FROM browser_binding WHERE agent_session_id=? AND app_id=?").get(agentSessionId, appId)?.tab_selector;
7242
+ }
7243
+ selectBrowserTab(agentSessionId, appId, tab) {
7244
+ this.db.prepare("INSERT INTO browser_binding VALUES (?,?,?) ON CONFLICT(agent_session_id,app_id) DO UPDATE SET tab_selector=excluded.tab_selector").run(agentSessionId, appId, tab);
7245
+ }
7238
7246
  selectedApp(agentSessionId) {
7239
7247
  const row = this.db.prepare("SELECT app_id, slug, title FROM selected_app WHERE agent_session_id=?").get(agentSessionId);
7240
7248
  return row ? { agentSessionId, appId: row.app_id, slug: row.slug, title: row.title } : null;
@@ -9211,6 +9211,7 @@ var LocalState = class {
9211
9211
  if (version2.user_version < 4) {
9212
9212
  this.db.exec("BEGIN EXCLUSIVE; CREATE TABLE pending_execution_report (id TEXT PRIMARY KEY, report_json TEXT NOT NULL, created_at TEXT NOT NULL); PRAGMA user_version=4; COMMIT");
9213
9213
  }
9214
+ this.db.exec("CREATE TABLE IF NOT EXISTS browser_binding (agent_session_id TEXT NOT NULL, app_id TEXT NOT NULL, tab_selector TEXT NOT NULL, PRIMARY KEY(agent_session_id,app_id))");
9214
9215
  }
9215
9216
  installation() {
9216
9217
  const row = this.db.prepare("SELECT id, token, account_id, workspace_id FROM installation LIMIT 1").get();
@@ -9235,9 +9236,16 @@ var LocalState = class {
9235
9236
  this.db.prepare("INSERT INTO use_session (agent_session_id, app_id, context_json, credentials_json) VALUES (?, ?, ?, ?) ON CONFLICT(agent_session_id, app_id) DO UPDATE SET context_json=excluded.context_json, credentials_json=excluded.credentials_json").run(value.agentSessionId, value.appId, JSON.stringify(value.context), JSON.stringify(value.credentials));
9236
9237
  }
9237
9238
  releaseUseSession(agentSessionId, appId) {
9239
+ this.db.prepare("DELETE FROM browser_binding WHERE agent_session_id=? AND app_id=?").run(agentSessionId, appId);
9238
9240
  this.db.prepare("DELETE FROM use_session WHERE agent_session_id=? AND app_id=?").run(agentSessionId, appId);
9239
9241
  this.db.prepare("DELETE FROM selected_app WHERE agent_session_id=? AND app_id=?").run(agentSessionId, appId);
9240
9242
  }
9243
+ browserTab(agentSessionId, appId) {
9244
+ return this.db.prepare("SELECT tab_selector FROM browser_binding WHERE agent_session_id=? AND app_id=?").get(agentSessionId, appId)?.tab_selector;
9245
+ }
9246
+ selectBrowserTab(agentSessionId, appId, tab) {
9247
+ this.db.prepare("INSERT INTO browser_binding VALUES (?,?,?) ON CONFLICT(agent_session_id,app_id) DO UPDATE SET tab_selector=excluded.tab_selector").run(agentSessionId, appId, tab);
9248
+ }
9241
9249
  selectedApp(agentSessionId) {
9242
9250
  const row = this.db.prepare("SELECT app_id, slug, title FROM selected_app WHERE agent_session_id=?").get(agentSessionId);
9243
9251
  return row ? { agentSessionId, appId: row.app_id, slug: row.slug, title: row.title } : null;
@@ -9269,7 +9277,7 @@ var LocalState = class {
9269
9277
  };
9270
9278
 
9271
9279
  // src/native-host/host.ts
9272
- import { createHash as createHash3 } from "node:crypto";
9280
+ import { createHash as createHash4 } from "node:crypto";
9273
9281
  import { readFileSync as readFileSync4, rmSync as rmSync2 } from "node:fs";
9274
9282
  import { join as join3 } from "node:path";
9275
9283
 
@@ -29838,8 +29846,16 @@ function createCliApi(baseUrl, fetcher = fetch, token, sessionId) {
29838
29846
  return createApiClient(baseUrl, transport);
29839
29847
  }
29840
29848
 
29841
- // src/capture-server/client.ts
29849
+ // src/session.ts
29842
29850
  import { createHash } from "node:crypto";
29851
+ function apiSessionId(installationId, session) {
29852
+ if (!session) return void 0;
29853
+ const hex3 = createHash("sha256").update("apilift-api-session/v1\0").update(installationId).update("\0").update(session.id).digest("hex");
29854
+ return `${hex3.slice(0, 8)}-${hex3.slice(8, 12)}-5${hex3.slice(13, 16)}-a${hex3.slice(17, 20)}-${hex3.slice(20, 32)}`;
29855
+ }
29856
+
29857
+ // src/capture-server/client.ts
29858
+ import { createHash as createHash2 } from "node:crypto";
29843
29859
  import { chmodSync as chmodSync2, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync } from "node:fs";
29844
29860
  import { dirname, join as join2 } from "node:path";
29845
29861
  import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
@@ -29868,8 +29884,8 @@ var CaptureServerClient = class {
29868
29884
  scopes() {
29869
29885
  return this.call("GET", "/scopes");
29870
29886
  }
29871
- bridgeReady(version2) {
29872
- return this.call("POST", "/bridge/ready", { version: version2 }).then(() => {
29887
+ bridgeReady(version2, capabilities = []) {
29888
+ return this.call("POST", "/bridge/ready", { version: version2, capabilities }).then(() => {
29873
29889
  });
29874
29890
  }
29875
29891
  candidates(id2) {
@@ -29889,7 +29905,7 @@ var CaptureServerClient = class {
29889
29905
  throw Object.assign(new Error(issue2.error?.message ?? `Capture server HTTP ${response.status}`), { code: issue2.error?.code ?? "capture-server-error" });
29890
29906
  }
29891
29907
  const bytes = Buffer.from(await response.arrayBuffer());
29892
- if (createHash("sha256").update(bytes).digest("hex") !== digest) throw Object.assign(new Error("Selected body digest does not match stored bytes"), { code: "blob-integrity" });
29908
+ if (createHash2("sha256").update(bytes).digest("hex") !== digest) throw Object.assign(new Error("Selected body digest does not match stored bytes"), { code: "blob-integrity" });
29893
29909
  return bytes;
29894
29910
  }
29895
29911
  openScope(input2) {
@@ -29976,7 +29992,7 @@ function installedCaptureVersion(script) {
29976
29992
  }
29977
29993
 
29978
29994
  // src/discovery/credential-draft.ts
29979
- import { createHash as createHash2 } from "node:crypto";
29995
+ import { createHash as createHash3 } from "node:crypto";
29980
29996
  import { readFileSync as readFileSync3 } from "node:fs";
29981
29997
 
29982
29998
  // ../../node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/impl/scanner.js
@@ -30870,6 +30886,7 @@ function parseDraftEnvelope(source) {
30870
30886
  if (value.dependencies !== void 0 && !object2(value.dependencies)) issues.push({ path: "/dependencies", message: "Dependencies must be an object" });
30871
30887
  if (value.verification !== void 0 && !object2(value.verification)) issues.push({ path: "/verification", message: "Verification settings must be an object" });
30872
30888
  if (value.capture !== void 0 && !object2(value.capture)) issues.push({ path: "/capture", message: "Capture settings must be an object" });
30889
+ if (object2(value.capture) && value.capture.tab !== void 0 && (typeof value.capture.tab !== "string" || value.capture.tab.length > 160)) issues.push({ path: "/capture/tab", message: "Use a tab selector returned by browser tabs, or leave it empty to use the session selection" });
30873
30890
  if (issues.length) return { issues };
30874
30891
  return { envelope: value, issues };
30875
30892
  }
@@ -30914,7 +30931,7 @@ function readCredentialDraft(filePath) {
30914
30931
  }
30915
30932
  const transport = checked.definition;
30916
30933
  if (!transport.description.trim()) throw new Error(`Research credential acquisition and edit /definition/description in ${filePath}. Run discover check before secure setup`);
30917
- return { transport, fingerprint: createHash2("sha256").update(canonicalJson(transport)).digest("hex") };
30934
+ return { transport, fingerprint: createHash3("sha256").update(canonicalJson(transport)).digest("hex") };
30918
30935
  }
30919
30936
 
30920
30937
  // src/native-host/credentials.ts
@@ -31401,12 +31418,15 @@ var NativeHost = class {
31401
31418
  state;
31402
31419
  analytics;
31403
31420
  helloDone = false;
31421
+ extensionCapabilities = [];
31422
+ recordingHeartbeatAt = 0;
31423
+ recordingHeartbeatPending = false;
31404
31424
  reportedReady = false;
31405
31425
  stopped = false;
31406
31426
  client;
31407
31427
  registryCache;
31408
31428
  installationKey() {
31409
- return createHash3("sha256").update(JSON.stringify(this.state.installation())).digest("hex");
31429
+ return createHash4("sha256").update(JSON.stringify(this.state.installation())).digest("hex");
31410
31430
  }
31411
31431
  lanes = { control: [], scope: [], binding: [], metadata: [], blob: [], poll: [] };
31412
31432
  admittedAt = /* @__PURE__ */ new WeakMap();
@@ -31573,6 +31593,7 @@ var NativeHost = class {
31573
31593
  if (this.options.registry || this.options.fetcher) void this.publishReadiness(registry2);
31574
31594
  else this.refreshRegistry();
31575
31595
  this.helloDone = true;
31596
+ this.extensionCapabilities = request2.payload.capabilities;
31576
31597
  if (!this.restartTimer) {
31577
31598
  this.restartTimer = setInterval(() => {
31578
31599
  void this.checkRestart();
@@ -31592,7 +31613,10 @@ var NativeHost = class {
31592
31613
  if (request2.operation === "scopes") {
31593
31614
  const client2 = await this.capture();
31594
31615
  const scopes = await client2.scopes();
31595
- if (!client2.legacyBridge) await client2.bridgeReady?.(this.options.version);
31616
+ if (this.stopped) return;
31617
+ void this.heartbeatRecordingSessions(scopes.scopes).catch(() => {
31618
+ });
31619
+ if (!client2.legacyBridge) await client2.bridgeReady?.(this.options.version, this.extensionCapabilities);
31596
31620
  this.analytics.recovered();
31597
31621
  if (!this.reportedReady) {
31598
31622
  this.reportedReady = true;
@@ -31606,7 +31630,11 @@ var NativeHost = class {
31606
31630
  if (registry2 && !this.options.registry && !this.options.fetcher) this.refreshRegistry();
31607
31631
  if (!this.state.installation()) this.registryCache = void 0;
31608
31632
  if (!this.stopped) this.analytics.updateIdentity(registry2?.identity ?? null);
31609
- return { scopes: scopes.scopes, ...registry2?.identity ? { identity: registry2.identity } : {} };
31633
+ return { scopes: scopes.scopes.map((scope2) => {
31634
+ const wire = { ...scope2 };
31635
+ delete wire.ownerSession;
31636
+ return wire;
31637
+ }), ...registry2?.identity ? { identity: registry2.identity } : {} };
31610
31638
  }
31611
31639
  if (request2.operation.startsWith("credentials.")) {
31612
31640
  const payload = request2.payload;
@@ -31674,6 +31702,27 @@ var NativeHost = class {
31674
31702
  }
31675
31703
  }
31676
31704
  }
31705
+ async heartbeatRecordingSessions(scopes) {
31706
+ if (this.stopped) return;
31707
+ const installation = this.state.installation();
31708
+ const owners = [...new Set(scopes.filter((scope2) => scope2.kind === "capture" && scope2.window && scope2.ownerSession).map((scope2) => scope2.ownerSession))];
31709
+ if (!installation || !owners.length || this.recordingHeartbeatPending || Date.now() - this.recordingHeartbeatAt < 3e4) return;
31710
+ this.recordingHeartbeatAt = Date.now();
31711
+ this.recordingHeartbeatPending = true;
31712
+ try {
31713
+ const api = createCliApi(this.options.siteUrl, this.options.fetcher ?? fetch, installation.token);
31714
+ for (const owner of owners.slice(0, 100)) {
31715
+ if (this.stopped) break;
31716
+ const agent2 = owner.startsWith("claude:") ? "claude" : "codex";
31717
+ try {
31718
+ await api.call("acquireSession", { params: { id: apiSessionId(installation.id, { id: owner, agent: agent2 }) }, body: { purpose: "discovery" } });
31719
+ } catch {
31720
+ }
31721
+ }
31722
+ } finally {
31723
+ this.recordingHeartbeatPending = false;
31724
+ }
31725
+ }
31677
31726
  fatal(error62) {
31678
31727
  if (this.helloDone) this.analytics.failure(publicError(error62).code, "unknown", "framing");
31679
31728
  process.stderr.write(`${publicError(error62).code}: ${error62.message}
@@ -31692,7 +31741,7 @@ var NativeHost = class {
31692
31741
  }
31693
31742
  };
31694
31743
  function hostFingerprint(script) {
31695
- return createHash3("sha256").update(readFileSync4(script)).digest("hex");
31744
+ return createHash4("sha256").update(readFileSync4(script)).digest("hex");
31696
31745
  }
31697
31746
 
31698
31747
  // src/capture-server/demand.ts
package/dist/apilift.js CHANGED
@@ -28571,7 +28571,7 @@ var init_registry = __esm({
28571
28571
  credentialsAttach: { path: ["credentials", "attach"], min: 3, max: 3 },
28572
28572
  transportsShow: { path: ["transports", "show"], min: 1, max: 2 },
28573
28573
  browserCheck: { path: ["browser", "check"], min: 1, max: 1 },
28574
- browserTabs: { path: ["browser", "tabs"], min: 1, max: 1 },
28574
+ browserTabs: { path: ["browser", "tabs"], min: 1, max: 1, values: ["select"] },
28575
28575
  contextSet: { path: ["context", "set"], min: 2, max: 2, values: ["app"] },
28576
28576
  contextShow: { path: ["context", "show"], min: 0, max: 0, values: ["app"] },
28577
28577
  contextUnset: { path: ["context", "unset"], min: 1, max: 1, values: ["app"] },
@@ -30228,6 +30228,7 @@ function parseDraftEnvelope(source) {
30228
30228
  if (value.dependencies !== void 0 && !object3(value.dependencies)) issues.push({ path: "/dependencies", message: "Dependencies must be an object" });
30229
30229
  if (value.verification !== void 0 && !object3(value.verification)) issues.push({ path: "/verification", message: "Verification settings must be an object" });
30230
30230
  if (value.capture !== void 0 && !object3(value.capture)) issues.push({ path: "/capture", message: "Capture settings must be an object" });
30231
+ if (object3(value.capture) && value.capture.tab !== void 0 && (typeof value.capture.tab !== "string" || value.capture.tab.length > 160)) issues.push({ path: "/capture/tab", message: "Use a tab selector returned by browser tabs, or leave it empty to use the session selection" });
30231
30232
  if (issues.length) return { issues };
30232
30233
  return { envelope: value, issues };
30233
30234
  }
@@ -30267,7 +30268,7 @@ function draftTemplate(kind) {
30267
30268
  app: { creation: { visibility: "private" }, definition: { title: "", description: "", origin: "", category: "other" } },
30268
30269
  entity: { definition: { title: "", description: "" } },
30269
30270
  action: { definition: { title: "", description: "", effect: "read", inputs: { type: "object", properties: {}, additionalProperties: false }, request: { method: "GET", url: "" }, response: { schema: { type: "object", properties: {} } } }, dependencies: { transport: "" }, verification: { inputs: {}, review: "pending", notes: "" }, capture: { tab: "", authField: "", authScope: "" } },
30270
- transport: { definition: { title: "", description: "", mode: "direct", baseUrl: "", allowedOrigins: [], credentials: [], probes: {} }, dependencies: {}, verification: { inputs: {}, probeOrigin: "", review: "pending", notes: "" } },
30271
+ transport: { definition: { title: "", description: "", mode: "direct", baseUrl: "", allowedOrigins: [], credentials: [], probes: {} }, dependencies: {}, verification: { inputs: {}, probeOrigin: "", review: "pending", notes: "" }, capture: { tab: "" } },
30271
30272
  "browser-page": { definition: { title: "", description: "", pageOrigin: "", startUrl: "" } }
30272
30273
  };
30273
30274
  const probeGuidance = kind === "transport" ? [
@@ -30284,8 +30285,9 @@ function draftTemplate(kind) {
30284
30285
  "// A locator hypothesis is not proof. Never copy a token value; verify the browser connection with discover run before accepting it.",
30285
30286
  ""
30286
30287
  ].join("\n") : "";
30288
+ const tabGuidance = ["action", "transport"].includes(kind) ? "// capture.tab is a selector returned by browser tabs; it selects the same signed-in Chrome tab for capture and discover run, including connection probes. Leave other sessions' tabs untouched.\n" : "";
30287
30289
  const captureGuidance = kind === "action" ? '// Request JSON/query uses typed {"$input":"name"} bindings; URL segments use {input.name}. Resolve every {unresolved.N} marker from retained evidence before running.\n// Mutation proof compares a readback path with exactly one input, primary result path or internal JSON value: equals: [{"path":"/ready","value":true}]. Public literals require semantic review; fixed proof expectations are not caller flags.\n// After selecting a captured request, discover capture auth runs a bounded default search without authField or authScope. Set either only to narrow an observed field or location; browser-managed cookies need no locator.\n' : "";
30288
- return `${head}${probeGuidance}${captureGuidance}${JSON.stringify(templates[kind], null, 2)}
30290
+ return `${head}${probeGuidance}${tabGuidance}${captureGuidance}${JSON.stringify(templates[kind], null, 2)}
30289
30291
  `;
30290
30292
  }
30291
30293
  var browserLocatorExample, names, object3, pointer;
@@ -31282,8 +31284,8 @@ var init_client2 = __esm({
31282
31284
  scopes() {
31283
31285
  return this.call("GET", "/scopes");
31284
31286
  }
31285
- bridgeReady(version2) {
31286
- return this.call("POST", "/bridge/ready", { version: version2 }).then(() => {
31287
+ bridgeReady(version2, capabilities = []) {
31288
+ return this.call("POST", "/bridge/ready", { version: version2, capabilities }).then(() => {
31287
31289
  });
31288
31290
  }
31289
31291
  candidates(id2) {
@@ -31383,6 +31385,17 @@ var init_demand = __esm({
31383
31385
  // src/commands/browser.ts
31384
31386
  import { realpathSync } from "node:fs";
31385
31387
  import { dirname as dirname3, join as join6 } from "node:path";
31388
+ async function checkTabOwnership(client, origin, tab, app2, prefix) {
31389
+ const state = await client.scopes();
31390
+ const active2 = state.scopes.filter((scope2) => scope2.pageOrigin === origin && scope2.kind !== "inspect");
31391
+ const held = active2.find((scope2) => !scope2.tabSelector || scope2.tabSelector === tab);
31392
+ if (held) {
31393
+ const ownerLabel = held.ownerSession?.split(":label:")[1];
31394
+ const settlement = held.kind === "capture" ? ownerLabel ? `Ask the owner to run apilift --session ${ownerLabel} discover capture stop or apilift --session ${ownerLabel} discover capture cancel` : "Ask the owning session to stop or cancel its recording" : "Wait for the owning command to finish or its lease to expire";
31395
+ throw new BrowserControlError(`The selected tab has a live ${held.kind} lease${held.ownerSession ? ` owned by ${held.ownerSession}` : ""}, started ${new Date(held.activatedAt).toISOString()}, expires ${held.expiresAt}. ${settlement}; leave its tab open. For independent work, open another signed-in Chrome tab, start a distinct worker with apilift --session <new-worker-label> apps update ${app2}, list tabs with apilift --session <new-worker-label> browser tabs ${app2}, then bind a returned selector with apilift --session <new-worker-label> browser tabs ${app2} --select <returned-tab-id>. Capture and execution use that worker's selected tab`, "tab-owned");
31396
+ }
31397
+ if (active2.length && !state.extensionCapabilities?.includes("tab-scopes-v1")) throw new BrowserControlError(`The browser bridge has not advertised independent-tab support. Update the CLI with apilift update and Apilift Companion in Chrome. Leave the other session's tab open; after its command finishes, retry ${prefix} browser check ${app2} and ${prefix} discover run`, "extension-update-required");
31398
+ }
31386
31399
  async function browserContext(api, appId) {
31387
31400
  const app2 = await api.call("getApp", { params: { app: appId } });
31388
31401
  const transports = app2.transports;
@@ -31427,7 +31440,7 @@ async function inspectTabs(client, appId, contextId, pageOrigin) {
31427
31440
  await client.closeScope(scope2.id);
31428
31441
  }
31429
31442
  }
31430
- async function handleBrowser(invocation, api, _state, session, root) {
31443
+ async function handleBrowser(invocation, api, state, session, root) {
31431
31444
  const appRef = invocation.positionals[0];
31432
31445
  if (!session) return fail(`A stable agent identity is required for browser work. Use --session <worker-label>. No browser scope was opened.
31433
31446
  `, 2);
@@ -31436,8 +31449,10 @@ async function handleBrowser(invocation, api, _state, session, root) {
31436
31449
  if (!context) return fail(`browser-page-unselected \u2014 ${app2.title} has no unambiguous published browser page. Publish one page or a browser connection that selects its page before browser work.
31437
31450
  `);
31438
31451
  let candidates;
31452
+ let client;
31439
31453
  try {
31440
- candidates = await inspectTabs(await captureClient(root), app2.id, context.name, context.pageOrigin);
31454
+ client = await captureClient(root);
31455
+ candidates = await inspectTabs(client, app2.id, context.name, context.pageOrigin);
31441
31456
  } catch (error62) {
31442
31457
  return fail(`extension-unavailable \u2014 ${error62 instanceof Error ? error62.message : "Chrome did not respond"}. Check native host and extension setup with ${command(invocation, "doctor")}.
31443
31458
  `);
@@ -31446,11 +31461,23 @@ async function handleBrowser(invocation, api, _state, session, root) {
31446
31461
  If the tab is already open, recheck the browser connection: ${command(invocation, "setup continue")}
31447
31462
  No app request was sent. Agent browser-tool control and sign-in remain unverified.
31448
31463
  `);
31449
- if (invocation.key === "browserTabs") return ok(`Tabs reported by the extension:
31464
+ if (invocation.key === "browserTabs") {
31465
+ const selected = invocation.options.select;
31466
+ if (selected) {
31467
+ if (!candidates.some((candidate2) => candidate2.selector === selected)) return fail(`The selected tab was not reported. List current tabs: ${command(invocation, `browser tabs ${app2.slug}`)}. No binding changed.
31468
+ `, 2);
31469
+ state.selectBrowserTab(session.id, app2.id, selected);
31470
+ return ok(`Selected ${selected} for ${app2.title} in this agent session. Capture, connection probes and published action execution use this tab. An explicit capture.tab in a draft overrides this choice. No recording started and no app request was sent.
31471
+ `);
31472
+ }
31473
+ const scopes = (await client.scopes()).scopes.filter((scope2) => scope2.pageOrigin === context.pageOrigin && scope2.kind !== "inspect");
31474
+ return ok(`Tabs reported by the extension:
31450
31475
  ${candidates.map((item) => ` ${item.selector} \u2014 ${item.title}${item.active ? " (active)" : ""}`).join("\n")}
31451
31476
 
31452
- Set capture.tab in the current draft to the intended tab ID. Do not infer the correct tab from an example ID.
31453
- `);
31477
+ Select the intended returned tab for capture and execution in this session: ${command(invocation, `browser tabs ${app2.slug} --select <returned-tab-id>`)}. A draft's capture.tab overrides the session choice. Do not infer the correct tab from an example ID.
31478
+ ${scopes.map((scope2) => `Active ${scope2.kind} lease: ${scope2.tabSelector ?? "unselected tab"}; owner ${scope2.ownerSession ?? "not recorded"}; started ${new Date(scope2.activatedAt).toISOString()}; expires ${scope2.expiresAt}. Ask its owning session to stop/cancel before reusing that tab.
31479
+ `).join("")}`);
31480
+ }
31454
31481
  if (candidates.length > 1) return ok(`Several ${app2.title} tabs match. No tab was selected and recording did not start.
31455
31482
  List them: ${command(invocation, `browser tabs ${app2.slug}`)}
31456
31483
  Choose the intended reported selector in the current draft.
@@ -31459,7 +31486,7 @@ Choose the intended reported selector in the current draft.
31459
31486
  Agent browser-tool control and ${app2.title} sign-in are not established by this check. Use your browser tools to inspect the actual page before observing an operation.
31460
31487
  `);
31461
31488
  }
31462
- var ok, fail, command;
31489
+ var ok, fail, command, BrowserControlError;
31463
31490
  var init_browser = __esm({
31464
31491
  "src/commands/browser.ts"() {
31465
31492
  "use strict";
@@ -31469,6 +31496,13 @@ var init_browser = __esm({
31469
31496
  ok = (stdout) => ({ stdout, stderr: "", exitCode: 0 });
31470
31497
  fail = (stderr, exitCode = 5) => ({ stdout: "", stderr, exitCode });
31471
31498
  command = (invocation, text2) => `apilift${invocation.sessionLabel ? ` --session ${invocation.sessionLabel}` : ""} ${text2}`;
31499
+ BrowserControlError = class extends Error {
31500
+ constructor(message2, code, dispatched = false) {
31501
+ super(message2);
31502
+ this.code = code;
31503
+ this.dispatched = dispatched;
31504
+ }
31505
+ };
31472
31506
  }
31473
31507
  });
31474
31508
 
@@ -31512,6 +31546,7 @@ var init_state = __esm({
31512
31546
  if (version2.user_version < 4) {
31513
31547
  this.db.exec("BEGIN EXCLUSIVE; CREATE TABLE pending_execution_report (id TEXT PRIMARY KEY, report_json TEXT NOT NULL, created_at TEXT NOT NULL); PRAGMA user_version=4; COMMIT");
31514
31548
  }
31549
+ this.db.exec("CREATE TABLE IF NOT EXISTS browser_binding (agent_session_id TEXT NOT NULL, app_id TEXT NOT NULL, tab_selector TEXT NOT NULL, PRIMARY KEY(agent_session_id,app_id))");
31515
31550
  }
31516
31551
  installation() {
31517
31552
  const row = this.db.prepare("SELECT id, token, account_id, workspace_id FROM installation LIMIT 1").get();
@@ -31536,9 +31571,16 @@ var init_state = __esm({
31536
31571
  this.db.prepare("INSERT INTO use_session (agent_session_id, app_id, context_json, credentials_json) VALUES (?, ?, ?, ?) ON CONFLICT(agent_session_id, app_id) DO UPDATE SET context_json=excluded.context_json, credentials_json=excluded.credentials_json").run(value.agentSessionId, value.appId, JSON.stringify(value.context), JSON.stringify(value.credentials));
31537
31572
  }
31538
31573
  releaseUseSession(agentSessionId, appId) {
31574
+ this.db.prepare("DELETE FROM browser_binding WHERE agent_session_id=? AND app_id=?").run(agentSessionId, appId);
31539
31575
  this.db.prepare("DELETE FROM use_session WHERE agent_session_id=? AND app_id=?").run(agentSessionId, appId);
31540
31576
  this.db.prepare("DELETE FROM selected_app WHERE agent_session_id=? AND app_id=?").run(agentSessionId, appId);
31541
31577
  }
31578
+ browserTab(agentSessionId, appId) {
31579
+ return this.db.prepare("SELECT tab_selector FROM browser_binding WHERE agent_session_id=? AND app_id=?").get(agentSessionId, appId)?.tab_selector;
31580
+ }
31581
+ selectBrowserTab(agentSessionId, appId, tab) {
31582
+ this.db.prepare("INSERT INTO browser_binding VALUES (?,?,?) ON CONFLICT(agent_session_id,app_id) DO UPDATE SET tab_selector=excluded.tab_selector").run(agentSessionId, appId, tab);
31583
+ }
31542
31584
  selectedApp(agentSessionId) {
31543
31585
  const row = this.db.prepare("SELECT app_id, slug, title FROM selected_app WHERE agent_session_id=?").get(agentSessionId);
31544
31586
  return row ? { agentSessionId, appId: row.app_id, slug: row.slug, title: row.title } : null;
@@ -32058,7 +32100,7 @@ async function send(fetcher, prepared, allowNonJsonProbe = false) {
32058
32100
  const body = await responseBody(response, allowNonJsonProbe);
32059
32101
  return { response, body };
32060
32102
  }
32061
- async function sendBrowser(root, appId, contextName, pageOrigin, effect2, transportName, transport, prepared, dispatched, credentialId, credentialDraftId, allowNonJsonProbe = false, dispatchBefore) {
32103
+ async function sendBrowser(root, appId, contextName, pageOrigin, effect2, transportName, transport, prepared, dispatched, credentialId, credentialDraftId, allowNonJsonProbe = false, dispatchBefore, selection = {}) {
32062
32104
  const client = await captureClient(root);
32063
32105
  const inspect = await client.openScope({ kind: "inspect", appId, contextId: contextName, pageOrigin, ownerPid: process.pid, ownerStartedAt: Date.now() - process.uptime() * 1e3 });
32064
32106
  let candidates = [];
@@ -32072,8 +32114,11 @@ async function sendBrowser(root, appId, contextName, pageOrigin, effect2, transp
32072
32114
  } finally {
32073
32115
  await client.closeScope(inspect.id);
32074
32116
  }
32075
- if (candidates.length !== 1) throw new Error(candidates.length ? "Several matching Chrome tabs were reported; use browser tabs and close or disambiguate them before execution" : "No matching Chrome tab was reported within 10 seconds");
32076
- const scope2 = await client.openScope({ kind: "execute", appId, contextId: contextName, pageOrigin, tabSelector: candidates[0].selector, ownerPid: process.pid, ownerStartedAt: Date.now() - process.uptime() * 1e3 });
32117
+ const app2 = selection.appSlug ?? appId, prefix = selection.prefix ?? "apilift";
32118
+ const tab = selection.tab ? candidates.find((candidate2) => candidate2.selector === selection.tab) : candidates.length === 1 ? candidates[0] : void 0;
32119
+ if (!tab) throw new BrowserControlError(selection.tab ? `Configured capture.tab is not present. Inspect ${prefix} browser tabs ${app2} and select a reported tab in the current draft` : candidates.length ? `Several matching Chrome tabs were reported. Use ${prefix} browser tabs ${app2} and set capture.tab in the current draft for both capture and execution; leave other sessions' tabs open` : "No matching Chrome tab was reported within 10 seconds", "tab-unselected");
32120
+ await checkTabOwnership(client, pageOrigin, tab.selector, app2, prefix);
32121
+ const scope2 = await client.openScope({ kind: "execute", appId, contextId: contextName, pageOrigin, tabSelector: tab.selector, ownerPid: process.pid, ownerStartedAt: Date.now() - process.uptime() * 1e3, ownerSession: selection.ownerSession });
32077
32122
  const id2 = randomUUID6();
32078
32123
  try {
32079
32124
  const headers = Object.fromEntries(new Headers(prepared.init.headers).entries());
@@ -32088,7 +32133,17 @@ async function sendBrowser(root, appId, contextName, pageOrigin, effect2, transp
32088
32133
  if (held.status === "delivered") {
32089
32134
  await client.acknowledgeJob(id2);
32090
32135
  const value = held.result;
32091
- if (value.transportError) throw new Error(value.transportError);
32136
+ if (value.transportError) {
32137
+ if (value.executionState === "not-dispatched" && value.mutationDispatched !== true) {
32138
+ let message2 = value.transportError.replace(/[.\s]+$/u, "");
32139
+ if (message2.includes("still recording")) {
32140
+ const peers = (await client.scopes()).scopes.filter((peer) => peer.kind !== "inspect" && peer.id !== scope2.id);
32141
+ message2 = peers.length ? `Chrome reports a recording on this tab. Inspect ${prefix} browser tabs ${app2}; ask the owning session to stop/cancel, or bind another signed-in tab with capture.tab` : `Chrome retained a stale recording after its recorder lease ended. No live recorder owns it. Open another signed-in Chrome tab, list it with ${prefix} browser tabs ${app2}, bind its returned ID with ${prefix} browser tabs ${app2} --select <returned-tab-id>, then retry ${prefix} discover run. To recover the original tab, if no other agent is using Companion, turn Apilift Companion off and on in Chrome's Extensions page`;
32142
+ }
32143
+ throw new BrowserControlError(message2, value.code ?? "browser-not-dispatched");
32144
+ }
32145
+ throw new Error(value.transportError);
32146
+ }
32092
32147
  if (!Number.isInteger(value.status) || value.bodyState && value.bodyState !== "complete") throw new Error("Chrome did not return complete response evidence");
32093
32148
  const body = [204, 205, 304].includes(value.status) ? null : value.body ?? null;
32094
32149
  const response = new Response(body, { status: value.status });
@@ -32188,7 +32243,18 @@ Inspect current action documentation: ${prefix(`actions show ${appName} ${action
32188
32243
  dispatchedAt ??= (/* @__PURE__ */ new Date()).toISOString();
32189
32244
  };
32190
32245
  const sendRequest = async (request2, effect2) => {
32191
- if (transport.mode === "browser") return sendBrowser(root, bundle2.app.id, transport.browserContext, browserPage.pageOrigin, effect2, action.transport, transport, request2, markDispatched, selectedCredential, void 0, false, bundle2.receipt?.dispatchBefore);
32246
+ if (transport.mode === "browser") {
32247
+ const prior = dispatchedAt;
32248
+ try {
32249
+ return await sendBrowser(root, bundle2.app.id, transport.browserContext, browserPage.pageOrigin, effect2, action.transport, transport, request2, markDispatched, selectedCredential, void 0, false, bundle2.receipt?.dispatchBefore, { tab: state.browserTab(session.id, bundle2.app.id), appSlug: bundle2.app.slug, prefix: prefix("").trim(), ownerSession: session.id });
32250
+ } catch (error62) {
32251
+ if (error62 instanceof BrowserControlError && !error62.dispatched) {
32252
+ if (prior) throw new BrowserControlError("The mutation was already dispatched; its browser readback could not run. Inspect app state instead of rerunning the mutation", error62.code, true);
32253
+ dispatchedAt = void 0;
32254
+ }
32255
+ throw error62;
32256
+ }
32257
+ }
32192
32258
  if (selectedCredential) {
32193
32259
  const store = new CredentialStore(root, new KeyringVault());
32194
32260
  try {
@@ -32340,10 +32406,19 @@ async function executeDraftVerification(input2) {
32340
32406
  }
32341
32407
  }
32342
32408
  if (transport.mode === "browser") {
32343
- return sendBrowser(input2.root, input2.appId, transport.browserContext, browserPage.pageOrigin, effect2, input2.kind === "action" && action && "transport" in action ? action.transport : input2.name, transport, request2, () => {
32344
- if (!dispatched) input2.onDispatch?.();
32345
- dispatched = true;
32346
- }, credential2, credentialDraftId, allowNonJsonProbe);
32409
+ const prior = dispatched;
32410
+ try {
32411
+ return await sendBrowser(input2.root, input2.appId, transport.browserContext, browserPage.pageOrigin, effect2, input2.kind === "action" && action && "transport" in action ? action.transport : input2.name, transport, request2, () => {
32412
+ if (!dispatched) input2.onDispatch?.();
32413
+ dispatched = true;
32414
+ }, credential2, credentialDraftId, allowNonJsonProbe, void 0, { tab: input2.browserTab || input2.state.browserTab(input2.session.id, input2.appId), appSlug: input2.appSlug, prefix: input2.commandPrefix, ownerSession: input2.session.id });
32415
+ } catch (error62) {
32416
+ if (error62 instanceof BrowserControlError && !error62.dispatched) {
32417
+ if (prior) throw new BrowserControlError("The mutation was already dispatched; its browser readback could not run. Inspect app state instead of rerunning the mutation", error62.code, true);
32418
+ dispatched = false;
32419
+ }
32420
+ throw error62;
32421
+ }
32347
32422
  }
32348
32423
  if (credential2) {
32349
32424
  const store = new CredentialStore(input2.root, new KeyringVault());
@@ -32366,7 +32441,7 @@ async function executeDraftVerification(input2) {
32366
32441
  };
32367
32442
  try {
32368
32443
  const primary = await sendConfigured(prepared, input2.kind === "action" ? action.effect : "read");
32369
- if (!primary.response.ok) return { ok: false, message: `The app returned HTTP ${primary.response.status}. Inspect its state before retrying a mutation`, exitCode: 4, dispatched };
32444
+ if (!primary.response.ok) return { ok: false, message: `The app returned HTTP ${primary.response.status}${input2.kind === "transport" || action && "effect" in action && action.effect === "read" ? "" : ". Inspect its state before retrying a mutation"}`, exitCode: 4, dispatched };
32370
32445
  if (input2.kind === "action" && transport.mode === "direct" && dispatchStarted) input2.onDirectBaseline?.(Math.max(0, performance.now() - dispatchStarted), JSON.stringify({ method: prepared.init.method, url: prepared.url.toString(), body: prepared.init.body ?? null }), JSON.stringify(primary.body), dispatchAt, (/* @__PURE__ */ new Date()).toISOString());
32371
32446
  if (input2.kind === "transport") {
32372
32447
  const selected = probe.response.path !== void 0 ? pointer2(primary.body, probe.response.path) : primary.body;
@@ -32383,7 +32458,7 @@ async function executeDraftVerification(input2) {
32383
32458
  }
32384
32459
  return { ok: true, output: value, proof: "not-applicable", dispatched: true };
32385
32460
  } catch (error62) {
32386
- return { ok: false, message: `${error62 instanceof Error ? error62.message : "The test did not return"}. ${dispatched ? "The request may have reached the app; inspect its state before another mutation" : "No app request was sent"}`, exitCode: dispatched || ["extension-unavailable", "capture-server-unavailable", "native-host-upgrade-required"].includes(error62?.code ?? "") ? 5 : 2, dispatched };
32461
+ return { ok: false, message: `${(error62 instanceof Error ? error62.message : "The test did not return").replace(/[.\s]+$/u, "")}. ${dispatched ? input2.kind === "transport" || action && "effect" in action && action.effect === "read" ? "The read did not return complete evidence" : "The request may have reached the app; inspect its state before another mutation" : "No app request was sent"}`, exitCode: dispatched || error62 instanceof BrowserControlError || ["extension-unavailable", "capture-server-unavailable", "native-host-upgrade-required"].includes(error62?.code ?? "") ? 5 : 2, dispatched };
32387
32462
  }
32388
32463
  }
32389
32464
  var import__3, import_ajv_formats, ajv3, result, fail2, ResponseContractFailure, omit2;
@@ -32947,7 +33022,9 @@ Attach only after they identify it: ${command3(invocation, "credentials attach <
32947
33022
  ${item.credentials.length ? `Required saved credential field${item.credentials.length === 1 ? "" : "s"}: ${item.credentials.map((field) => field.name).join(", ")}.` : "No stored credential fields are required."}
32948
33023
  ${current2?.credentials[item.name] ? `A saved entry is selected for this agent's session.` : "No saved entry is selected for this agent's session."}
32949
33024
  ${item.instructions || "Acquisition instructions are missing from this connection."}
32950
- ${item.mode === "direct" && item.credentials.length ? "A successful connection probe does not verify how another user obtains a key. Compare these published instructions with official key-issuance and permission steps; do not invent missing steps. Secure setup saves an existing key; it does not issue one.\n" : ""}${item.credentials.length ? `Inspect saved names: ${command3(invocation, `credentials list ${selectedApp.slug} ${item.name}`)}
33025
+ ${item.mode === "direct" && item.credentials.length ? "A successful connection probe does not verify how another user obtains a key. Compare these published instructions with official key-issuance and permission steps; do not invent missing steps. Secure setup saves an existing key; it does not issue one.\n" : ""}${item.mode === "browser" ? `Select a signed-in tab for this session: ${command3(invocation, `browser tabs ${selectedApp.slug}`)}, then repeat it with --select <returned-tab-id>.
33026
+ To verify this published connection without recording, open ${command3(invocation, `apps update ${selectedApp.slug}`)}, then ${command3(invocation, `discover draft transport ${item.name}`)}. In the returned file, set verification.probeOrigin to a declared definition.probes origin and verification.inputs to the read probe's required values. Run ${command3(invocation, "discover check")}, then ${command3(invocation, "discover run")}.
33027
+ ` : ""}${item.credentials.length ? `Inspect saved names: ${command3(invocation, `credentials list ${selectedApp.slug} ${item.name}`)}
32951
33028
  Start secure setup: ${command3(invocation, `credentials add ${selectedApp.slug} ${item.name}`)}` : ""}`).join("\n\n") + `
32952
33029
 
32953
33030
  Search actions: ${command3(invocation, `actions search ${selectedApp.slug} "<keyword>"`)}
@@ -33987,7 +34064,7 @@ Abandon removal: ${command4(invocation, "discover discard")}
33987
34064
  return ok4(`Editing ${current.title} ${label(draft.kind)} ${draft.name}.
33988
34065
  File: ${draft.filePath}
33989
34066
  ${evidence ? `Latest saved execution: ${evidence.resultPath}
33990
- ` : "No execution evidence is saved for this draft.\n"}${capture?.scope_id ? `Recording phase: ${capture.phase}. ${capture.phase === "closed" ? `Temporary auth evidence is available until run, publish, defer or close releases it automatically.` : `Do not repeat the app operation. Confirm the boundary with ${command4(invocation, "discover capture stop")} or ${command4(invocation, "discover capture cancel")}.`}
34067
+ ` : "No execution evidence is saved for this draft.\n"}${capture?.phase === "lost" ? "Recorder lease ended; admission is closed and the stop boundary is unconfirmed. Inspect app state before another mutation. Retained evidence remains available.\n" : ""}${capture?.scope_id ? `Recording phase: ${capture.phase}. ${capture.phase === "closed" ? `Temporary auth evidence is available until run, publish, defer or close releases it automatically.` : `Do not repeat the app operation. Confirm the boundary with ${command4(invocation, "discover capture stop")} or ${command4(invocation, "discover capture cancel")}.`}
33991
34068
  ` : ""}${store.publication(draft.id) ? `A publication request may have been sent. Reconcile it with ${command4(invocation, "discover publish")} before changing or discarding this draft.
33992
34069
  ` : ""}
33993
34070
  Check: ${command4(invocation, "discover check")}
@@ -34031,6 +34108,8 @@ Start again: ${command4(invocation, `discover ${draft.intent === "remove" ? "rem
34031
34108
  `);
34032
34109
  if (captureRow(state, store.selected(session.id)?.id ?? "")?.scope_id) return fail5(`A recording is active. Stop it with ${command4(invocation, "discover capture stop")} or cancel it with ${command4(invocation, "discover capture cancel")} before closing.
34033
34110
  `);
34111
+ const installation = state.installation();
34112
+ if (installation) await api.call("releaseSession", { params: { id: apiSessionId(installation.id, session) } });
34034
34113
  store.close(session.id);
34035
34114
  return ok4(`Closed discovery for ${current.title}. Published actions remain usable.
34036
34115
  To continue later: ${command4(invocation, `apps update ${current.appSlug}`)}
@@ -34299,6 +34378,8 @@ async function verifyDraft(invocation, api, state, store, session, fetcher, root
34299
34378
  transport = related.definition;
34300
34379
  }
34301
34380
  if (!transport) return fail5("The configured connection is missing. No app request was sent.\n");
34381
+ const installation = state.installation();
34382
+ if (installation) await api.call("acquireSession", { params: { id: apiSessionId(installation.id, session) }, body: { purpose: "discovery", appId: draft.appId } });
34302
34383
  const write = Boolean(action && "effect" in action && action.effect !== "read");
34303
34384
  const previous = store.attempt(draft.id);
34304
34385
  if (write && previous?.fingerprint === identity2.fingerprint && previous.state === "success") {
@@ -34311,7 +34392,7 @@ Review it and publish; no app request was sent.
34311
34392
  if (write && previous?.fingerprint === identity2.fingerprint && previous.state === "possibly-dispatched") return fail5(`This configured mutation may already have reached the app. Do not repeat it. Inspect the app and use a changed, authorized test request before running again.
34312
34393
  `, 12);
34313
34394
  if (write) store.saveAttempt(draft.id, identity2.fingerprint, "possibly-dispatched");
34314
- const executed = await executeDraftVerification({ api, state, session, root, fetcher, appId: draft.appId, appSlug: draft.appSlug, kind: draft.kind, name: draft.name, action, transport, commandPrefix: command4(invocation, "").trim(), probeOrigin: envelope.verification?.probeOrigin, sampleInputs: envelope.verification?.inputs ?? {}, onDispatch: () => withBaselines(root, (baselines) => baselines.closeObservation(draft.id, "dispatch")), onDirectBaseline: (durationMs, request2, response, startedAt, finishedAt) => {
34395
+ const executed = await executeDraftVerification({ api, state, session, root, fetcher, appId: draft.appId, appSlug: draft.appSlug, kind: draft.kind, name: draft.name, action, transport, commandPrefix: command4(invocation, "").trim(), browserTab: envelope.capture?.tab || void 0, probeOrigin: envelope.verification?.probeOrigin, sampleInputs: envelope.verification?.inputs ?? {}, onDispatch: () => withBaselines(root, (baselines) => baselines.closeObservation(draft.id, "dispatch")), onDirectBaseline: (durationMs, request2, response, startedAt, finishedAt) => {
34315
34396
  if (draft.kind === "action") withBaselines(root, (baselines) => baselines.recordDirect({ draftId: draft.id, appId: draft.appId, sessionId: session.id, durationMs, request: request2, response, invocationId, startedAt, finishedAt }));
34316
34397
  } });
34317
34398
  if (!executed.ok) {
@@ -34335,7 +34416,7 @@ Saved output: ${resultPath}
34335
34416
 
34336
34417
  Review the actual objects and field meanings against the official docs or UI.
34337
34418
  Edit ${draft.filePath}: set verification.review to accepted only if this result has the intended meaning; otherwise rejected with verification.notes.
34338
- Then publish: ${command4(invocation, "discover publish")}
34419
+ ${draft.kind === "transport" && draft.baseVersion ? `If this task requires publishing connection changes or renewed verification, publish: ${command4(invocation, "discover publish")}. If you only checked the existing connection, discard this local draft: ${command4(invocation, "discover discard")}.` : `Then publish: ${command4(invocation, "discover publish")}`}
34339
34420
  Publication does not send another app request.
34340
34421
  `, exitCode: 0, stdoutJson: true, dispatched: true };
34341
34422
  }
@@ -34358,9 +34439,19 @@ function captureRow(state, draftId) {
34358
34439
  function captureEntries(row) {
34359
34440
  return row.entries_json ? JSON.parse(row.entries_json) : [];
34360
34441
  }
34442
+ async function retainedCaptureEntries(client, scopeId) {
34443
+ const entries = [];
34444
+ let after = 0;
34445
+ for (; ; ) {
34446
+ const page2 = (await client.entries(scopeId, after, 100)).entries;
34447
+ entries.push(...page2);
34448
+ if (page2.length < 100) return entries;
34449
+ after = page2.at(-1).seq;
34450
+ }
34451
+ }
34361
34452
  async function releaseCapture(state, draftId, row, root) {
34362
34453
  if (!row.scope_id) return;
34363
- await (await captureClient(root)).retainScope(row.scope_id);
34454
+ await (await captureClient(root, false)).retainScope(row.scope_id);
34364
34455
  state.db.prepare("UPDATE discovery_capture SET evidence_scope_id=COALESCE(evidence_scope_id,scope_id),scope_id=NULL WHERE draft_id=?").run(draftId);
34365
34456
  }
34366
34457
  async function awaitCaptureJob(client, id2) {
@@ -34602,15 +34693,23 @@ ${detail}
34602
34693
  if (!page2) return fail5(`Published browser page ${transport.browserContext} is missing. No recording started.
34603
34694
  `);
34604
34695
  const context = page2.definition;
34696
+ const installation = state.installation();
34697
+ if (installation) await api.call("acquireSession", { params: { id: apiSessionId(installation.id, session) }, body: { purpose: "discovery", appId: draft.appId } });
34605
34698
  const client = await captureClient(root);
34606
34699
  const tabs = await inspectTabs(client, draft.appId, transport.browserContext, context.pageOrigin);
34607
- const intended = envelope.capture?.tab;
34700
+ const intended = envelope.capture?.tab || state.browserTab(session.id, draft.appId);
34608
34701
  if (!intended && tabs.length !== 1) return fail5(`${tabs.length ? "Several matching tabs are open" : "No matching tab was reported"}. Set capture.tab in ${draft.filePath} using ${command4(invocation, `browser tabs ${draft.appSlug}`)}. No recording started.
34609
34702
  `);
34610
34703
  const tab = intended ? tabs.find((item2) => item2.selector === intended) : tabs[0];
34611
34704
  if (!tab) return fail5(`Configured capture.tab ${intended} is not present. Check ${command4(invocation, `browser tabs ${draft.appSlug}`)}. No recording started.
34612
34705
  `);
34613
- const scope2 = await client.openScope({ kind: "capture", appId: draft.appId, contextId: transport.browserContext, pageOrigin: context.pageOrigin, tabSelector: tab.selector, ownerPid: client.receipt.pid, ownerStartedAt: Date.parse(client.receipt.startedAt) });
34706
+ try {
34707
+ await checkTabOwnership(client, context.pageOrigin, tab.selector, draft.appSlug, command4(invocation, "").trim());
34708
+ } catch (error62) {
34709
+ return fail5(`${error62 instanceof Error ? error62.message : "Tab ownership is unavailable"}. No recording started.
34710
+ `, 5);
34711
+ }
34712
+ const scope2 = await client.openScope({ kind: "capture", appId: draft.appId, contextId: transport.browserContext, pageOrigin: context.pageOrigin, tabSelector: tab.selector, ownerPid: client.receipt.pid, ownerStartedAt: Date.parse(client.receipt.startedAt), ownerSession: session.id });
34614
34713
  const previousEvidence = row?.evidence_scope_id ?? row?.scope_id;
34615
34714
  if (previousEvidence) await client.closeScope(previousEvidence);
34616
34715
  const id2 = randomUUID8();
@@ -34629,11 +34728,13 @@ Perform that one UI operation now. Then ${command4(invocation, "discover capture
34629
34728
  `);
34630
34729
  }
34631
34730
  if (!row) return fail5(`No recording exists for this draft. Start with ${command4(invocation, "discover capture start")}.
34731
+ `);
34732
+ if ((key === "discoverCaptureCancel" || key === "discoverCaptureStop") && row.phase === "lost") return ok4(`Recording admission is closed because the recorder lease ended. Its stop boundary was not confirmed. Inspect app state before any mutation; retained evidence and drafts remain available. No app operation was repeated.
34632
34733
  `);
34633
34734
  if (key === "discoverCaptureCancel" && row.phase === "closed") {
34634
34735
  if (row.scope_id) {
34635
34736
  try {
34636
- await (await captureClient(root, false)).closeScope(row.scope_id);
34737
+ await (await captureClient(root, false)).retainScope(row.scope_id);
34637
34738
  } catch (error62) {
34638
34739
  return fail5(`Recording admission was closed, but scope release is unconfirmed: ${error62 instanceof Error ? error62.message : "unknown error"}. Retry ${command4(invocation, "discover capture cancel")}.
34639
34740
  `, 12);
@@ -34651,7 +34752,12 @@ Review: ${command4(invocation, "discover status")}
34651
34752
  if (!row.scope_id) return key === "discoverCaptureCancel" ? ok4("Recording admission is already closed. App operations were not undone.\n") : fail5("This recording is already closed. Inspect its candidates with discover capture list.\n");
34652
34753
  let client = await captureClient(root, false);
34653
34754
  if (!(await client.scopes()).scopes.some((scope2) => scope2.id === row.scope_id)) {
34654
- state.db.prepare("UPDATE discovery_capture SET evidence_scope_id=COALESCE(evidence_scope_id,scope_id),scope_id=NULL,job_id=NULL,phase='closed' WHERE draft_id=?").run(draft.id);
34755
+ state.db.prepare("UPDATE discovery_capture SET evidence_scope_id=COALESCE(evidence_scope_id,scope_id),scope_id=NULL,job_id=NULL,phase='lost' WHERE draft_id=?").run(draft.id);
34756
+ try {
34757
+ const entries3 = await retainedCaptureEntries(client, row.scope_id);
34758
+ state.db.prepare("UPDATE discovery_capture SET entries_json=? WHERE draft_id=?").run(JSON.stringify(entries3), draft.id);
34759
+ } catch {
34760
+ }
34655
34761
  withBaselines(root, (baselines) => baselines.closeObservation(draft.id, "capture-lost"));
34656
34762
  return fail5(`capture-scope-lost \u2014 The recorder exited before it confirmed the recording boundary. Admission is closed, but the app operation may have happened. Do not repeat a mutation. Inspect app state and the retained draft: ${draft.filePath}
34657
34763
  `, 12);
@@ -34674,14 +34780,7 @@ Review: ${command4(invocation, "discover status")}
34674
34780
  const job = await awaitCaptureJob(client, stopId);
34675
34781
  if (job.status !== "delivered" || job.result?.boundaryConfirmed !== true) return fail5(`Recording closure is unconfirmed. Do not repeat the app operation. Retry the same ${command4(invocation, key === "discoverCaptureCancel" ? "discover capture cancel" : "discover capture stop")}.
34676
34782
  `, 12);
34677
- const entries2 = [];
34678
- let after = 0;
34679
- for (; ; ) {
34680
- const page2 = (await client.entries(row.scope_id, after, 100)).entries;
34681
- entries2.push(...page2);
34682
- if (page2.length < 100) break;
34683
- after = page2.at(-1).seq;
34684
- }
34783
+ const entries2 = await retainedCaptureEntries(client, row.scope_id);
34685
34784
  await client.acknowledgeJob(stopId);
34686
34785
  state.db.prepare("UPDATE discovery_capture SET job_id=NULL,phase='closed',entries_json=? WHERE draft_id=?").run(JSON.stringify(entries2), draft.id);
34687
34786
  if (key === "discoverCaptureCancel") {
@@ -34700,7 +34799,7 @@ Select the causal request for the draft: ${command4(invocation, "discover captur
34700
34799
  List all captured metadata pages: ${command4(invocation, "discover capture list")}
34701
34800
  `);
34702
34801
  }
34703
- if (row.phase !== "closed") return fail5(`Recording is ${row.phase}. Stop or cancel it before reading candidates.
34802
+ if (row.phase !== "closed" && row.phase !== "lost") return fail5(`Recording is ${row.phase}. Stop or cancel it before reading candidates.
34704
34803
  `);
34705
34804
  const entries = captureEntries(row);
34706
34805
  if (key === "discoverCaptureList") {
@@ -34805,6 +34904,7 @@ var init_discover = __esm({
34805
34904
  init_envelope();
34806
34905
  init_lock();
34807
34906
  init_session();
34907
+ init_session();
34808
34908
  init_run();
34809
34909
  init_baselines();
34810
34910
  init_browser();
@@ -38213,7 +38313,7 @@ function commandHelp() {
38213
38313
  apilift credentials list <app> [<transport>] | use <app> <transport> <name> | add <app> <transport> | remove <app> <transport> <name>
38214
38314
  apilift credentials unbound | attach <app> <transport> <name>
38215
38315
  apilift transports show <app> [<name>]
38216
- apilift browser check <app> | browser tabs <app>
38316
+ apilift browser check <app> | browser tabs <app> [--select <tab-id>]
38217
38317
  apilift apps new <slug> | apps update <app> | apps history <app>
38218
38318
  apilift discover draft <kind> [name] | check | run | publish | status | defer | discard | rebase | close
38219
38319
  apilift discover remove <kind> <name>
@@ -38418,7 +38518,9 @@ Use apilift help commands for syntax.
38418
38518
  const apiError2 = envelope.data.error;
38419
38519
  output2 = result2("", cliExitForError(apiError2), `${apiError2.code} \u2014 ${apiError2.message}
38420
38520
  ${apiError2.hint ? `${apiError2.hint}
38421
- ` : ""}`, true);
38521
+ ` : ""}${apiError2.code === "session-limit" ? `${typeof apiError2.details.activeExpiresAt === "string" ? `Active session lease expires ${apiError2.details.activeExpiresAt}.
38522
+ ` : ""}No app request was sent.
38523
+ ` : ""}`, apiError2.code !== "session-limit");
38422
38524
  } else output2 = result2("", 11, `registry-response-invalid \u2014 The registry returned HTTP ${error62.status} without a valid JSON response. The request was sent; check its outcome before retrying a mutation.
38423
38525
  `, true);
38424
38526
  } else {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apilift",
3
- "version": "1.4.32",
3
+ "version": "1.4.33",
4
4
  "description": "Use logged-in apps and discover missing actions with Apilift.",
5
5
  "author": {
6
6
  "name": "Apilift"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apilift",
3
- "version": "1.4.32",
3
+ "version": "1.4.33",
4
4
  "description": "Use logged-in apps and discover missing actions with Apilift.",
5
5
  "author": {
6
6
  "name": "Apilift"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apilift/cli",
3
- "version": "1.4.32",
3
+ "version": "1.4.33",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,7 @@
36
36
  "engines": {
37
37
  "node": ">=24.16.0 <27"
38
38
  },
39
- "gitHead": "10f690490a8f07533344fcbf30be957a02c1c3c5",
39
+ "gitHead": "c4d0802b2b89d48cadc699d922e39587c1f92c98",
40
40
  "repository": {
41
41
  "type": "git",
42
42
  "url": "git+https://github.com/egor-sergeev/apilift.git",