pi-ui-extend 1.0.37 → 1.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +1 -0
  2. package/dist/app/extensions/extension-ui-controller.js +3 -0
  3. package/dist/app/rendering/extension-entry-renderer.js +2 -0
  4. package/dist/bundled-extensions/question/contract.d.ts +3 -0
  5. package/dist/bundled-extensions/question/contract.js +24 -0
  6. package/dist/bundled-extensions/question/desktop.d.ts +11 -0
  7. package/dist/bundled-extensions/question/desktop.js +143 -0
  8. package/dist/bundled-extensions/question/index.d.ts +1 -0
  9. package/dist/bundled-extensions/question/index.js +5 -1
  10. package/dist/bundled-extensions/question/render.js +6 -0
  11. package/dist/bundled-extensions/question/result.js +71 -9
  12. package/dist/bundled-extensions/question/tool-description.js +4 -3
  13. package/dist/bundled-extensions/question/tui.js +127 -12
  14. package/dist/bundled-extensions/question/types.d.ts +23 -2
  15. package/dist/tool-renderers/question.js +20 -1
  16. package/docs/desktop-markdown-media.md +77 -0
  17. package/docs/desktop-mvp.md +134 -0
  18. package/docs/desktop-task-manager.md +124 -0
  19. package/external/pi-tools-suite/package.json +3 -3
  20. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +9 -1
  21. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +41 -2
  22. package/external/pi-tools-suite/src/async-subagents/core/config.ts +13 -1
  23. package/external/pi-tools-suite/src/async-subagents/index.ts +6 -2
  24. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/SKILL.md +65 -22
  25. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-design.md +23 -8
  26. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/scripts/browser-qa-runner.mjs +180 -41
  27. package/external/pi-tools-suite/src/async-subagents/subagent-overlay.ts +1 -1
  28. package/external/pi-tools-suite/src/dcp/auto-compress.ts +1 -1
  29. package/external/pi-tools-suite/src/dcp/compression-blocks.ts +1 -51
  30. package/external/pi-tools-suite/src/dcp/debug-log.ts +6 -0
  31. package/external/pi-tools-suite/src/dcp/index.ts +27 -125
  32. package/external/pi-tools-suite/src/dcp/prompts.ts +2 -2
  33. package/external/pi-tools-suite/src/dcp/provider-tool-results.ts +2 -1
  34. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +31 -10
  35. package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +6 -7
  36. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +153 -111
  37. package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +28 -17
  38. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +61 -27
  39. package/external/pi-tools-suite/src/dcp/pruner.ts +21 -13
  40. package/external/pi-tools-suite/src/dcp/state.ts +59 -0
  41. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +4 -0
  42. package/external/pi-tools-suite/src/lib/rpc-session-state.ts +34 -0
  43. package/external/pi-tools-suite/src/todo/todo.ts +8 -3
  44. package/package.json +10 -4
@@ -17,6 +17,7 @@ const QA_WORKSPACE_RELATIVE = "browser-qa";
17
17
  const EVIDENCE_RELATIVE = "evidence";
18
18
  const UI_READY_SETTLE_MS = 500;
19
19
  const UI_READY_POLL_MS = 100;
20
+ const ACTION_REQUEST_WAIT_MAX_MS = 2_000;
20
21
  const FORM_VIDEO_STEP_DELAY_MS = UI_READY_SETTLE_MS;
21
22
  const POPUP_VIDEO_SETTLE_MS = 250;
22
23
  const CLICK_VIDEO_DELAY_MS = 120;
@@ -160,7 +161,7 @@ async function main() {
160
161
  const args = parseArgs(rawArgs);
161
162
  const selected = args.profile
162
163
  ? selectProfile(readAuthConfig(cwd, true, true).profiles, args.profile)
163
- : createPublicProfile(args.baseUrl);
164
+ : createPublicProfile(args.baseUrl, args.allowOrigin);
164
165
  const profileId = selected.id;
165
166
  const profile = selected.profile;
166
167
  const secrets = collectSecrets(profile.auth);
@@ -249,6 +250,9 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
249
250
  runtimeSecrets.push(...collectStorageStateSecrets(initialStorageState));
250
251
  await runStage(progress, "trace_start", deadline, () => context.tracing.start({ screenshots: true, snapshots: true, sources: false }), profileId);
251
252
  await runStage(progress, "flow_execute", deadline, () => executeFlow({ context, page, baseURL, evidenceDir, flow, allowedOrigins, profileId, secrets: runtimeSecrets, observations, videos }), profileId);
253
+ await runStage(progress, "storage_state_refresh", deadline, async () => {
254
+ if (await refreshRuntimeSecrets(context, runtimeSecrets)) discardRecordedVideos(videos);
255
+ }, profileId);
252
256
  await runStage(progress, "secret_exposure_check", deadline, () => assertBrowserDoesNotExposeSecrets(context, page, runtimeSecrets, profileId), profileId);
253
257
  await runStage(progress, "final_screenshot", deadline, () => page.screenshot({ path: path.join(evidenceDir, "final.png"), fullPage: true }), profileId);
254
258
  } catch (error) {
@@ -256,15 +260,25 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
256
260
  failure = error;
257
261
  if (page && !page.isClosed() && !error.suppressEvidence) {
258
262
  try {
263
+ await runCleanupStage(progress, "failure_storage_state_refresh", async () => {
264
+ if (await refreshRuntimeSecrets(context, runtimeSecrets)) discardRecordedVideos(videos);
265
+ });
266
+ await runCleanupStage(progress, "failure_secret_exposure_check", () => assertBrowserDoesNotExposeSecrets(context, page, runtimeSecrets, profileId));
259
267
  await runCleanupStage(progress, "failure_screenshot", () => page.screenshot({ path: path.join(evidenceDir, "failure.png"), fullPage: true }));
260
- } catch { /* best effort */ }
268
+ } catch (evidenceError) {
269
+ if (evidenceError instanceof QaStatusError && evidenceError.suppressEvidence) {
270
+ failure = evidenceError;
271
+ outcome = evidenceError.status;
272
+ }
273
+ }
261
274
  }
262
275
  } finally {
263
276
  const cleanupDeadline = Date.now() + CLEANUP_TIMEOUT_MS;
277
+ let discoveredLateSecrets = false;
264
278
  if (context) {
265
279
  try {
266
280
  const finalStorageState = await runCleanupStage(progress, "storage_state_finalize", () => context.storageState(), cleanupDeadline);
267
- runtimeSecrets.push(...collectStorageStateSecrets(finalStorageState));
281
+ discoveredLateSecrets = mergeRuntimeSecrets(runtimeSecrets, collectStorageStateSecrets(finalStorageState));
268
282
  } catch { /* best effort */ }
269
283
  try {
270
284
  await runCleanupStage(progress, "trace_stop", () => context.tracing.stop({ path: tracePath }), cleanupDeadline);
@@ -293,6 +307,14 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
293
307
  } catch { /* best effort */ }
294
308
  }
295
309
  await runCleanupStage(progress, "browser_close", () => browser.close(), cleanupDeadline).catch(() => {});
310
+ if (discoveredLateSecrets) {
311
+ removeVisualEvidence(evidenceDir);
312
+ if (!failure) {
313
+ outcome = "failed";
314
+ failure = new QaStatusError("QA_RUN_FAILED", "authentication changed after the final evidence safety check; visual evidence was discarded", 1, profileId);
315
+ failure.suppressEvidence = true;
316
+ }
317
+ }
296
318
  }
297
319
  try {
298
320
  for (const name of fs.readdirSync(evidenceDir).filter((name) => /^download-[A-Za-z0-9._-]+\.bin$/.test(name))) {
@@ -886,23 +908,29 @@ function authPlaceholderCount(profile) {
886
908
  )).length;
887
909
  }
888
910
 
889
- function createPublicProfile(rawBaseUrl) {
911
+ function createPublicProfile(rawBaseUrl, extraAllowedOrigins = []) {
890
912
  if (typeof rawBaseUrl !== "string") {
891
913
  throw new QaStatusError("QA_RUN_FAILED", "--base-url is required when running without --profile", 1, "public");
892
914
  }
893
915
  try {
894
916
  const url = new URL(rawBaseUrl);
895
917
  if (!/^https?:$/.test(url.protocol) || url.username || url.password) throw new Error();
918
+ const allowedOrigins = [url.origin];
919
+ for (const raw of Array.isArray(extraAllowedOrigins) ? extraAllowedOrigins : []) {
920
+ const extra = new URL(raw);
921
+ if (!/^https?:$/.test(extra.protocol) || extra.username || extra.password || extra.href !== `${extra.origin}/`) throw new Error();
922
+ allowedOrigins.push(extra.origin);
923
+ }
896
924
  return {
897
925
  id: "public",
898
926
  profile: {
899
927
  baseUrl: url.href,
900
- allowedOrigins: [url.origin],
928
+ allowedOrigins: [...new Set(allowedOrigins)],
901
929
  auth: { type: "none" },
902
930
  },
903
931
  };
904
932
  } catch {
905
- throw new QaStatusError("QA_RUN_FAILED", "public --base-url must be an http(s) URL without embedded credentials", 1, "public");
933
+ throw new QaStatusError("QA_RUN_FAILED", "public --base-url and --allow-origin values must be http(s) URLs without credentials; --allow-origin must be an exact origin", 1, "public");
906
934
  }
907
935
  }
908
936
 
@@ -1050,6 +1078,15 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
1050
1078
  let capturedPopup;
1051
1079
  let unexpectedPopupError;
1052
1080
  const readinessTrackers = new Map();
1081
+ let discardFutureVideos = false;
1082
+ const refreshSecrets = async () => {
1083
+ const changed = await refreshRuntimeSecrets(context, secrets);
1084
+ if (changed) {
1085
+ discardFutureVideos = true;
1086
+ discardRecordedVideos(videos);
1087
+ }
1088
+ return changed;
1089
+ };
1053
1090
  const readinessFor = (candidate) => {
1054
1091
  if (!readinessTrackers.has(candidate)) readinessTrackers.set(candidate, createPageReadinessTracker(candidate));
1055
1092
  return readinessTrackers.get(candidate);
@@ -1078,6 +1115,9 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
1078
1115
  }
1079
1116
  const { scope, ownerPage } = await resolveStepTarget({ page, popups, step, allowedOrigins, profileId, index });
1080
1117
  const locator = step.locator === undefined ? undefined : resolveLocator(scope, step.locator, profileId, index);
1118
+ const readinessTracker = readinessFor(ownerPage);
1119
+ const requestScope = UI_READY_ACTIONS.has(step.action) ? readinessTracker.beginActionScope() : undefined;
1120
+ try {
1081
1121
  switch (step.action) {
1082
1122
  case "goto": {
1083
1123
  const target = typeof step.url === "string" ? step.url : step.path;
@@ -1149,7 +1189,7 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
1149
1189
  [, popup] = await Promise.all([requireLocator(locator, profileId, index).click({ delay: CLICK_VIDEO_DELAY_MS }), popupPromise]);
1150
1190
  popup.setDefaultTimeout(timeout);
1151
1191
  popup.setDefaultNavigationTimeout(timeout);
1152
- popupVideo = { name: `video-popup-${step.name}.webm`, video: popup.video(), discard: false };
1192
+ popupVideo = { name: `video-popup-${step.name}.webm`, video: popup.video(), discard: discardFutureVideos };
1153
1193
  videos.push(popupVideo);
1154
1194
  await popup.waitForLoadState("domcontentloaded", { timeout });
1155
1195
  await waitForUiReady({
@@ -1234,6 +1274,15 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
1234
1274
  const target = requireLocator(locator, profileId, index);
1235
1275
  await retryAssertion({
1236
1276
  page: ownerPage, timeout, profileId, index, reason: "text assertion failed",
1277
+ check: async () => (await target.isVisible()) && matches(await target.innerText()),
1278
+ });
1279
+ break;
1280
+ }
1281
+ case "assertTextContent": {
1282
+ const matches = expectedStringMatcher(step, profileId, index, "text content");
1283
+ const target = requireLocator(locator, profileId, index);
1284
+ await retryAssertion({
1285
+ page: ownerPage, timeout, profileId, index, reason: "text content assertion failed",
1237
1286
  check: async () => matches((await target.textContent()) ?? ""),
1238
1287
  });
1239
1288
  break;
@@ -1290,6 +1339,7 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
1290
1339
  break;
1291
1340
  }
1292
1341
  case "authRejectedIf": {
1342
+ if (typeof step.urlIncludes !== "string" && !locator) throw flowError(profileId, index, "authRejectedIf requires urlIncludes or locator");
1293
1343
  const urlRejected = typeof step.urlIncludes === "string" && scope.url().includes(step.urlIncludes);
1294
1344
  const locatorRejected = locator ? await locator.isVisible().catch(() => false) : false;
1295
1345
  if (!urlRejected && !locatorRejected) break;
@@ -1298,21 +1348,34 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
1298
1348
  case "screenshot": {
1299
1349
  const name = typeof step.name === "string" ? step.name : `step-${index + 1}`;
1300
1350
  if (!isSafeName(name)) throw flowError(profileId, index, "screenshot name is invalid");
1351
+ await refreshSecrets();
1301
1352
  await assertBrowserDoesNotExposeSecrets(context, page, secrets, profileId);
1302
1353
  await ownerPage.screenshot({ path: path.join(evidenceDir, `${name}.png`), fullPage: step.fullPage !== false });
1303
1354
  break;
1304
1355
  }
1305
1356
  default: throw flowError(profileId, index, `unsupported action: ${step.action}`);
1306
1357
  }
1358
+ } catch (error) {
1359
+ requestScope?.stopCapture();
1360
+ requestScope?.dispose();
1361
+ throw error;
1362
+ }
1307
1363
  if (UI_READY_ACTIONS.has(step.action)) {
1308
- await waitForUiReady({
1309
- scope,
1310
- tracker: readinessFor(ownerPage),
1311
- timeout,
1312
- failure: () => flowError(profileId, index, "page did not finish loading"),
1313
- });
1364
+ try {
1365
+ await waitForUiReady({
1366
+ scope,
1367
+ tracker: readinessTracker,
1368
+ requestScope,
1369
+ timeout,
1370
+ failure: () => flowError(profileId, index, "page did not finish loading"),
1371
+ });
1372
+ } finally {
1373
+ requestScope?.stopCapture();
1374
+ requestScope?.dispose();
1375
+ }
1314
1376
  }
1315
1377
  if (unexpectedPopupError) throw unexpectedPopupError;
1378
+ await refreshSecrets();
1316
1379
  await assertBrowserDoesNotExposeSecrets(context, page, secrets, profileId);
1317
1380
  }
1318
1381
  } finally {
@@ -1323,15 +1386,36 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
1323
1386
 
1324
1387
  function createPageReadinessTracker(page) {
1325
1388
  const pendingRequests = new Set();
1326
- const onRequest = (request) => pendingRequests.add(request);
1327
- const onRequestDone = (request) => pendingRequests.delete(request);
1389
+ const actionScopes = new Set();
1390
+ const onRequest = (request) => {
1391
+ if (isLongLivedRequest(request)) return;
1392
+ pendingRequests.add(request);
1393
+ for (const scope of actionScopes) {
1394
+ if (scope.capturing) scope.pendingRequests.add(request);
1395
+ }
1396
+ };
1397
+ const onRequestDone = (request) => {
1398
+ pendingRequests.delete(request);
1399
+ for (const scope of actionScopes) scope.pendingRequests.delete(request);
1400
+ };
1328
1401
  page.on("request", onRequest);
1329
1402
  page.on("requestfinished", onRequestDone);
1330
1403
  page.on("requestfailed", onRequestDone);
1331
1404
  return {
1332
1405
  page,
1333
1406
  pendingRequests,
1407
+ beginActionScope() {
1408
+ const scope = {
1409
+ pendingRequests: new Set(),
1410
+ capturing: true,
1411
+ stopCapture() { scope.capturing = false; },
1412
+ dispose() { actionScopes.delete(scope); },
1413
+ };
1414
+ actionScopes.add(scope);
1415
+ return scope;
1416
+ },
1334
1417
  dispose() {
1418
+ actionScopes.clear();
1335
1419
  page.off("request", onRequest);
1336
1420
  page.off("requestfinished", onRequestDone);
1337
1421
  page.off("requestfailed", onRequestDone);
@@ -1339,8 +1423,18 @@ function createPageReadinessTracker(page) {
1339
1423
  };
1340
1424
  }
1341
1425
 
1342
- async function waitForUiReady({ scope, tracker, timeout, failure }) {
1426
+ function isLongLivedRequest(request) {
1427
+ try {
1428
+ return ["eventsource", "websocket"].includes(request.resourceType?.());
1429
+ } catch {
1430
+ return false;
1431
+ }
1432
+ }
1433
+
1434
+ async function waitForUiReady({ scope, tracker, requestScope = undefined, timeout, failure }) {
1343
1435
  const deadline = Date.now() + timeout;
1436
+ const pendingRequests = requestScope?.pendingRequests ?? tracker.pendingRequests;
1437
+ const requestDeadline = Date.now() + Math.min(ACTION_REQUEST_WAIT_MAX_MS, Math.max(0, timeout - UI_READY_SETTLE_MS));
1344
1438
  try {
1345
1439
  await scope.waitForLoadState("domcontentloaded", { timeout });
1346
1440
  } catch {
@@ -1350,10 +1444,12 @@ async function waitForUiReady({ scope, tracker, timeout, failure }) {
1350
1444
  const remaining = deadline - Date.now();
1351
1445
  if (remaining <= 0) throw failure();
1352
1446
  const hasBusyIndicator = await hasVisibleBusyIndicator(scope);
1353
- if (tracker.pendingRequests.size === 0 && !hasBusyIndicator) {
1447
+ const requestsStillBlock = pendingRequests.size > 0 && Date.now() < requestDeadline;
1448
+ if (!requestsStillBlock && !hasBusyIndicator) {
1354
1449
  await tracker.page.waitForTimeout(Math.min(UI_READY_SETTLE_MS, remaining));
1355
1450
  const stillBusy = await hasVisibleBusyIndicator(scope);
1356
- if (tracker.pendingRequests.size === 0 && !stillBusy) return;
1451
+ const requestsStillBlockAfterSettle = pendingRequests.size > 0 && Date.now() < requestDeadline;
1452
+ if (!requestsStillBlockAfterSettle && !stillBusy) return;
1357
1453
  continue;
1358
1454
  }
1359
1455
  await tracker.page.waitForTimeout(Math.min(UI_READY_POLL_MS, remaining));
@@ -1813,13 +1909,20 @@ async function applyFormAuth(page, auth, allowedOrigins, profileId) {
1813
1909
  const timeout = Math.min(finitePositive(auth.timeoutMs) ?? 15_000, 60_000);
1814
1910
  page.setDefaultTimeout(timeout);
1815
1911
  page.setDefaultNavigationTimeout(timeout);
1816
- await page.goto(auth.loginUrl, { timeout });
1817
- await waitForUiReady({
1818
- scope: page,
1819
- tracker: readinessTracker,
1820
- timeout,
1821
- failure: () => authError(profileId, "form page did not finish loading"),
1822
- });
1912
+ const loginRequestScope = readinessTracker.beginActionScope();
1913
+ try {
1914
+ await page.goto(auth.loginUrl, { timeout });
1915
+ await waitForUiReady({
1916
+ scope: page,
1917
+ tracker: readinessTracker,
1918
+ requestScope: loginRequestScope,
1919
+ timeout,
1920
+ failure: () => authError(profileId, "form page did not finish loading"),
1921
+ });
1922
+ } finally {
1923
+ loginRequestScope.stopCapture();
1924
+ loginRequestScope.dispose();
1925
+ }
1823
1926
  for (const field of auth.fields) {
1824
1927
  if (!isObject(field) || typeof field.selector !== "string" || typeof field.value !== "string") {
1825
1928
  throw authError(profileId, "form fields must contain selector/value strings");
@@ -1827,20 +1930,27 @@ async function applyFormAuth(page, auth, allowedOrigins, profileId) {
1827
1930
  await page.locator(field.selector).fill(field.value);
1828
1931
  await page.waitForTimeout(FORM_VIDEO_STEP_DELAY_MS);
1829
1932
  }
1830
- await page.locator(auth.submitSelector).click({ delay: CLICK_VIDEO_DELAY_MS });
1831
- if (isObject(auth.success) && typeof auth.success.url === "string") await page.waitForURL(auth.success.url, { timeout });
1832
- if (isObject(auth.success) && typeof auth.success.selector === "string") {
1833
- await page.locator(auth.success.selector).waitFor({ timeout, state: validLocatorState(auth.success.state) });
1834
- }
1835
- if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) {
1836
- throw authError(profileId, "form success.url or success.selector is required");
1933
+ const submitRequestScope = readinessTracker.beginActionScope();
1934
+ try {
1935
+ await page.locator(auth.submitSelector).click({ delay: CLICK_VIDEO_DELAY_MS });
1936
+ if (isObject(auth.success) && typeof auth.success.url === "string") await page.waitForURL(auth.success.url, { timeout });
1937
+ if (isObject(auth.success) && typeof auth.success.selector === "string") {
1938
+ await page.locator(auth.success.selector).waitFor({ timeout, state: validLocatorState(auth.success.state) });
1939
+ }
1940
+ if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) {
1941
+ throw authError(profileId, "form success.url or success.selector is required");
1942
+ }
1943
+ await waitForUiReady({
1944
+ scope: page,
1945
+ tracker: readinessTracker,
1946
+ requestScope: submitRequestScope,
1947
+ timeout,
1948
+ failure: () => authError(profileId, "authenticated page did not finish loading"),
1949
+ });
1950
+ } finally {
1951
+ submitRequestScope.stopCapture();
1952
+ submitRequestScope.dispose();
1837
1953
  }
1838
- await waitForUiReady({
1839
- scope: page,
1840
- tracker: readinessTracker,
1841
- timeout,
1842
- failure: () => authError(profileId, "authenticated page did not finish loading"),
1843
- });
1844
1954
  } catch (error) {
1845
1955
  if (error instanceof QaStatusError) throw error;
1846
1956
  throw authError(profileId, "form login was rejected or did not reach the configured success condition");
@@ -1931,7 +2041,10 @@ function cookieAllowed(cookie, allowedOrigins) {
1931
2041
  if (typeof cookie.url === "string") return isAllowedUrl(cookie.url, allowedOrigins);
1932
2042
  if (typeof cookie.domain !== "string") return false;
1933
2043
  const domain = cookie.domain.replace(/^\./, "").toLowerCase();
1934
- return allowedOrigins.some((origin) => new URL(origin).hostname.toLowerCase() === domain);
2044
+ return allowedOrigins.some((origin) => {
2045
+ const host = new URL(origin).hostname.toLowerCase();
2046
+ return host === domain || (domain.includes(".") && host.endsWith(`.${domain}`));
2047
+ });
1935
2048
  }
1936
2049
 
1937
2050
  function loadPlaywright(cwd) {
@@ -1990,13 +2103,18 @@ function findPackageJson(startFile) {
1990
2103
  }
1991
2104
 
1992
2105
  function parseArgs(values) {
1993
- const result = {};
2106
+ const result = { allowOrigin: [] };
1994
2107
  for (let index = 0; index < values.length; index += 2) {
1995
2108
  const flag = values[index];
1996
2109
  const value = values[index + 1];
1997
2110
  if (!flag?.startsWith("--") || value === undefined) throw new QaStatusError("QA_RUN_FAILED", `invalid argument: ${flag ?? "(missing)"}`, 1);
1998
2111
  const key = flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
1999
- if (!["profile", "flow", "baseUrl", "runId", "runnerTimeoutMs"].includes(key)) throw new QaStatusError("QA_RUN_FAILED", `unknown option: ${flag}`, 1);
2112
+ if (!["profile", "flow", "baseUrl", "allowOrigin", "runId", "runnerTimeoutMs"].includes(key)) throw new QaStatusError("QA_RUN_FAILED", `unknown option: ${flag}`, 1);
2113
+ if (key === "allowOrigin") {
2114
+ result.allowOrigin.push(value);
2115
+ continue;
2116
+ }
2117
+ if (result[key] !== undefined) throw new QaStatusError("QA_RUN_FAILED", `duplicate option: ${flag}`, 1);
2000
2118
  result[key] = value;
2001
2119
  }
2002
2120
  return result;
@@ -2251,6 +2369,23 @@ function collectStorageStateSecrets(state) {
2251
2369
  return expandSecretForms(output);
2252
2370
  }
2253
2371
 
2372
+ async function refreshRuntimeSecrets(context, runtimeSecrets) {
2373
+ if (!context || typeof context.storageState !== "function") return false;
2374
+ return mergeRuntimeSecrets(runtimeSecrets, collectStorageStateSecrets(await context.storageState()));
2375
+ }
2376
+
2377
+ function mergeRuntimeSecrets(runtimeSecrets, candidates) {
2378
+ const known = new Set(runtimeSecrets);
2379
+ let added = false;
2380
+ for (const candidate of candidates) {
2381
+ if (!candidate || known.has(candidate)) continue;
2382
+ known.add(candidate);
2383
+ runtimeSecrets.push(candidate);
2384
+ added = true;
2385
+ }
2386
+ return added;
2387
+ }
2388
+
2254
2389
  function expandSecretForms(values) {
2255
2390
  const forms = new Set();
2256
2391
  for (const value of values) {
@@ -2570,6 +2705,10 @@ function removeVisualEvidence(evidenceDir) {
2570
2705
  }
2571
2706
  }
2572
2707
 
2708
+ function discardRecordedVideos(videos) {
2709
+ for (const entry of videos) entry.discard = true;
2710
+ }
2711
+
2573
2712
  function writeJsonPrivate(file, value) {
2574
2713
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
2575
2714
  fs.chmodSync(file, 0o600);
@@ -23,7 +23,7 @@ export class SubagentOverlay {
23
23
  if (liveRun.has(agent.id)) continue;
24
24
  const agentDir = path.join(runDir, agent.id);
25
25
  const agentParentSession = readParentSessionLink(agentDir);
26
- if (parentSession && agentParentSession && !pathsEqual(parentSession, agentParentSession)) continue;
26
+ if (parentSession && (!agentParentSession || !pathsEqual(parentSession, agentParentSession))) continue;
27
27
  liveRun.set(agent.id, { runDir, agentId: agent.id, parentSession: agentParentSession, completed: Promise.resolve() });
28
28
  }
29
29
  if (liveRun.size === 0) this.liveAgents.delete(runDir);
@@ -116,7 +116,7 @@ export function buildProgrammaticSummary(
116
116
  return lines.join("\n")
117
117
  }
118
118
 
119
- const SUMMARIZER_SYSTEM_PROMPT = `You summarize a slice of a coding agent's conversation so it can replace the raw messages in context. Produce a dense, continuation-focused summary: preserve user intent, decisions made, files/symbols changed or inspected, exact errors still actionable, verification status, and next steps. Do not infer, invent, or add facts absent from the source; preserve uncertainty instead of filling gaps. Drop full logs, repeated output, and incidental detail. Be concise (roughly 4-10 bullets). Output ONLY the summary text, no preamble.`
119
+ const SUMMARIZER_SYSTEM_PROMPT = `You summarize a slice of a coding agent's conversation so it can replace the raw messages in context. Produce a dense, continuation-focused summary: preserve user intent, decisions made, files/symbols changed or inspected, exact errors still actionable, verification status, and next steps. Preserve exact identifiers and explicit continuity markers verbatim, including uppercase labels before colons; never paraphrase or omit those labels. Do not infer, invent, or add facts absent from the source; preserve uncertainty instead of filling gaps. Drop full logs, repeated output, and incidental detail without quoting or naming the discarded log lines or their markers. Be concise (roughly 4-10 bullets). Output ONLY the summary text, no preamble.`
120
120
 
121
121
  /** Outcome of one summarizer-model attempt, surfaced in DCP debug logs. */
122
122
  export interface ModelSummaryAttempt {
@@ -388,55 +388,6 @@ export function resolveIdToBoundary(
388
388
  const ts = state.messageIdSnapshot.get(id)
389
389
  if (ts !== undefined) return { timestamp: ts }
390
390
 
391
- // ── Stale mNNN fallback: direction-only clamp ────────────────────────
392
- // When compression or pruning removes messages between context passes,
393
- // positional mNNN IDs shift (e.g. an end boundary m145 becomes m123
394
- // after 22 messages are removed). Clamp to the closest valid ID, but
395
- // ONLY in a direction that preserves the range's semantics:
396
- // - start boundary: clamp upward to the first available ID at or after
397
- // the requested number. The start must never move backwards into
398
- // older content; if no such ID exists, the requested start is gone
399
- // and we cannot safely compress — throw.
400
- // - end boundary: clamp downward to the last available ID at or before
401
- // the requested number. The end must never move forwards into newer
402
- // content; if no such ID exists, throw.
403
- // The previous implementation fell back to the highest available ID in
404
- // both "no match" cases, which could clamp e.g. m010..m010 over a
405
- // snapshot of m001..m003 to a single-message block over m003 — silently
406
- // compressing the wrong content.
407
- const mMatch = id.match(/^m(\d+)$/i)
408
- if (mMatch && state.messageIdSnapshot.size > 0) {
409
- const requestedNum = parseInt(mMatch[1]!, 10)
410
- const allIds = sortIds([...state.messageIdSnapshot.keys()])
411
- const allNums = allIds
412
- .map((mid) => {
413
- const n = mid.match(/^m(\d+)$/i)
414
- return n ? { id: mid, num: parseInt(n[1]!, 10) } : null
415
- })
416
- .filter((entry): entry is { id: string; num: number } => entry !== null)
417
- .sort((a, b) => a.num - b.num)
418
-
419
- if (allNums.length > 0) {
420
- let clamped: { id: string; num: number } | undefined
421
- if (field === "startTimestamp") {
422
- clamped = allNums.find((entry) => entry.num >= requestedNum)
423
- } else {
424
- for (let i = allNums.length - 1; i >= 0; i--) {
425
- if (allNums[i]!.num <= requestedNum) {
426
- clamped = allNums[i]
427
- break
428
- }
429
- }
430
- }
431
- if (clamped) {
432
- const clampedMeta = state.messageMetaSnapshot.get(clamped.id)
433
- if (clampedMeta) return resolveMetaBoundary(clampedMeta, field, state)
434
- const clampedTs = state.messageIdSnapshot.get(clamped.id)
435
- if (clampedTs !== undefined) return { timestamp: clampedTs }
436
- }
437
- }
438
- }
439
-
440
391
  throw unknownCompressionIdError(id, state)
441
392
  }
442
393
 
@@ -445,8 +396,7 @@ export function resolveIdToBoundary(
445
396
  * synthetic placeholder for an active compression block (`meta.blockId` is
446
397
  * set), resolve to that block's stored boundary so the caller rolls the
447
398
  * block up instead of nesting a new block on top of the placeholder. This
448
- * matters for both exact-match and clamped mNNN IDs: a model-visible mNNN
449
- * may itself represent a previously compressed section.
399
+ * A model-visible mNNN may itself represent a previously compressed section.
450
400
  */
451
401
  function resolveMetaBoundary(
452
402
  meta: MessageIdMeta,
@@ -127,7 +127,13 @@ export function summarizeDcpState(state: DcpState): Record<string, unknown> {
127
127
  nextBlockId: state.nextBlockId,
128
128
  },
129
129
  inactiveBlocksTail: inactiveBlocks,
130
+ persistentMessageIds: state.messageIdsByStableId.size,
131
+ nextMessageId: state.nextMessageId,
130
132
  prunedTools: state.prunedToolIds.size,
133
+ automaticPruneCheckpoint: {
134
+ turn: state.lastAutomaticPruneTurn,
135
+ blockId: state.lastAutomaticPruneBlockId,
136
+ },
131
137
  providerSeenTools: state.providerSeenToolIds.size,
132
138
  consecutiveEmergencyPasses: state.consecutiveIgnoredStrongNudges,
133
139
  nudgeAnchors: state.nudgeAnchors.map((anchor) => ({