local-operator-ui 0.29.12 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/out/main/index.js +1959 -92
  2. package/out/preload/index.js +4 -0
  3. package/out/renderer/assets/{_basePickBy-1H7gCAbJ.js → _basePickBy-Bxj6z8Wi.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-9lNuDyr9.js → _baseUniq-CRJuWtvs.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-DljI0xn2.js → agent-details-page-DRjUMEUI.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-BrDV_UN_.js → agent-hub-page-BMCNLFYk.js} +1 -1
  7. package/out/renderer/assets/{agents-page-DwxRQf7x.js → agents-page-D4pQP4dR.js} +2 -2
  8. package/out/renderer/assets/{arc-Bo4fsGBf.js → arc-W5w8I8BO.js} +1 -1
  9. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-CFazObUZ.js → architectureDiagram-IEHRJDOE-uGd1cdqb.js} +1 -1
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-Bg72Epup.js → blockDiagram-JOT3LUYC-D1Eg2vE0.js} +1 -1
  11. package/out/renderer/assets/browser-page-CZ3Z-PgY.js +1 -0
  12. package/out/renderer/assets/{browser-webauthn-prompt-Bp6ZNrfE.js → browser-webauthn-prompt-C1cS8dj0.js} +1 -1
  13. package/out/renderer/assets/{c4Diagram-VJAJSXHY-BGeUxHWC.js → c4Diagram-VJAJSXHY-Me-bZTC4.js} +1 -1
  14. package/out/renderer/assets/channel-CYsqm6Ws.js +1 -0
  15. package/out/renderer/assets/{chunk-4BMEZGHF-DzG5AABJ.js → chunk-4BMEZGHF-CEIGhpCi.js} +1 -1
  16. package/out/renderer/assets/{chunk-A2AXSNBT-BDE1k_Sd.js → chunk-A2AXSNBT-GF1F80gh.js} +1 -1
  17. package/out/renderer/assets/{chunk-AEK57VVT-Bnqy1REO.js → chunk-AEK57VVT-DtLnbbUV.js} +1 -1
  18. package/out/renderer/assets/{chunk-D6G4REZN-OJwou3dn.js → chunk-D6G4REZN-B1DRTHm0.js} +1 -1
  19. package/out/renderer/assets/{chunk-RZ5BOZE2-BBpDVXLp.js → chunk-RZ5BOZE2-D6h9Tibd.js} +1 -1
  20. package/out/renderer/assets/{chunk-XZIHB7SX-HO33Rcol.js → chunk-XZIHB7SX-DO5kHk_V.js} +1 -1
  21. package/out/renderer/assets/classDiagram-GIVACNV2-ChJdbdZp.js +1 -0
  22. package/out/renderer/assets/classDiagram-v2-COTLJTTW-ChJdbdZp.js +1 -0
  23. package/out/renderer/assets/clone-C6PHt_85.js +1 -0
  24. package/out/renderer/assets/{compact-pagination-YLr5UxEq.js → compact-pagination-DvIFPVVF.js} +1 -1
  25. package/out/renderer/assets/{dagre-OKDRZEBW-DaKzvkro.js → dagre-OKDRZEBW-u5fIdPgH.js} +1 -1
  26. package/out/renderer/assets/{diagram-SSKATNLV-BJ85p8MH.js → diagram-SSKATNLV-BX2uYodZ.js} +1 -1
  27. package/out/renderer/assets/{diagram-VNBRO52H-BDylL4pB.js → diagram-VNBRO52H-CjnNNdUv.js} +1 -1
  28. package/out/renderer/assets/{erDiagram-Q7BY3M3F-Bmuo6n4L.js → erDiagram-Q7BY3M3F-qtGVvLUM.js} +1 -1
  29. package/out/renderer/assets/{flowDiagram-4HSFHLVR-CxswEMEz.js → flowDiagram-4HSFHLVR-DQvp9JGV.js} +1 -1
  30. package/out/renderer/assets/{ganttDiagram-APWFNJXF-CXUPGd8I.js → ganttDiagram-APWFNJXF-Ci1ah8CY.js} +1 -1
  31. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-By5CUo46.js → gitGraphDiagram-7IBYFJ6S-wJbkC8Is.js} +1 -1
  32. package/out/renderer/assets/{graph-CMfReLAi.js → graph-DJopaDTn.js} +1 -1
  33. package/out/renderer/assets/icon-Bld1dwLh.css +1 -0
  34. package/out/renderer/assets/{index-CbVSom_R.js → index-CnT3YOgO.js} +378 -373
  35. package/out/renderer/assets/{index-COoXd8RY.js → index-CxqFXiU9.js} +1 -1
  36. package/out/renderer/assets/{index-CpOPM-iW.js → index-DnCfIkzT.js} +1 -1
  37. package/out/renderer/assets/{infoDiagram-PH2N3AL5-BWOjcWnS.js → infoDiagram-PH2N3AL5-B3U7DI90.js} +1 -1
  38. package/out/renderer/assets/{installer-BfhAHY4r.js → installer-B8Nn8EOm.js} +1 -1
  39. package/out/renderer/assets/{journeyDiagram-U35MCT3I-B-x8rfW8.js → journeyDiagram-U35MCT3I-DScIXrsc.js} +1 -1
  40. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-v7_cq85Q.js → kanban-definition-NDS4AKOZ-44QsoV35.js} +1 -1
  41. package/out/renderer/assets/{layout-DD4W9deB.js → layout-C2XO3rbH.js} +1 -1
  42. package/out/renderer/assets/{legacy-agents-page-Ciisz9T5.js → legacy-agents-page-DzvJsW6a.js} +9 -14
  43. package/out/renderer/assets/{mermaid.core-DMA_yzhO.js → mermaid.core-CedM0RYf.js} +19 -19
  44. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BO-1wp5Z.js → mindmap-definition-ALO5MXBD-Cjvw-N5z.js} +1 -1
  45. package/out/renderer/assets/{page-header-kampE4Cn.js → page-header-CpVC9jUn.js} +1 -1
  46. package/out/renderer/assets/{parseISO-fIBQPPHT.js → parseISO-D0NosKnD.js} +1 -1
  47. package/out/renderer/assets/{pieDiagram-IB7DONF6-SvGNVrTF.js → pieDiagram-IB7DONF6-BqCKCsJW.js} +1 -1
  48. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-BQ9fNSZT.js → quadrantDiagram-7GDLP6J5-Pojkyhrm.js} +1 -1
  49. package/out/renderer/assets/{radar-MK3ICKWK-BdQH-zQG.js → radar-MK3ICKWK-Bx4TYIXP.js} +1 -1
  50. package/out/renderer/assets/{radient-auth-buttons-BMIRfsdU.js → radient-auth-buttons-D_SoNQJd.js} +1 -1
  51. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-B5b6b0Cc.js → requirementDiagram-KVF5MWMF-DUkbReMG.js} +1 -1
  52. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-DBrSxuPt.js → sankeyDiagram-QLVOVGJD-BUGhR_2u.js} +1 -1
  53. package/out/renderer/assets/{schedules-page-BiLy5_Sv.js → schedules-page-9pqWDmrK.js} +1 -1
  54. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-CQ7cg-pc.js → sequenceDiagram-X6HHIX6F-B0ZGPAMn.js} +1 -1
  55. package/out/renderer/assets/{settings-page-Bt7hCm86.js → settings-page-CnL9a7Fr.js} +1 -1
  56. package/out/renderer/assets/{stateDiagram-DGXRK772-C9ED7Xhx.js → stateDiagram-DGXRK772-Cg6i1sjE.js} +1 -1
  57. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-SfqhHCpy.js +1 -0
  58. package/out/renderer/assets/{timeline-definition-BDJGKUSR-DKvZ80yG.js → timeline-definition-BDJGKUSR-8RQ5e7vN.js} +1 -1
  59. package/out/renderer/assets/{use-agent-like-mutation-C_Y-w2IJ.js → use-agent-like-mutation-anGek86t.js} +1 -1
  60. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-peQEFG2j.js → xychartDiagram-VJFVF3MP-DgYi9eSD.js} +1 -1
  61. package/out/renderer/index.html +3 -3
  62. package/out/renderer/installer.html +3 -3
  63. package/package.json +3 -2
  64. package/out/renderer/assets/browser-page-NToP8sAi.js +0 -1
  65. package/out/renderer/assets/channel-M9BWKnVc.js +0 -1
  66. package/out/renderer/assets/classDiagram-GIVACNV2-C2Z-Z1K9.js +0 -1
  67. package/out/renderer/assets/classDiagram-v2-COTLJTTW-C2Z-Z1K9.js +0 -1
  68. package/out/renderer/assets/clone-StYPNe3g.js +0 -1
  69. package/out/renderer/assets/icon-DGoQRttI.css +0 -1
  70. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DNMp6of4.js +0 -1
  71. /package/out/renderer/assets/{icon-2Q4AuVUW.js → icon-BrElBHST.js} +0 -0
package/out/main/index.js CHANGED
@@ -4240,23 +4240,31 @@ function resolveGlobalInstallPlan(input) {
4240
4240
  * Managed - what the app is about to do TO THEM. This arm used to tell the
4241
4241
  * user to go to a terminal on the one path where the app runs the command
4242
4242
  * itself, which no surface rendered (reviews D7, U7), and the offer named
4243
- * no cost at all before the click (review U3). The restart is the one
4244
- * destructive thing this path does, so it is stated here rather than in
4245
- * the in-flight panel the user reaches only after pressing.
4243
+ * no cost at all before the click (review U3). It named the restart as its
4244
+ * cost for the same reason - it was the one destructive thing this path
4245
+ * did. It no longer is: on the generation layout the install lands beside
4246
+ * the running build and the server keeps serving the build it loaded until
4247
+ * its own next idle, so the cost is the install and the wait, and no
4248
+ * sentence here may describe a turn being dropped.
4246
4249
  *
4247
4250
  * Source build - the same disclosure with the one difference that matters:
4248
- * what is rebuilt is THEIR checkout, and the version they end up on is the
4249
- * checkout's rather than the release the app offered.
4251
+ * this route DOES rewrite a tree a live runtime is reading, so its sentence
4252
+ * keeps saying so. What it adds is the half it was silent about: the app
4253
+ * now waits for the fleet to drain before it starts
4254
+ * (`drainFleetForUpdate`), which is the operator's own rule - nothing may
4255
+ * kill runtimes en masse - applied to the one route that can.
4250
4256
  *
4251
4257
  * Legacy - WHY the app will not press the button, which is what the user
4252
4258
  * is choosing between (reviews U8, N1). It lived only in the mono Details
4253
- * line, trailing a resolved path and a classification.
4259
+ * line, trailing a resolved path and a classification. Its warning is
4260
+ * about the OLD INSTALLER the reader would run themselves, not about
4261
+ * anything this app does, which is why it still says a turn can be lost.
4254
4262
  *
4255
4263
  * NEITHER ENDS IN A COLON: the well below is visually distinct in both
4256
4264
  * panels, and in the by-hand panel the next line was the version sentence,
4257
4265
  * so the promise landed on the wrong line (review D5).
4258
4266
  */
4259
- remedy: managed ? "The app updates this install and then restarts the server it started, so a turn that is in flight is dropped while the server comes back. This can take a minute or two." : sourceRebuildRoute ? "Rebuilds this checkout with `lop-update`. The rebuild happens in place, so sessions on this machine can be interrupted while it runs, and it can take up to half an hour. This install keeps reporting the checkout's version, not the release the app offered." : "This install predates the non-disruptive installer, so update it once from your terminal. This install's updater rewrites the shared environment in place, which can interrupt sessions mid-turn; the app manages updates after that.",
4267
+ remedy: managed ? "The app installs the new build beside the one in use and leaves the server you are using on the build it loaded, so nothing in flight is cut off. The server moves onto the new build when it is next idle." : sourceRebuildRoute ? "Rebuilds this checkout with `lop-update`. The app waits for the turns running on this machine to finish first, and the rebuild then reinstalls this install in place - so a turn started while it runs can still be interrupted - and it can take up to half an hour. This install keeps reporting the checkout's version, not the release the app offered." : "This install predates the non-disruptive installer, so update it once from your terminal; that updater rewrites the shared environment in place, which can interrupt sessions mid-turn. The app manages updates after that.",
4260
4268
  detail: `${detail}${managed ? provenance : sourceRebuildRoute ? " source build of this machine's checkout; an in-place rebuild." : provenance}`,
4261
4269
  sourceBuild,
4262
4270
  managedRoute: managed ? "entry-point" : sourceRebuildRoute ? "source-build" : null,
@@ -4402,14 +4410,35 @@ function driftInstallReading(input) {
4402
4410
  return { version: input.planVersion, source: "unknown" };
4403
4411
  return { version: input.planVersion, source: "serving-install" };
4404
4412
  }
4405
- function servingWorkStateFromSessions(body) {
4413
+ const DEGRADED_LIVENESS_SOURCE = "liveness";
4414
+ function rosterLivenessDegraded(body) {
4415
+ const degraded = body?.result?.degraded;
4416
+ return Array.isArray(degraded) && degraded.includes(DEGRADED_LIVENESS_SOURCE);
4417
+ }
4418
+ const KNOWN_LIVE_STATES = /* @__PURE__ */ new Set(["", "wedged", "busy", "attached", "idle"]);
4419
+ function fleetRosterFromSessions(body) {
4406
4420
  const sessions = body?.result?.sessions;
4407
- if (!Array.isArray(sessions)) return "unknown";
4408
- const rows = sessions.filter(
4421
+ if (!Array.isArray(sessions)) return null;
4422
+ if (rosterLivenessDegraded(body)) return null;
4423
+ return sessions.filter(
4409
4424
  (row) => typeof row === "object" && row !== null
4410
- );
4411
- if (rows.some((row) => row.live_state === "busy")) return "busy";
4412
- if (rows.length > 0 && rows.every((row) => !("live_state" in row)))
4425
+ ).map((row) => ({
4426
+ sessionId: typeof row.id === "string" ? row.id : "",
4427
+ name: typeof row.name === "string" ? row.name : "",
4428
+ kind: typeof row.kind === "string" ? row.kind : "",
4429
+ liveState: typeof row.live_state === "string" ? row.live_state : null
4430
+ }));
4431
+ }
4432
+ function busyRosterRows(rows) {
4433
+ return rows.filter((row) => row.liveState === "busy");
4434
+ }
4435
+ function servingWorkStateFromSessions(body) {
4436
+ const rows = fleetRosterFromSessions(body);
4437
+ if (rows === null) return "unknown";
4438
+ if (busyRosterRows(rows).length > 0) return "busy";
4439
+ if (rows.some(
4440
+ (row) => row.liveState === null || !KNOWN_LIVE_STATES.has(row.liveState)
4441
+ ))
4413
4442
  return "unknown";
4414
4443
  return "idle";
4415
4444
  }
@@ -6199,7 +6228,7 @@ async function prepareRuntime(options) {
6199
6228
  await promises.rename(staging, runtime);
6200
6229
  return { runtime, id: id2 };
6201
6230
  }
6202
- function describe$1(error) {
6231
+ function describe$2(error) {
6203
6232
  return error instanceof Error ? error.message : String(error);
6204
6233
  }
6205
6234
  function selectionIsWellFormed(root, selection) {
@@ -6251,7 +6280,7 @@ function inspectManagedSelection(options) {
6251
6280
  realDirectory(path2);
6252
6281
  } catch (error) {
6253
6282
  return missing(
6254
- `The selected ${what} is not there any more (${path2}): ${describe$1(error)}`
6283
+ `The selected ${what} is not there any more (${path2}): ${describe$2(error)}`
6255
6284
  );
6256
6285
  }
6257
6286
  }
@@ -6265,7 +6294,7 @@ function inspectManagedSelection(options) {
6265
6294
  );
6266
6295
  } catch (error) {
6267
6296
  return missing(
6268
- `The selected environment is not complete (${selection.venv}): ${describe$1(error)}`
6297
+ `The selected environment is not complete (${selection.venv}): ${describe$2(error)}`
6269
6298
  );
6270
6299
  }
6271
6300
  if (!fs.existsSync(path$1.join(selection.venv, "bin", "local-operator")))
@@ -6279,7 +6308,7 @@ function inspectManagedSelection(options) {
6279
6308
  )?.[1]?.trim();
6280
6309
  } catch (error) {
6281
6310
  return missing(
6282
- `The selected environment has no readable pyvenv.cfg (${selection.venv}): ${describe$1(error)}`
6311
+ `The selected environment has no readable pyvenv.cfg (${selection.venv}): ${describe$2(error)}`
6283
6312
  );
6284
6313
  }
6285
6314
  if (home !== path$1.join(selection.runtime, "bin"))
@@ -6291,7 +6320,7 @@ function inspectManagedSelection(options) {
6291
6320
  identity = runtimeIdentity(selection.runtime, options.arch);
6292
6321
  } catch (error) {
6293
6322
  return missing(
6294
- `The selected Python runtime could not be read (${selection.runtime}): ${describe$1(error)}`
6323
+ `The selected Python runtime could not be read (${selection.runtime}): ${describe$2(error)}`
6295
6324
  );
6296
6325
  }
6297
6326
  if (identity !== selection.runtimeId) {
@@ -6679,7 +6708,7 @@ class ProbeTimeout extends Error {
6679
6708
  this.name = "ProbeTimeout";
6680
6709
  }
6681
6710
  }
6682
- const describe = (error) => error instanceof Error ? error.message : String(error);
6711
+ const describe$1 = (error) => error instanceof Error ? error.message : String(error);
6683
6712
  function runBounded(command, args, env, bounds) {
6684
6713
  return new Promise((resolve, reject) => {
6685
6714
  const child = node_child_process.spawn(command, args, {
@@ -6857,7 +6886,7 @@ async function ownedServeLaunch(interpreters, port, env, platform = process.plat
6857
6886
  bounds
6858
6887
  );
6859
6888
  } catch (error) {
6860
- rejected.push(`${interpreter}: ${describe(error)}`);
6889
+ rejected.push(`${interpreter}: ${describe$1(error)}`);
6861
6890
  }
6862
6891
  }
6863
6892
  return null;
@@ -7324,6 +7353,18 @@ class BackendServiceManager {
7324
7353
  // Flag to track when the app is being closed
7325
7354
  isAutoUpdating = false;
7326
7355
  // Flag to track when an autoupdate is in progress
7356
+ /**
7357
+ * WHY the fleet roster last came back unreadable, when the server answered.
7358
+ *
7359
+ * A dead socket and a 401 look the same to `servingWorkState` - both are not
7360
+ * evidence about work, so both are `unknown` and both hold a restart - but they
7361
+ * are different next steps for a READER, and the update path's refusal says
7362
+ * which happened (QA round 1, observation b). The transport is the only party
7363
+ * that knows, so it records the answer here and the refusal reads it back;
7364
+ * guessing it from the verdict is how the two would come to disagree. Reset on
7365
+ * every read, so it always describes the most recent one.
7366
+ */
7367
+ fleetReadFailure = "unreachable";
7327
7368
  shutdownTimeoutMs = { ...SHUTDOWN_TIMEOUT_DEFAULTS };
7328
7369
  // Configurable timeouts for different shutdown scenarios
7329
7370
  /**
@@ -9150,6 +9191,10 @@ class BackendServiceManager {
9150
9191
  * A transport that does not answer, and a non-200, are both `unknown` - which
9151
9192
  * the decision treats as a reason to WAIT. A read that could not be taken is
9152
9193
  * not evidence that the machine is quiet.
9194
+ *
9195
+ * A LISTING whose own liveness read failed is `unknown` for the same reason and
9196
+ * by the same route: `fleetRosterFromSessions` declines a degraded roster and
9197
+ * answers null, which this reduces to `unknown` (review round 1, B1 = QA Q-1).
9153
9198
  */
9154
9199
  async servingWorkState() {
9155
9200
  try {
@@ -9159,12 +9204,63 @@ class BackendServiceManager {
9159
9204
  // first page is still work in flight, so the read asks for the lot.
9160
9205
  limit: 500
9161
9206
  });
9207
+ this.noteFleetReadAnswer(response.status);
9162
9208
  if (response.status !== 200) return "unknown";
9163
9209
  return servingWorkStateFromSessions(response.body);
9164
9210
  } catch {
9211
+ this.fleetReadFailure = "unreachable";
9165
9212
  return "unknown";
9166
9213
  }
9167
9214
  }
9215
+ /**
9216
+ * Record what a `sessions.list` answer says about the app's own access.
9217
+ *
9218
+ * 401/403 is the one non-200 that is about the CREDENTIAL rather than about the
9219
+ * fleet: the server is up and answering, and it is refusing this app's token.
9220
+ * Everything else - another status, or no answer at all - is `unreachable`,
9221
+ * which is the arm whose remedy (try again once the server answers) is true.
9222
+ */
9223
+ noteFleetReadAnswer(status2) {
9224
+ this.fleetReadFailure = status2 === 401 || status2 === 403 ? "refused-credentials" : "unreachable";
9225
+ }
9226
+ /**
9227
+ * Why the last fleet read could not be taken: `servingWorkState`'s own reason.
9228
+ *
9229
+ * Read by the update path only when a refusal is being composed, and only on
9230
+ * the `unknown` arm - see `FleetDrainOutcome.credentialsRefused`.
9231
+ */
9232
+ fleetReadFailureReason() {
9233
+ return this.fleetReadFailure;
9234
+ }
9235
+ /**
9236
+ * The session roster itself: the same read as `servingWorkState`, with the
9237
+ * rows kept rather than reduced to one verdict.
9238
+ *
9239
+ * The update path's fleet gate needs the rows for two jobs a verdict cannot
9240
+ * do: naming the sessions it waited for in a refusal, and taking the before
9241
+ * and after snapshots that tell it which runtimes a restart displaced
9242
+ * (`backend/fleet-drain.ts`). Both callers read ONE route, and the row shape
9243
+ * and the busy spelling come from the same module, so there is no second
9244
+ * reading of what "busy" means.
9245
+ *
9246
+ * Null rather than an empty array when the route did not answer: an empty
9247
+ * roster is a machine with no sessions, which is a different fact from a read
9248
+ * that could not be taken.
9249
+ */
9250
+ async servingSessionFleet() {
9251
+ try {
9252
+ const response = await this.requestDesktop({
9253
+ op: "sessions.list",
9254
+ limit: 500
9255
+ });
9256
+ this.noteFleetReadAnswer(response.status);
9257
+ if (response.status !== 200) return null;
9258
+ return fleetRosterFromSessions(response.body);
9259
+ } catch {
9260
+ this.fleetReadFailure = "unreachable";
9261
+ return null;
9262
+ }
9263
+ }
9168
9264
  /**
9169
9265
  * The address this app is talking to RIGHT NOW.
9170
9266
  *
@@ -10219,7 +10315,17 @@ const METHODS$1 = [
10219
10315
  "owner_finish",
10220
10316
  "owner_retain",
10221
10317
  "owner_release",
10222
- ...CONSOLE_METHODS
10318
+ ...CONSOLE_METHODS,
10319
+ // File transfer, appended in the Python source's order so the two lists stay
10320
+ // comparable by eye (design §6.1, §6.5). Both halves are NOT symmetric on the
10321
+ // extension, and the reason is a measurement rather than a preference — see
10322
+ // `EXTENSION_CANNOT_SERVE` in `browser_bridge/protocol.py`: no extension build
10323
+ // can serve `download`, because Chrome refuses an extension every CDP primitive
10324
+ // that could put a file where the harness chose (measured 2026-09-18, Chrome
10325
+ // 153), while `upload` rides `DOM.setFileInputFiles` on the session it already
10326
+ // holds. THIS host serves both.
10327
+ "download",
10328
+ "upload"
10223
10329
  ];
10224
10330
  const METHOD_SET = new Set(METHODS$1);
10225
10331
  function isMethod(value) {
@@ -10243,6 +10349,15 @@ const ERROR_CODES = [
10243
10349
  "proto_mismatch",
10244
10350
  "owner_refused",
10245
10351
  "extension_unresponsive",
10352
+ // Present for MIRROR PARITY with `protocol.py`'s `ErrorCode`, and emitted by the
10353
+ // DAEMON, never by this host (§6.2). It has to be in this list for the reason
10354
+ // `rpc.ts` narrows an unknown code to `internal`: an already-released session
10355
+ // validates `ErrorDetail.code` against the Python enum, so a frame carrying a
10356
+ // code it does not know is DROPPED — which is worse than a wrong answer, because
10357
+ // the caller waits out its whole budget. The daemon is on the safe side of that
10358
+ // direction (daemon -> session), a browser host is not, which is exactly why a
10359
+ // policy refusal travels as an `ok: true` result here.
10360
+ "capability_unsupported",
10246
10361
  "internal",
10247
10362
  // The console namespace's own additions (design 10.6). Spread rather than copied
10248
10363
  // for the same reason METHODS is: this list is what `rpc.ts` narrows a raised
@@ -10250,6 +10365,7 @@ const ERROR_CODES = [
10250
10365
  // a command that hangs rather than one that fails.
10251
10366
  ...CONSOLE_ERROR_CODES
10252
10367
  ];
10368
+ const HOST_CAPABILITIES = ["download", "upload"];
10253
10369
  const RUN_DIRNAME = path$1.join("run", "ui-browser");
10254
10370
  const STATE_FILENAME = "host.json";
10255
10371
  const STATE_DIR_MODE = 448;
@@ -10319,6 +10435,10 @@ class BrowserStateWriter {
10319
10435
  host: "ui",
10320
10436
  app_version: this.appVersion,
10321
10437
  profile_dir: facts.profileDir,
10438
+ // Advertised from the ONE list the `/health` route also reads (§6.3), so the
10439
+ // record a session reads and the probe Python acquits a stale record with
10440
+ // cannot name different capabilities.
10441
+ capabilities: [...HOST_CAPABILITIES],
10322
10442
  tabs: facts.tabs,
10323
10443
  agent_tabs: facts.agentTabs,
10324
10444
  console: facts.console ?? false,
@@ -10651,7 +10771,7 @@ const FIXED = /* @__PURE__ */ new Map([
10651
10771
  ["f11", "\x1B[23~"],
10652
10772
  ["f12", "\x1B[24~"]
10653
10773
  ]);
10654
- const CONTROL = /* @__PURE__ */ new Map([
10774
+ const CONTROL$1 = /* @__PURE__ */ new Map([
10655
10775
  ["ctrl+space", 0],
10656
10776
  ["ctrl+[", 27],
10657
10777
  ["ctrl+\\", 28],
@@ -10660,7 +10780,7 @@ const CONTROL = /* @__PURE__ */ new Map([
10660
10780
  ["ctrl+_", 31]
10661
10781
  ]);
10662
10782
  for (const letter of "abcdefghijklmnopqrstuvwxyz") {
10663
- CONTROL.set(`ctrl+${letter}`, letter.charCodeAt(0) - 96);
10783
+ CONTROL$1.set(`ctrl+${letter}`, letter.charCodeAt(0) - 96);
10664
10784
  }
10665
10785
  const CURSOR = /* @__PURE__ */ new Map([
10666
10786
  ["up", { normal: "\x1B[A", application: "\x1BOA" }],
@@ -10675,7 +10795,7 @@ const encodeText = (text) => ENCODER$1.encode(text);
10675
10795
  function encodeNamedKey(name, modes) {
10676
10796
  const fixed = FIXED.get(name);
10677
10797
  if (fixed !== void 0) return encodeText(fixed);
10678
- const control = CONTROL.get(name);
10798
+ const control = CONTROL$1.get(name);
10679
10799
  if (control !== void 0) return Uint8Array.from([control]);
10680
10800
  const cursor = CURSOR.get(name);
10681
10801
  if (cursor !== void 0) {
@@ -12646,6 +12766,7 @@ const PTY_PACKAGE = "node-pty";
12646
12766
  const SPAWN_HELPER_NAME = "spawn-helper";
12647
12767
  const SPAWN_HELPER_MODE = 493;
12648
12768
  const OWNER_EXECUTE = 64;
12769
+ const NATIVE_MODULE_NAME = "pty.node";
12649
12770
  const ASAR_APP = /([/\\])app\.asar([/\\])/;
12650
12771
  const ASAR_NODE_MODULES = /([/\\])node_modules\.asar([/\\])/;
12651
12772
  function unpackedPath(path2) {
@@ -12654,8 +12775,18 @@ function unpackedPath(path2) {
12654
12775
  function prebuildDir(root, target2) {
12655
12776
  return path$1.join(root, "prebuilds", `${target2.platform}-${target2.arch}`);
12656
12777
  }
12657
- function spawnHelperPath(root, target2) {
12658
- return path$1.join(prebuildDir(root, target2), SPAWN_HELPER_NAME);
12778
+ function forksSpawnHelper(platform) {
12779
+ return platform === "darwin";
12780
+ }
12781
+ function nativeDirs(root, target2) {
12782
+ return [
12783
+ path$1.join(root, "build", "Release"),
12784
+ path$1.join(root, "build", "Debug"),
12785
+ prebuildDir(root, target2)
12786
+ ];
12787
+ }
12788
+ function spawnHelperPaths(root, target2) {
12789
+ return nativeDirs(root, target2).map((dir) => path$1.join(dir, SPAWN_HELPER_NAME));
12659
12790
  }
12660
12791
  function resolvePtyRoot() {
12661
12792
  const requireFrom = node_module.createRequire(
@@ -12666,26 +12797,50 @@ function resolvePtyRoot() {
12666
12797
  );
12667
12798
  }
12668
12799
  function ensureSpawnHelperExecutable(root, target2) {
12669
- const path2 = spawnHelperPath(root, target2);
12800
+ const required = forksSpawnHelper(target2.platform);
12670
12801
  try {
12671
- if (!fs.existsSync(path2)) {
12672
- return { path: null, mode: null, healed: false, reason: "no helper" };
12673
- }
12674
- const mode = fs.statSync(path2).mode & 511;
12675
- if ((mode & OWNER_EXECUTE) !== 0) {
12676
- return { path: path2, mode, healed: false };
12802
+ const helpers = spawnHelperPaths(root, target2).filter((path2) => fs.existsSync(path2)).map((path2) => {
12803
+ const mode = fs.statSync(path2).mode & 511;
12804
+ if ((mode & OWNER_EXECUTE) !== 0) return { path: path2, mode, healed: false };
12805
+ fs.chmodSync(path2, SPAWN_HELPER_MODE);
12806
+ return { path: path2, mode, healed: true };
12807
+ });
12808
+ if (helpers.length === 0) {
12809
+ return {
12810
+ path: null,
12811
+ mode: null,
12812
+ healed: false,
12813
+ required,
12814
+ helpers,
12815
+ reason: required ? "no helper" : `no helper, and ${target2.platform}-${target2.arch} forks none`
12816
+ };
12677
12817
  }
12678
- fs.chmodSync(path2, SPAWN_HELPER_MODE);
12679
- return { path: path2, mode, healed: true };
12818
+ const loaded = nativeDirs(root, target2).find(
12819
+ (dir) => fs.existsSync(path$1.join(dir, NATIVE_MODULE_NAME))
12820
+ );
12821
+ const primary = helpers.find((helper) => path$1.dirname(helper.path) === loaded) ?? helpers[0];
12822
+ return {
12823
+ path: primary.path,
12824
+ mode: primary.mode,
12825
+ healed: helpers.some((helper) => helper.healed),
12826
+ required,
12827
+ helpers
12828
+ };
12680
12829
  } catch (error) {
12681
12830
  return {
12682
- path: path2,
12831
+ path: null,
12683
12832
  mode: null,
12684
12833
  healed: false,
12834
+ required,
12835
+ helpers: [],
12685
12836
  reason: error instanceof Error ? error.message : String(error)
12686
12837
  };
12687
12838
  }
12688
12839
  }
12840
+ function helperStatus(report) {
12841
+ if (report.path !== null) return "ready";
12842
+ return report.required ? "without a helper" : "no helper needed on this platform";
12843
+ }
12689
12844
  let cached = null;
12690
12845
  function loadNodePty() {
12691
12846
  if (cached) return cached;
@@ -12841,7 +12996,7 @@ async function startConsoleHost(options) {
12841
12996
  log
12842
12997
  });
12843
12998
  log(
12844
- `[console] host ready on the existing endpoint: ${registry.count()} surface(s) (node-pty ${load.helper.mode === null ? "without a helper" : "ready"})`
12999
+ `[console] host ready on the existing endpoint: ${registry.count()} surface(s) (node-pty ${helperStatus(load.helper)})`
12845
13000
  );
12846
13001
  return {
12847
13002
  ok: true,
@@ -13309,31 +13464,6 @@ function readJson(path2) {
13309
13464
  function errorData(error) {
13310
13465
  return error instanceof BridgeCommandError ? error.data : {};
13311
13466
  }
13312
- const CDP_DEADLINE_MS = 15e3;
13313
- const CDP_ATTACH_DEADLINE_MS = 5e3;
13314
- const CHROME_API_DEADLINE_MS = 5e3;
13315
- const SCRIPTING_DEADLINE_MS = 15e3;
13316
- function deadline(op, ms, what) {
13317
- return new Promise((resolve, reject) => {
13318
- const timer = setTimeout(() => {
13319
- reject(
13320
- new BridgeCommandError("internal", `${what} did not respond within ${ms}ms`, {
13321
- stalled: what
13322
- })
13323
- );
13324
- }, ms);
13325
- op.then(
13326
- (value) => {
13327
- clearTimeout(timer);
13328
- resolve(value);
13329
- },
13330
- (error) => {
13331
- clearTimeout(timer);
13332
- reject(error);
13333
- }
13334
- );
13335
- });
13336
- }
13337
13467
  function nextSequence(queue) {
13338
13468
  return (queue ?? []).reduce(
13339
13469
  (highest, entry) => Math.max(highest, entry.sequence),
@@ -13351,7 +13481,7 @@ const SCROLL_DIRECTIONS = /* @__PURE__ */ new Set([
13351
13481
  function sleep(ms) {
13352
13482
  return new Promise((resolve) => setTimeout(resolve, ms));
13353
13483
  }
13354
- const WEB_CONTENTS_DEADLINE_MS = CHROME_API_DEADLINE_MS;
13484
+ const WEB_CONTENTS_DEADLINE_MS = 15e3;
13355
13485
  const ONCE_GRANT_TTL_MS = 10 * 6e4;
13356
13486
  function receiptKey(origin, requester) {
13357
13487
  return `${origin}
@@ -14342,6 +14472,30 @@ function routeDebuggerEvent(webContentsId, method, rawParams) {
14342
14472
  });
14343
14473
  }
14344
14474
  }
14475
+ const CDP_DEADLINE_MS = 15e3;
14476
+ const CDP_ATTACH_DEADLINE_MS = 5e3;
14477
+ const SCRIPTING_DEADLINE_MS = 15e3;
14478
+ function deadline(op, ms, what) {
14479
+ return new Promise((resolve, reject) => {
14480
+ const timer = setTimeout(() => {
14481
+ reject(
14482
+ new BridgeCommandError("internal", `${what} did not respond within ${ms}ms`, {
14483
+ stalled: what
14484
+ })
14485
+ );
14486
+ }, ms);
14487
+ op.then(
14488
+ (value) => {
14489
+ clearTimeout(timer);
14490
+ resolve(value);
14491
+ },
14492
+ (error) => {
14493
+ clearTimeout(timer);
14494
+ reject(error);
14495
+ }
14496
+ );
14497
+ });
14498
+ }
14345
14499
  const PROTOCOL_VERSION = "1.3";
14346
14500
  const ALREADY_ATTACHED = /another debugger|already attached/i;
14347
14501
  class CdpPool {
@@ -14623,6 +14777,743 @@ class ConsentNotifier {
14623
14777
  }
14624
14778
  }
14625
14779
  }
14780
+ const DENY_EXTS = ["apk", "app", "bash", "bat", "cmd", "com", "command", "cpl", "crx", "dex", "dll", "dmg", "drv", "exe", "hta", "jar", "lnk", "msi", "msp", "mst", "ocx", "pkg", "ps1", "psm1", "reg", "scpt", "scr", "sh", "sys", "url", "vbe", "vbs", "wasm", "wsf", "zsh"];
14781
+ const CREDENTIAL_NAME_PATTERNS = ["id_rsa*", "id_ed25519*", "id_ecdsa*", "id_dsa*", "*.pem", "*.key", "*.p12", "*.pfx", "*.jks", "*.keystore", ".netrc", "_netrc", ".git-credentials", ".npmrc", ".pypirc", ".pgpass", ".my.cnf", ".dockercfg", ".env", ".env.*", "credentials", "credentials.json", "service-account*.json", "*.keychain", "*.keychain-db"];
14782
+ const CAPS = {
14783
+ "downloadMaxBytes": 268435456,
14784
+ "downloadMaxFilesPerCall": 20,
14785
+ "downloadTimeoutS": 120,
14786
+ "downloadTimeoutMaxS": 600,
14787
+ "uploadMaxFiles": 10
14788
+ };
14789
+ const MAX_NAME_BYTES = 200;
14790
+ const FALLBACK_STEM = "download";
14791
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
14792
+ const BIDI_ZERO_WIDTH = /[\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g;
14793
+ const WINDOWS_RESERVED_STEMS = /* @__PURE__ */ new Set([
14794
+ "con",
14795
+ "prn",
14796
+ "aux",
14797
+ "nul",
14798
+ "com1",
14799
+ "com2",
14800
+ "com3",
14801
+ "com4",
14802
+ "com5",
14803
+ "com6",
14804
+ "com7",
14805
+ "com8",
14806
+ "com9",
14807
+ "lpt1",
14808
+ "lpt2",
14809
+ "lpt3",
14810
+ "lpt4",
14811
+ "lpt5",
14812
+ "lpt6",
14813
+ "lpt7",
14814
+ "lpt8",
14815
+ "lpt9"
14816
+ ]);
14817
+ function bytesOf(text) {
14818
+ return new TextEncoder().encode(text).length;
14819
+ }
14820
+ function fallbackDigest(text) {
14821
+ let hash = 2166136261;
14822
+ for (const byte of new TextEncoder().encode(text)) {
14823
+ hash = Math.imul(hash ^ byte, 16777619) >>> 0;
14824
+ }
14825
+ return hash.toString(16).padStart(8, "0");
14826
+ }
14827
+ function truncateBytes(name, limit) {
14828
+ let head = name;
14829
+ let ext = "";
14830
+ const dot = name.lastIndexOf(".");
14831
+ if (dot > 0 && name.length - dot - 1 <= 12) {
14832
+ head = name.slice(0, dot);
14833
+ ext = name.slice(dot + 1);
14834
+ }
14835
+ const budget = limit - (ext ? bytesOf(ext) + 1 : 0);
14836
+ if (budget <= 0) return "";
14837
+ while (bytesOf(head) > budget && head.length > 0) {
14838
+ head = head.slice(0, -1);
14839
+ if (/[\ud800-\udbff]$/.test(head)) head = head.slice(0, -1);
14840
+ }
14841
+ return ext ? `${head}.${ext}` : head;
14842
+ }
14843
+ function fallbackName(raw, sniffedExt = "") {
14844
+ const stem = `${FALLBACK_STEM}-${fallbackDigest(raw)}`;
14845
+ return sniffedExt ? `${stem}.${sniffedExt}` : stem;
14846
+ }
14847
+ function safeName(raw, sniffedExt = "") {
14848
+ const parts = raw.replace(/\\/g, "/").split("/");
14849
+ let name = (parts[parts.length - 1] ?? "").replace(CONTROL, "").replace(BIDI_ZERO_WIDTH, "");
14850
+ name = name.normalize("NFC").trim().replace(/[. ]+$/, "");
14851
+ if (name === "" || name === "." || name === "..") return fallbackName(raw, sniffedExt);
14852
+ const dot = name.lastIndexOf(".");
14853
+ const stem = dot > 0 ? name.slice(0, dot) : name;
14854
+ let ext = dot > 0 ? name.slice(dot + 1) : "";
14855
+ if (WINDOWS_RESERVED_STEMS.has(stem.toLowerCase())) return fallbackName(raw, sniffedExt);
14856
+ if (sniffedExt && ext.toLowerCase() !== sniffedExt.toLowerCase()) {
14857
+ ext = sniffedExt;
14858
+ }
14859
+ return truncateBytes(ext ? `${stem}.${ext}` : stem, MAX_NAME_BYTES);
14860
+ }
14861
+ function extensionOf(raw) {
14862
+ const name = safeName(raw).toLowerCase();
14863
+ const dot = name.lastIndexOf(".");
14864
+ if (dot <= 0) return "";
14865
+ const ext = name.slice(dot + 1);
14866
+ return ext.length > 12 || ext.includes("/") ? "" : ext;
14867
+ }
14868
+ function executableName(raw) {
14869
+ const ext = extensionOf(raw);
14870
+ return ext !== "" && DENY_EXTS.includes(ext);
14871
+ }
14872
+ function globToRegExp(pattern) {
14873
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
14874
+ return new RegExp(`^${escaped}$`);
14875
+ }
14876
+ CREDENTIAL_NAME_PATTERNS.map((pattern) => ({
14877
+ pattern,
14878
+ regex: globToRegExp(pattern)
14879
+ }));
14880
+ const QUIET_MS = 1500;
14881
+ const NOTES_KEPT = 4;
14882
+ const PROGRESS_RENDER_MS = 250;
14883
+ const CAP_SAMPLE_MS = 100;
14884
+ class DownloadArmer {
14885
+ constructor(options) {
14886
+ this.options = options;
14887
+ }
14888
+ captures = /* @__PURE__ */ new Map();
14889
+ notes = [];
14890
+ /** The newest file SAVED into the download directory, and how many have been saved
14891
+ * in this app run, for the durable control's own label (review round 2, U12).
14892
+ *
14893
+ * WHY IT IS NOT DERIVED FROM `notes`: the notes are a bounded NOTIFICATION window
14894
+ * (`NOTES_KEPT` = 4), so a save four decisions ago is gone from them — and the
14895
+ * first draft of this field read the notes, which meant the summary VANISHED
14896
+ * exactly when the user most needed it (a refusal, a refusal, a refusal, and the
14897
+ * trace of the file that did arrive is gone). It is one string and a count, kept
14898
+ * for the life of the process, which is what makes "this session" a true word in
14899
+ * the control's label rather than a bound dressed up as a total. */
14900
+ savedSummary = null;
14901
+ /** The paths this host has PROMISED Chromium, keyed by the DIRECTORY they are
14902
+ * in (review round 2, R2-4).
14903
+ *
14904
+ * WHY IT IS NOT ON THE CAPTURE ANY MORE. A reservation held per capture answers
14905
+ * only the question that capture asked: two same-named downloads on two armed
14906
+ * CALLS into one directory are still decided by the `existsSync` probe alone,
14907
+ * because Chromium creates the file after `setSavePath` returns — so the second
14908
+ * call cannot see the first's path. The directory is what a name can collide in,
14909
+ * so the directory is the key. And a reservation is RELEASED when the write it
14910
+ * was for settles — it either exists on disk (the probe sees it) or was
14911
+ * discarded (the name is free) — rather than consuming its suffix for the rest
14912
+ * of the call, which is how the next download of that name landed as
14913
+ * `name (1).ext` with nothing on disk (review round 2, R2-4). */
14914
+ reserved = /* @__PURE__ */ new Map();
14915
+ /** The owner kind of one tab, or null when this armer cannot say (review round
14916
+ * 2, U10: the kind travels with the decision because the tab may be closed by
14917
+ * the time the row renders it). */
14918
+ ownerKindOf(tabId) {
14919
+ return this.options.ownerKindFor?.(tabId) ?? null;
14920
+ }
14921
+ /** Arm one tab. `dir` is the harness-composed directory (§10.2); it is created
14922
+ * 0700 if missing, because the harness composes it and a race with a session's
14923
+ * own cleanup must not cost the user the file.
14924
+ *
14925
+ * TWO RULES ABOUT THAT DIRECTORY (review round 1, M3), and both exist because
14926
+ * this is the last place between a wire parameter and a `chmod`. ABSOLUTE: a
14927
+ * relative `dir` resolves against the APP's working directory rather than the
14928
+ * session's, which is the shape `upload` already refuses for its own paths. And
14929
+ * the MODE IS ASSERTED ONLY ON A DIRECTORY THIS CALL CREATED: re-moding an
14930
+ * existing path the harness composed — or a symlink to one — would be a chmod on
14931
+ * something this host does not own.
14932
+ */
14933
+ arm(tabId, dir, timeoutMs) {
14934
+ if (!path$1.isAbsolute(dir)) {
14935
+ throw new BridgeCommandError(
14936
+ "internal",
14937
+ "download needs an absolute directory to write into",
14938
+ { param: "dir" }
14939
+ );
14940
+ }
14941
+ this.forget(tabId);
14942
+ const created = !fs.existsSync(dir);
14943
+ try {
14944
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
14945
+ if (created) fs.chmodSync(dir, 448);
14946
+ } catch (error) {
14947
+ throw new BridgeCommandError(
14948
+ "internal",
14949
+ `download needs a directory it can write into: ${describe(error)}`,
14950
+ { param: "dir" }
14951
+ );
14952
+ }
14953
+ const now = this.options.now ?? Date.now;
14954
+ const capture = {
14955
+ tabId,
14956
+ dir,
14957
+ timeoutMs,
14958
+ startedAt: now(),
14959
+ files: [],
14960
+ refusals: [],
14961
+ pending: 0,
14962
+ started: false,
14963
+ live: [],
14964
+ handedOut: /* @__PURE__ */ new Set(),
14965
+ active: null,
14966
+ lastProgressAt: 0,
14967
+ quietTimer: null,
14968
+ deadlineTimer: null,
14969
+ capTimer: null,
14970
+ finish: null,
14971
+ settled: false
14972
+ };
14973
+ this.captures.set(tabId, capture);
14974
+ const deadline2 = setTimeout(() => this.settle(capture), timeoutMs);
14975
+ deadline2.unref?.();
14976
+ capture.deadlineTimer = deadline2;
14977
+ const capSample = setInterval(
14978
+ () => this.sampleCaps(capture),
14979
+ CAP_SAMPLE_MS
14980
+ );
14981
+ capSample.unref?.();
14982
+ capture.capTimer = capSample;
14983
+ this.options.log(
14984
+ `[browser] armed downloads on tab ${tabId} into ${dir} for ${Math.round(timeoutMs / 1e3)}s`
14985
+ );
14986
+ this.options.onActivity?.();
14987
+ return {
14988
+ done: () => new Promise((resolve) => {
14989
+ const answer = () => {
14990
+ capture.settled = true;
14991
+ resolve(this.resultOf(capture));
14992
+ };
14993
+ if (capture.settled) answer();
14994
+ else capture.finish = answer;
14995
+ })
14996
+ };
14997
+ }
14998
+ /** Drop one tab's arm and stop tracking it. Called from the action's `finally`
14999
+ * and from the single tab-removal path, so a closed tab cannot leave a live
15000
+ * capture behind. */
15001
+ forget(tabId) {
15002
+ const capture = this.captures.get(tabId);
15003
+ if (!capture) return;
15004
+ this.captures.delete(tabId);
15005
+ this.clearTimers(capture);
15006
+ this.cancelLive(
15007
+ capture,
15008
+ "was still being written when the download call ended",
15009
+ { rule: "interrupted", bytes: 0, limit: 0 }
15010
+ );
15011
+ for (const path2 of capture.handedOut) this.release(capture, path2);
15012
+ if (!capture.settled) {
15013
+ capture.settled = true;
15014
+ capture.finish?.();
15015
+ }
15016
+ }
15017
+ /**
15018
+ * Decide one download attempt. THE ONLY ENTRY POINT `profile.ts` calls.
15019
+ *
15020
+ * The order is deliberate and is the design's: the NAME first (cheapest, and
15021
+ * the only check that can refuse a `.url`/`.reg`/`.command`, whose bytes are
15022
+ * plain text), then the CAP before the write, then the save path.
15023
+ */
15024
+ decide(item, webContentsId) {
15025
+ const tabId = this.options.tabForWebContents(webContentsId);
15026
+ const capture = tabId === null ? void 0 : this.captures.get(tabId);
15027
+ if (!capture || capture.settled) {
15028
+ return { cancel: true, reason: "no download call is armed on this tab" };
15029
+ }
15030
+ const raw = item.getFilename();
15031
+ const clean = safeName(raw);
15032
+ if (executableName(raw)) {
15033
+ return this.refuse(
15034
+ capture,
15035
+ clean,
15036
+ `refused: \`${clean}\` is an executable/script type; nothing was saved`,
15037
+ { rule: "executable", bytes: item.getTotalBytes(), limit: 0 }
15038
+ );
15039
+ }
15040
+ if (capture.files.length + capture.pending >= CAPS.downloadMaxFilesPerCall) {
15041
+ return this.refuse(
15042
+ capture,
15043
+ clean,
15044
+ // The row shows this verbatim, so it is written for a person who just
15045
+ // clicked something rather than in tool-call jargon (review round 1, D1):
15046
+ // "call" and "landed" described the MODEL's call, and a user reading the
15047
+ // strip made neither.
15048
+ `refused: this download call has already saved its limit of ${CAPS.downloadMaxFilesPerCall} files; nothing was saved`,
15049
+ {
15050
+ rule: "count",
15051
+ bytes: 0,
15052
+ limit: CAPS.downloadMaxFilesPerCall
15053
+ }
15054
+ );
15055
+ }
15056
+ const total = item.getTotalBytes();
15057
+ if (total > CAPS.downloadMaxBytes) {
15058
+ return this.refuse(
15059
+ capture,
15060
+ clean,
15061
+ `refused: \`${clean}\` is over the ${humanBytes(CAPS.downloadMaxBytes)} per-file download limit; nothing was saved (it is ${total} bytes)`,
15062
+ { rule: "limit", bytes: total, limit: CAPS.downloadMaxBytes }
15063
+ );
15064
+ }
15065
+ const savePath = this.uniquePath(capture, clean, raw);
15066
+ try {
15067
+ item.setSavePath(savePath);
15068
+ } catch (error) {
15069
+ return this.refuse(
15070
+ capture,
15071
+ clean,
15072
+ `refused: \`${clean}\` could not be saved — the download folder could not be written to (${describe(error)}); nothing was saved`,
15073
+ { rule: "write", bytes: item.getTotalBytes(), limit: 0 }
15074
+ );
15075
+ }
15076
+ this.track(item, capture, savePath);
15077
+ this.reserve(capture, savePath);
15078
+ return { cancel: false, reason: "" };
15079
+ }
15080
+ /** Promise one path, in this capture's own list and in the directory's index. */
15081
+ reserve(capture, savePath) {
15082
+ capture.handedOut.add(savePath);
15083
+ const held = this.reserved.get(capture.dir);
15084
+ if (held) held.add(savePath);
15085
+ else this.reserved.set(capture.dir, /* @__PURE__ */ new Set([savePath]));
15086
+ }
15087
+ /** Give a reserved name back, because the write it was for has settled (review
15088
+ * round 2, R2-4): the file is on disk, so the probe sees it, or it was
15089
+ * discarded, so the name is free again.
15090
+ *
15091
+ * The capture's OWN list is what decides whether this host ever promised the
15092
+ * path, so a `done` event arriving after the arm was forgotten — the capture
15093
+ * object is still reachable through the listener closure even though the map
15094
+ * entry is gone — cannot release something a different capture reserved. */
15095
+ release(capture, savePath) {
15096
+ if (!capture.handedOut.delete(savePath)) return;
15097
+ const held = this.reserved.get(capture.dir);
15098
+ if (!held) return;
15099
+ held.delete(savePath);
15100
+ if (held.size === 0) this.reserved.delete(capture.dir);
15101
+ }
15102
+ /** What the chrome row renders for ONE tab (§16.4).
15103
+ *
15104
+ * WHY PER TAB (review round 1, D2): `captures` and `notes` are host-wide, and
15105
+ * the row rendered both into EVERY browser tab's chrome — so the strip of the
15106
+ * tab the user is watching could narrate a decision taken in another one, and
15107
+ * did exactly that in the published frames (`04` showed a refusal above a form
15108
+ * holding three attached files). The active tab is the row's subject.
15109
+ *
15110
+ * `null` is "no tab is active", which renders nothing: the strip belongs to a
15111
+ * tab, and there is no tab to speak for.
15112
+ *
15113
+ * WHY THE NOTES ARE HOST-WIDE AND THE TAB IS NAMED INSTEAD OF FILTERED OUT, which
15114
+ * is a correction to this change's own first attempt at D2. Filtering the notes to
15115
+ * the active tab looks like the reviewer's "scope it to the tab whose decision it
15116
+ * was", and it HIDES the case the feature exists for: an agent tab is created
15117
+ * INACTIVE by design (`tabs.ts`: "An agent `open` NEVER changes which tab the user
15118
+ * is looking at", §11.4's focus-safety rule), so an agent's own download would
15119
+ * leave no trace anywhere on screen — the exact complaint U3 files against the
15120
+ * upload path, reintroduced on the download path. What the reviewer's finding
15121
+ * actually needs is that the strip must not APPEAR to be about the page on screen:
15122
+ * so the note keeps its `tabId`, the projection carries `activeTabId`, and the row
15123
+ * says "on the agent's tab" when the two differ.
15124
+ */
15125
+ activityFor(tabId) {
15126
+ const newest = [...this.captures.values()].at(-1);
15127
+ return {
15128
+ active: newest?.active ?? null,
15129
+ // The DIRECTORY is reported from a live capture even before anything has
15130
+ // started, so the reveal is available for the whole of a call rather than
15131
+ // only after the first file lands. The NAME is not: "Downloading <a
15132
+ // directory>" would be a row that appears before the page has decided to
15133
+ // download anything.
15134
+ dir: this.downloadDir(),
15135
+ notes: [...this.notes].reverse(),
15136
+ activeTabId: tabId,
15137
+ recent: this.savedSummary
15138
+ };
15139
+ }
15140
+ /** The directory the reveal opens (§16.4): the live arm's, else the newest one a
15141
+ * decision used.
15142
+ *
15143
+ * HOST-WIDE ON PURPOSE, which is why it is not `activityFor`'s own field: the
15144
+ * button is about the FOLDER the host writes into, not about a tab. Upload notes
15145
+ * carry no directory, so they cannot answer for one. */
15146
+ downloadDir() {
15147
+ const dirs = this.notes.filter((note) => note.dir !== "").map((n) => n.dir);
15148
+ return [...this.captures.values()].at(-1)?.dir ?? dirs.at(-1) ?? null;
15149
+ }
15150
+ /** One upload's own line (review round 1, U3).
15151
+ *
15152
+ * WHY AN UPLOAD HAS A NOTE AT ALL, when §16.4 deliberately has no upload
15153
+ * affordance: the surface asymmetry was defensible while nothing was going
15154
+ * wrong, but an upload is the more dangerous verb — and the QA matrix showed
15155
+ * three files leaving the machine with the strip silent, or (worse) still
15156
+ * narrating an unrelated download refusal while they left. One line naming what
15157
+ * went where is not the per-file list §16.4 rules out; it is the same rule the
15158
+ * download half follows, applied to the half that had none.
15159
+ *
15160
+ * The SITE comes from the page the action actually reported (never from a URL a
15161
+ * caller composed), and the names are the sanitised ones the host attached. */
15162
+ noteUpload(tabId, files, site) {
15163
+ if (files.length === 0) return;
15164
+ this.notes.push({
15165
+ name: files[0]?.name ?? "",
15166
+ count: files.length,
15167
+ dir: "",
15168
+ outcome: "sent",
15169
+ reason: "",
15170
+ at: (this.options.now ?? Date.now)(),
15171
+ direction: "upload",
15172
+ tabId,
15173
+ site,
15174
+ refusal: null,
15175
+ ownerKind: this.ownerKindOf(tabId)
15176
+ });
15177
+ while (this.notes.length > NOTES_KEPT) this.notes.shift();
15178
+ this.options.onActivity?.();
15179
+ }
15180
+ // ---- the item lifecycle --------------------------------------------------
15181
+ track(item, capture, savePath) {
15182
+ capture.started = true;
15183
+ capture.pending += 1;
15184
+ const entry = { item, savePath, weCancelled: false };
15185
+ capture.live.push(entry);
15186
+ this.showProgress(capture, entry);
15187
+ if (capture.quietTimer) {
15188
+ clearTimeout(capture.quietTimer);
15189
+ capture.quietTimer = null;
15190
+ }
15191
+ this.options.onActivity?.();
15192
+ item.on("updated", () => {
15193
+ if (entry.weCancelled) return;
15194
+ if (capture.settled) {
15195
+ this.refuseLive(
15196
+ capture,
15197
+ entry,
15198
+ `\`${path$1.basename(savePath)}\` was still being written after this call's answer`,
15199
+ {
15200
+ rule: "interrupted",
15201
+ bytes: item.getReceivedBytes(),
15202
+ limit: 0
15203
+ }
15204
+ );
15205
+ return;
15206
+ }
15207
+ if (item.getReceivedBytes() > CAPS.downloadMaxBytes) {
15208
+ this.overrun(capture, entry);
15209
+ return;
15210
+ }
15211
+ this.showProgress(capture, entry);
15212
+ });
15213
+ item.once("done", (_event, state) => {
15214
+ capture.pending -= 1;
15215
+ capture.live = capture.live.filter((live) => live !== entry);
15216
+ const name = path$1.basename(savePath);
15217
+ this.release(capture, savePath);
15218
+ if (entry.weCancelled) {
15219
+ if (state !== "completed") discardPartial(savePath, this.options.log);
15220
+ } else if (state === "completed") {
15221
+ restrictMode(savePath, this.options.log);
15222
+ capture.files.push(fileFact(item, savePath));
15223
+ this.note(capture, name, "saved", 1, "", null);
15224
+ } else {
15225
+ const reason = capture.settled ? `refused: \`${name}\` did not finish (${state}) after this call's answer; the partial file was discarded` : `refused: \`${name}\` did not finish (${state}); the partial file was discarded`;
15226
+ discardPartial(savePath, this.options.log);
15227
+ if (!capture.settled) capture.refusals.push(reason);
15228
+ this.note(capture, name, "refused", 1, reason, {
15229
+ rule: "interrupted",
15230
+ bytes: item.getReceivedBytes(),
15231
+ limit: 0
15232
+ });
15233
+ }
15234
+ this.showProgress(capture);
15235
+ this.options.onActivity?.();
15236
+ if (!capture.settled && capture.pending === 0) this.armQuiet(capture);
15237
+ });
15238
+ }
15239
+ /** What the row says is in flight, refreshed on the render throttle.
15240
+ *
15241
+ * The LAST tracked item is the one the row names, which is the item a page that
15242
+ * starts several files is currently growing; the byte counts come from the item
15243
+ * itself, never from a sum this module keeps (a sum would be a second account of
15244
+ * the same write). The throttle is what keeps a 40 MB download from sending a
15245
+ * message per chunk to the renderer. */
15246
+ showProgress(capture, entry) {
15247
+ const last = entry ?? capture.live.at(-1);
15248
+ capture.active = last ? {
15249
+ name: path$1.basename(last.savePath),
15250
+ received: last.item.getReceivedBytes(),
15251
+ total: last.item.getTotalBytes(),
15252
+ tabId: capture.tabId
15253
+ } : null;
15254
+ const now = (this.options.now ?? Date.now)();
15255
+ if (now - capture.lastProgressAt < PROGRESS_RENDER_MS) return;
15256
+ capture.lastProgressAt = now;
15257
+ this.options.onActivity?.();
15258
+ }
15259
+ /** THE RUNTIME CAP'S REFUSAL, IN ONE PLACE (review round 2, Q4). Two triggers fire
15260
+ * it now — Chromium's `updated` and this host's own sampler — and two spellings
15261
+ * of one rule is exactly how the row's copy and the tool result stop agreeing.
15262
+ *
15263
+ * The RULE is `overrun` rather than `limit` (review round 2, R2-5): this case
15264
+ * cancelled a write that was already on disk, and the row must not tell the user
15265
+ * "Nothing was saved." about a partial it discarded. The SENTENCE is unchanged,
15266
+ * because it is the tool result's and QA and the review both read it as right. */
15267
+ overrun(capture, entry) {
15268
+ this.refuseLive(
15269
+ capture,
15270
+ entry,
15271
+ `\`${path$1.basename(entry.savePath)}\` went over the ${humanBytes(CAPS.downloadMaxBytes)} per-file download limit while it was being written`,
15272
+ {
15273
+ rule: "overrun",
15274
+ bytes: entry.item.getReceivedBytes(),
15275
+ limit: CAPS.downloadMaxBytes
15276
+ }
15277
+ );
15278
+ }
15279
+ /** Sample every still-writing transfer's own byte count for the runtime cap.
15280
+ *
15281
+ * WHY THIS EXISTS BESIDE THE `updated` CHECK (review round 2, Q4): `updated` is
15282
+ * Chromium's event, and QA measured it silent for the whole of a 700 MiB write,
15283
+ * so on its own it bounds the disk by the cap plus however long the origin stays
15284
+ * quiet. This reads `getReceivedBytes()` — the item's own counter, the same one
15285
+ * the refusal reports — so the ceiling is the host's to enforce rather than the
15286
+ * page's throughput to decide. It reports nothing of its own: the refusal, the
15287
+ * note and the cancel all go through `overrun`, so a sample can never produce a
15288
+ * second account of a write the event already cancelled. */
15289
+ sampleCaps(capture) {
15290
+ if (capture.settled) return;
15291
+ for (const entry of [...capture.live]) {
15292
+ if (entry.weCancelled) continue;
15293
+ if (entry.item.getReceivedBytes() <= CAPS.downloadMaxBytes) continue;
15294
+ this.overrun(capture, entry);
15295
+ }
15296
+ }
15297
+ /** Report a download this host is cancelling, and cancel it.
15298
+ *
15299
+ * ONE PATH FOR ALL THREE KINDS OF CANCEL (the runtime cap, the deadline, the
15300
+ * call going away), because they differ only in the clause that explains them
15301
+ * and a second copy would be a second chance to forget the note, the refusal or
15302
+ * the cancel itself.
15303
+ *
15304
+ * `weCancelled` is set HERE rather than by the callers, and it is what makes the
15305
+ * sentence survivable: the `done` event our own `cancel()` produces arrives after
15306
+ * the answer in the deadline case, and the handler must then discard the residue
15307
+ * without writing a second note. */
15308
+ refuseLive(capture, entry, clause, refusal) {
15309
+ entry.weCancelled = true;
15310
+ const reason = `refused: ${clause}; it was cancelled and the partial file was discarded`;
15311
+ capture.refusals.push(reason);
15312
+ this.note(capture, path$1.basename(entry.savePath), "refused", 1, reason, refusal);
15313
+ this.options.log(`[browser] ${reason}`);
15314
+ if (entry.item.getState() !== "progressing") return;
15315
+ try {
15316
+ entry.item.cancel();
15317
+ } catch (error) {
15318
+ this.options.log(
15319
+ `[browser] could not cancel ${path$1.basename(entry.savePath)}: ${describe(error)}`
15320
+ );
15321
+ }
15322
+ if (entry.item.getState() !== "completed") {
15323
+ discardPartial(entry.savePath, this.options.log);
15324
+ this.release(capture, entry.savePath);
15325
+ }
15326
+ this.options.onActivity?.();
15327
+ }
15328
+ /** Cancel every download this capture still has writing. */
15329
+ cancelLive(capture, clause, refusal) {
15330
+ const live = [...capture.live];
15331
+ capture.live = [];
15332
+ for (const entry of live) {
15333
+ if (entry.weCancelled) continue;
15334
+ this.refuseLive(
15335
+ capture,
15336
+ entry,
15337
+ `\`${path$1.basename(entry.savePath)}\` ${clause}`,
15338
+ { ...refusal, bytes: entry.item.getReceivedBytes() }
15339
+ );
15340
+ }
15341
+ }
15342
+ armQuiet(capture) {
15343
+ if (capture.quietTimer) clearTimeout(capture.quietTimer);
15344
+ const timer = setTimeout(
15345
+ () => this.settle(capture),
15346
+ this.options.quietMs ?? QUIET_MS
15347
+ );
15348
+ timer.unref?.();
15349
+ capture.quietTimer = timer;
15350
+ }
15351
+ /**
15352
+ * Answer the call.
15353
+ *
15354
+ * THE TWO ARMS ARE THE DEADLINE AND THE QUIET WINDOW, and the quiet window never
15355
+ * fires with a write in flight (it is only armed at `pending === 0`), so it always
15356
+ * has the finished files Python is about to inspect. A DEADLINE can arrive
15357
+ * mid-write, and it answers anyway — a call that never returns is worse than one
15358
+ * that admits what it could not finish — after cancelling what was still writing,
15359
+ * because that is the one file the harness could not describe honestly.
15360
+ */
15361
+ settle(capture) {
15362
+ if (capture.settled) return;
15363
+ if (capture.pending > 0) {
15364
+ this.cancelLive(
15365
+ capture,
15366
+ `was still being written when the ${Math.round(capture.timeoutMs / 1e3)}s budget expired`,
15367
+ {
15368
+ rule: "deadline",
15369
+ bytes: 0,
15370
+ limit: Math.round(capture.timeoutMs / 1e3)
15371
+ }
15372
+ );
15373
+ }
15374
+ capture.settled = true;
15375
+ capture.active = null;
15376
+ this.clearTimers(capture);
15377
+ capture.finish?.();
15378
+ this.options.onActivity?.();
15379
+ }
15380
+ resultOf(capture) {
15381
+ const reasons = [...capture.refusals];
15382
+ if (capture.files.length === 0 && reasons.length === 0) {
15383
+ const waited = Math.max(
15384
+ 1,
15385
+ Math.round(
15386
+ ((this.options.now ?? Date.now)() - capture.startedAt) / 1e3
15387
+ )
15388
+ );
15389
+ reasons.push(
15390
+ `no download started within ${waited}s; if the page needs a click first, pass a selector, or \`click\` it and retry`
15391
+ );
15392
+ }
15393
+ return {
15394
+ // `armed: false` is a POLICY answer, not a fault (§6.1). The app host always
15395
+ // arms when the harness asks it to and reports true; the harness's own
15396
+ // pre-arm refusals (its per-call checks before dispatching) are the caller of
15397
+ // the other arm.
15398
+ armed: true,
15399
+ files: capture.files,
15400
+ reason: reasons.join("; ")
15401
+ };
15402
+ }
15403
+ refuse(capture, clean, reason, refusal) {
15404
+ capture.refusals.push(reason);
15405
+ this.note(capture, clean, "refused", 1, reason, refusal);
15406
+ this.options.log(`[browser] ${reason}`);
15407
+ return { cancel: true, reason };
15408
+ }
15409
+ note(capture, name, outcome, count, reason, refusal) {
15410
+ this.notes.push({
15411
+ name,
15412
+ count,
15413
+ dir: capture.dir,
15414
+ outcome,
15415
+ reason,
15416
+ at: (this.options.now ?? Date.now)(),
15417
+ direction: "download",
15418
+ tabId: capture.tabId,
15419
+ site: "",
15420
+ refusal,
15421
+ ownerKind: this.ownerKindOf(capture.tabId)
15422
+ });
15423
+ if (outcome === "saved") {
15424
+ this.savedSummary = {
15425
+ name,
15426
+ count: (this.savedSummary?.count ?? 0) + 1
15427
+ };
15428
+ }
15429
+ while (this.notes.length > NOTES_KEPT) this.notes.shift();
15430
+ }
15431
+ clearTimers(capture) {
15432
+ if (capture.quietTimer) clearTimeout(capture.quietTimer);
15433
+ if (capture.deadlineTimer) clearTimeout(capture.deadlineTimer);
15434
+ if (capture.capTimer) clearInterval(capture.capTimer);
15435
+ capture.quietTimer = null;
15436
+ capture.deadlineTimer = null;
15437
+ capture.capTimer = null;
15438
+ }
15439
+ /**
15440
+ * A path in `dir` that neither exists nor has already been PROMISED in this
15441
+ * capture. The page's name is never obeyed beyond its sanitised basename, and
15442
+ * §11.4's rule is "no silent overwrite": the app host uniquifies with
15443
+ * `name (1).ext` rather than letting a second download of the same name replace
15444
+ * the first.
15445
+ *
15446
+ * THE RESERVATION IS THE FIX, and the earlier comment here was simply wrong
15447
+ * (review round 1, Q1/M4). It claimed two downloads of one name on one tab
15448
+ * "cannot race (an arm accepts them one at a time)" — being DECIDED one at a
15449
+ * time is not being CREATED one at a time: Chromium creates the file after
15450
+ * `setSavePath` returns, so two accepted downloads of one name are both in
15451
+ * flight with the disk still empty, both probe the same path, and the second
15452
+ * silently overwrites the first while the result reports two files at one path.
15453
+ * The probe therefore runs against the filesystem AND against the paths this
15454
+ * capture has already handed out.
15455
+ *
15456
+ * It is still not an atomic create, and that is honest rather than sloppy: the
15457
+ * value is handed to Chromium, which opens the file itself, so a
15458
+ * create-and-release probe would only add a window where the file exists empty.
15459
+ * The reservation closes the window the probe could not see.
15460
+ */
15461
+ uniquePath(capture, clean, raw) {
15462
+ const reserved = this.reserved.get(capture.dir);
15463
+ const taken = (path2) => fs.existsSync(path2) || (reserved?.has(path2) ?? false);
15464
+ const first = path$1.join(capture.dir, clean);
15465
+ if (!taken(first)) return first;
15466
+ const dot = clean.lastIndexOf(".");
15467
+ const stem = dot > 0 ? clean.slice(0, dot) : clean;
15468
+ const ext = dot > 0 ? clean.slice(dot + 1) : "";
15469
+ for (let index = 1; index <= 999; index += 1) {
15470
+ const candidate = path$1.join(
15471
+ capture.dir,
15472
+ ext ? `${stem} (${index}).${ext}` : `${stem} (${index})`
15473
+ );
15474
+ if (!taken(candidate)) return candidate;
15475
+ }
15476
+ return path$1.join(capture.dir, safeName(raw, extensionOf(raw) || "bin"));
15477
+ }
15478
+ }
15479
+ function fileFact(item, savePath) {
15480
+ return {
15481
+ name: path$1.basename(savePath),
15482
+ path: savePath,
15483
+ bytes: item.getReceivedBytes(),
15484
+ mime: item.getMimeType() || "",
15485
+ sniffed: "",
15486
+ sha256: ""
15487
+ };
15488
+ }
15489
+ function describe(error) {
15490
+ return error instanceof Error ? error.message : String(error);
15491
+ }
15492
+ function humanBytes(bytes) {
15493
+ const MiB = 1024 * 1024;
15494
+ return `${Math.floor(bytes / MiB)} MiB`;
15495
+ }
15496
+ function discardPartial(path2, log) {
15497
+ try {
15498
+ if (fs.existsSync(path2)) {
15499
+ fs.unlinkSync(path2);
15500
+ log(`[browser] discarded the partial download at ${path$1.basename(path2)}`);
15501
+ }
15502
+ } catch (error) {
15503
+ log(
15504
+ `[browser] could not discard the partial download at ${path$1.basename(path2)}: ${describe(error)}`
15505
+ );
15506
+ }
15507
+ }
15508
+ function restrictMode(path2, log) {
15509
+ try {
15510
+ fs.chmodSync(path2, 384);
15511
+ } catch (error) {
15512
+ log(
15513
+ `[browser] could not restrict ${path$1.basename(path2)} to 0600: ${describe(error)}`
15514
+ );
15515
+ }
15516
+ }
14626
15517
  function requesterOf(params, requestId2) {
14627
15518
  const supplied = typeof params.requester === "string" ? params.requester.trim() : "";
14628
15519
  return supplied.startsWith("session:") ? supplied : requestId2;
@@ -15275,10 +16166,10 @@ async function type(ctx, params) {
15275
16166
  returnByValue: true
15276
16167
  });
15277
16168
  await ctx.cdp.send(contents, "Input.insertText", { text });
15278
- const readBack = await conversationReadBack(ctx, node.objectId, contents);
15279
- if (readBack.includes(text)) {
16169
+ const readBack2 = await conversationReadBack(ctx, node.objectId, contents);
16170
+ if (readBack2.includes(text)) {
15280
16171
  ctx.registry.touch(record);
15281
- return { value: readBack, via: "insert_text", ...pageOf(record.view) };
16172
+ return { value: readBack2, via: "insert_text", ...pageOf(record.view) };
15282
16173
  }
15283
16174
  const set = await ctx.cdp.send(
15284
16175
  contents,
@@ -15292,9 +16183,9 @@ async function type(ctx, params) {
15292
16183
  );
15293
16184
  ctx.registry.touch(record);
15294
16185
  return {
15295
- value: String(set?.result?.value ?? readBack),
16186
+ value: String(set?.result?.value ?? readBack2),
15296
16187
  via: "value_setter",
15297
- insert_text_readback: readBack,
16188
+ insert_text_readback: readBack2,
15298
16189
  ...pageOf(record.view)
15299
16190
  };
15300
16191
  }
@@ -15310,6 +16201,41 @@ async function conversationReadBack(ctx, objectId, contents) {
15310
16201
  );
15311
16202
  return String(out?.result?.value ?? "");
15312
16203
  }
16204
+ async function download(ctx, params) {
16205
+ const record = ctx.registry.requireSurface(params.tab);
16206
+ const dir = stringParam(params, "dir");
16207
+ if (!dir) {
16208
+ throw new BridgeCommandError(
16209
+ "internal",
16210
+ "download needs the harness-composed directory to write into",
16211
+ { param: "dir" }
16212
+ );
16213
+ }
16214
+ const timeoutS = clampTimeout(numberParam(params, "timeout_s"));
16215
+ const selector = stringParam(params, "selector");
16216
+ try {
16217
+ const arm = ctx.downloads.arm(record.tabId, dir, timeoutS * 1e3);
16218
+ if (selector) await click(ctx, params);
16219
+ const result = await arm.done();
16220
+ ctx.registry.touch(record);
16221
+ return {
16222
+ files: result.files,
16223
+ armed: result.armed,
16224
+ reason: result.reason,
16225
+ // The page's own identity, as every other action reports it: a caller that
16226
+ // named a selector wants to know what it was looking at when the file
16227
+ // started, and a click can have moved the page.
16228
+ ...pageOf(record.view)
16229
+ };
16230
+ } finally {
16231
+ ctx.downloads.forget(record.tabId);
16232
+ }
16233
+ }
16234
+ function clampTimeout(requested) {
16235
+ const wanted = requested === void 0 ? CAPS.downloadTimeoutS : requested;
16236
+ if (!Number.isFinite(wanted) || wanted <= 0) return CAPS.downloadTimeoutS;
16237
+ return Math.min(wanted, CAPS.downloadTimeoutMaxS);
16238
+ }
15313
16239
  const MAX_AGENT_TABS = 8;
15314
16240
  const BACKGROUND_VIEWPORT = {
15315
16241
  x: 0,
@@ -15432,6 +16358,24 @@ class TabRegistry {
15432
16358
  (record) => !record.view.webContents.isDestroyed() && record.view.webContents.id === webContentsId
15433
16359
  );
15434
16360
  }
16361
+ /** The tab a webContents belongs to, or null.
16362
+ *
16363
+ * WHY THIS EXISTS: `will-download` is a SESSION-level handler (Electron's own
16364
+ * split, see `profile.ts`), and the only thing it hands the handler that names a
16365
+ * tab is the WebContents that started the download. Without this lookup the
16366
+ * host could not tell an armed tab from an unarmed one, and every download would
16367
+ * have to be refused — which is what it did before the file-transfer feature.
16368
+ *
16369
+ * A linear scan is deliberate: the set is bounded by the agent-tab cap plus the
16370
+ * user's own tabs, it runs once per download, and a second index keyed by
16371
+ * webContents id is one more thing that can go stale when a view dies. */
16372
+ byWebContents(webContentsId) {
16373
+ if (!Number.isSafeInteger(webContentsId)) return null;
16374
+ for (const record of this.tabs.values()) {
16375
+ if (record.view.webContents.id === webContentsId) return record;
16376
+ }
16377
+ return null;
16378
+ }
15435
16379
  get activeTab() {
15436
16380
  return this.activeTabId === null ? null : this.tabs.get(this.activeTabId) ?? null;
15437
16381
  }
@@ -15964,6 +16908,202 @@ function retitle() {
15964
16908
  reason: "the browser tab is labelled with the page title"
15965
16909
  };
15966
16910
  }
16911
+ const READ_FILES_FUNCTION = `function () {
16912
+ if (!("files" in this)) return null;
16913
+ const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "files");
16914
+ let files;
16915
+ try { files = descriptor && descriptor.get ? descriptor.get.call(this) : this.files; }
16916
+ catch (err) { return null; }
16917
+ if (!files) return null;
16918
+ const list = Array.from(files);
16919
+ return { count: list.length, names: list.map((f) => f.name), sizes: list.map((f) => f.size) };
16920
+ }`;
16921
+ const NOT_A_FILE_INPUT = 'that selector is not a file input; snapshot the page and use the element that <input type="file"> names';
16922
+ const CONTEXT_GONE = [
16923
+ "Cannot find context with specified id",
16924
+ "Cannot find execution context",
16925
+ "Execution context was destroyed",
16926
+ "Inspected target navigated or closed"
16927
+ ];
16928
+ const NODE_GONE = [
16929
+ "Node with given id does not belong to the document",
16930
+ "No node with given id found",
16931
+ "Could not find node with given id"
16932
+ ];
16933
+ function isContextGone(error) {
16934
+ const message = error instanceof Error ? error.message : String(error);
16935
+ return CONTEXT_GONE.some((marker) => message.includes(marker));
16936
+ }
16937
+ function isNodeGone(error) {
16938
+ const message = error instanceof Error ? error.message : String(error);
16939
+ return NODE_GONE.some((marker) => message.includes(marker));
16940
+ }
16941
+ function describeError(error) {
16942
+ const message = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim();
16943
+ return message.slice(0, 120) || "no detail";
16944
+ }
16945
+ async function upload(ctx, params) {
16946
+ const record = ctx.registry.requireSurface(params.tab);
16947
+ const selector = stringParam(params, "selector");
16948
+ if (!selector) {
16949
+ throw new BridgeCommandError(
16950
+ "element_not_found",
16951
+ "upload needs a selector naming the file input"
16952
+ );
16953
+ }
16954
+ const paths = uploadPaths(params);
16955
+ const contents = record.view.webContents;
16956
+ const node = await resolveNode(ctx, record, selector);
16957
+ const accept = await acceptsOf(ctx, record, node.nodeId);
16958
+ await ctx.cdp.send(contents, "DOM.setFileInputFiles", {
16959
+ files: paths,
16960
+ nodeId: node.nodeId
16961
+ }).catch((error) => {
16962
+ if (!/not a file input/i.test(String(error))) throw error;
16963
+ throw new BridgeCommandError("element_not_found", NOT_A_FILE_INPUT, {
16964
+ selector,
16965
+ accept
16966
+ });
16967
+ });
16968
+ let held = null;
16969
+ let readback = "";
16970
+ try {
16971
+ held = await readBack(ctx, record, node.objectId);
16972
+ } catch (error) {
16973
+ readback = isContextGone(error) ? "unavailable — the page replaced its document from the change event before the input could be read back" : isNodeGone(error) ? "unavailable — the input was replaced or removed before it could be read back" : `unavailable — the read-back failed (${describeError(error)})`;
16974
+ }
16975
+ if (!readback) {
16976
+ if (!held) {
16977
+ throw new BridgeCommandError("element_not_found", NOT_A_FILE_INPUT, {
16978
+ selector,
16979
+ accept
16980
+ });
16981
+ }
16982
+ assertHolds(paths, held, selector, accept);
16983
+ }
16984
+ ctx.registry.touch(record);
16985
+ const facts = paths.map(factOf);
16986
+ const page = pageOf(record.view);
16987
+ ctx.downloads.noteUpload(record.tabId, facts, siteOf(page.url));
16988
+ return {
16989
+ // The selectors that accepted files. One entry today, because the wire takes
16990
+ // one `selector`; the field is a list because the result vocabulary is shared
16991
+ // with the extension host, whose `DOM.setFileInputFiles` call can address the
16992
+ // same shape.
16993
+ inputs: [selector],
16994
+ accepted: facts,
16995
+ // The host's own word about its read: "" when it completed, a sentence when it
16996
+ // could not. The harness reports the attach as UNVERIFIED when this is set
16997
+ // (`tools/builtin.py`: `verified = count >= 0 and not readback_reported`) rather
16998
+ // than turning a completed egress into an error.
16999
+ readback,
17000
+ // The page's own identity, as every other action reports it: the harness's audit
17001
+ // row for this call records the origin the files went TO, and a host that answered
17002
+ // without it would leave that row naming nowhere.
17003
+ ...page
17004
+ };
17005
+ }
17006
+ function uploadPaths(params) {
17007
+ const raw = params.paths;
17008
+ if (!Array.isArray(raw) || raw.length === 0) {
17009
+ throw new BridgeCommandError(
17010
+ "internal",
17011
+ "upload needs a non-empty list of paths",
17012
+ { param: "paths" }
17013
+ );
17014
+ }
17015
+ const paths = [];
17016
+ for (const entry of raw) {
17017
+ if (typeof entry !== "string" || !entry.trim()) {
17018
+ throw new BridgeCommandError(
17019
+ "internal",
17020
+ "every upload path must be a non-empty string",
17021
+ { param: "paths" }
17022
+ );
17023
+ }
17024
+ const path2 = entry.trim();
17025
+ if (!path$1.isAbsolute(path2)) {
17026
+ throw new BridgeCommandError(
17027
+ "internal",
17028
+ `upload needs absolute paths; ${safeName(path2)} is not one`,
17029
+ { param: "paths" }
17030
+ );
17031
+ }
17032
+ paths.push(path2);
17033
+ }
17034
+ if (paths.length > CAPS.uploadMaxFiles) {
17035
+ throw new BridgeCommandError(
17036
+ "internal",
17037
+ `upload takes at most ${CAPS.uploadMaxFiles} files per call`,
17038
+ { param: "paths", limit: CAPS.uploadMaxFiles }
17039
+ );
17040
+ }
17041
+ return paths;
17042
+ }
17043
+ async function acceptsOf(ctx, record, nodeId) {
17044
+ const attributes = await ctx.cdp.send(
17045
+ record.view.webContents,
17046
+ "DOM.getAttributes",
17047
+ { nodeId }
17048
+ );
17049
+ const list = attributes?.attributes ?? [];
17050
+ for (let index = 0; index + 1 < list.length; index += 2) {
17051
+ if (list[index] === "accept") return list[index + 1] ?? "";
17052
+ }
17053
+ return "";
17054
+ }
17055
+ async function readBack(ctx, record, objectId) {
17056
+ const answer = await ctx.cdp.send(record.view.webContents, "Runtime.callFunctionOn", {
17057
+ objectId,
17058
+ functionDeclaration: READ_FILES_FUNCTION,
17059
+ returnByValue: true
17060
+ });
17061
+ return answer?.result?.value ?? null;
17062
+ }
17063
+ function assertHolds(paths, held, selector, accept) {
17064
+ const expected = paths.map((path2) => path$1.basename(path2));
17065
+ const mismatch = held.count !== expected.length || expected.some((name, index) => held.names[index] !== name) || paths.some((path2, index) => sizeOf(path2) !== held.sizes[index]);
17066
+ if (!mismatch) return;
17067
+ throw new BridgeCommandError(
17068
+ "internal",
17069
+ `the file input did not take the files: it holds ${summarise(held)}, and ${expected.length} were attached (${expected.join(", ")})`,
17070
+ {
17071
+ selector,
17072
+ attached: expected,
17073
+ held: held.names,
17074
+ accept,
17075
+ reason: "read_back_mismatch"
17076
+ }
17077
+ );
17078
+ }
17079
+ function summarise(held) {
17080
+ if (held.count === 0) return "nothing";
17081
+ return `${held.count} file(s) [${held.names.join(", ")}]`;
17082
+ }
17083
+ function sizeOf(path2) {
17084
+ try {
17085
+ return fs.statSync(path2).size;
17086
+ } catch {
17087
+ return -1;
17088
+ }
17089
+ }
17090
+ function siteOf(url) {
17091
+ try {
17092
+ return new URL(url).host;
17093
+ } catch {
17094
+ return "";
17095
+ }
17096
+ }
17097
+ function factOf(path2) {
17098
+ return {
17099
+ name: path$1.basename(path2),
17100
+ path: path2,
17101
+ bytes: sizeOf(path2),
17102
+ mime: "",
17103
+ sniffed: "",
17104
+ sha256: ""
17105
+ };
17106
+ }
15967
17107
  const DOCUMENT_SCOPED = /* @__PURE__ */ new Set([
15968
17108
  "read",
15969
17109
  "snapshot",
@@ -15971,7 +17111,23 @@ const DOCUMENT_SCOPED = /* @__PURE__ */ new Set([
15971
17111
  "click",
15972
17112
  "type",
15973
17113
  "scroll",
15974
- "logs"
17114
+ "logs",
17115
+ // `download` names a control on the CURRENT document (the link or button that
17116
+ // starts the file) and `upload` names a file input on it, so both are authorized
17117
+ // against that document on entry and again on the result. BOTH are in the
17118
+ // NAVIGATING_ACTIONS set below — each reaches its own control by driving the page,
17119
+ // and an auto-submitting form turns an attach into a navigation — and NEITHER is
17120
+ // in NAVIGATION_ACTIONS (the per-hop gate stays off for both); the set comments
17121
+ // say why, and the pair is what replaced the single post-hoc check the round-1
17122
+ // review refused (M2).
17123
+ "download",
17124
+ "upload"
17125
+ ]);
17126
+ const NAVIGATING_ACTIONS = /* @__PURE__ */ new Set([
17127
+ "click",
17128
+ "type",
17129
+ "download",
17130
+ "upload"
15975
17131
  ]);
15976
17132
  const NAVIGATION_ACTIONS = /* @__PURE__ */ new Set(["click", "type"]);
15977
17133
  const TAB_SCOPED = /* @__PURE__ */ new Set([
@@ -15984,7 +17140,14 @@ const TAB_SCOPED = /* @__PURE__ */ new Set([
15984
17140
  "scroll",
15985
17141
  "logs",
15986
17142
  "close",
15987
- "retitle"
17143
+ "retitle",
17144
+ // The two file verbs take the tab's lane for the same reason every other
17145
+ // tab-addressed action does, and for one more: `download` ARMS the tab for the
17146
+ // duration of the call, and two concurrent arms on one tab would be two captures
17147
+ // writing into two harness directories with no way to tell which file came from
17148
+ // which call.
17149
+ "download",
17150
+ "upload"
15988
17151
  ]);
15989
17152
  const RESTORE_CONCURRENCY = 4;
15990
17153
  const RESTORE_TAB_TIMEOUT_MS = 1e4;
@@ -16021,6 +17184,7 @@ class BrowserHost {
16021
17184
  cdp;
16022
17185
  approvals;
16023
17186
  ownership;
17187
+ downloads;
16024
17188
  log;
16025
17189
  onChanged;
16026
17190
  facts;
@@ -16044,6 +17208,7 @@ class BrowserHost {
16044
17208
  this.cdp = options.cdp;
16045
17209
  this.approvals = options.approvals;
16046
17210
  this.ownership = options.ownership;
17211
+ this.downloads = options.downloads;
16047
17212
  this.log = options.log;
16048
17213
  this.onChanged = options.onChanged;
16049
17214
  this.facts = options.facts;
@@ -16069,10 +17234,11 @@ class BrowserHost {
16069
17234
  const record = this.registry.requireSurface(token);
16070
17235
  const requester = requesterOf(params, requestId2);
16071
17236
  let authorizedOn = record.documentEpoch;
17237
+ const entryUrl = record.view.webContents.getURL();
16072
17238
  const assertDocument = () => {
16073
17239
  const url = new URL(record.view.webContents.getURL() || "about:blank");
16074
17240
  if (record.documentEpoch !== authorizedOn) {
16075
- if (!NAVIGATION_ACTIONS.has(method)) {
17241
+ if (!NAVIGATING_ACTIONS.has(method)) {
16076
17242
  this.registry.bumpEpoch(record.tabId);
16077
17243
  throw new BridgeCommandError(
16078
17244
  "origin_not_allowed",
@@ -16084,6 +17250,9 @@ class BrowserHost {
16084
17250
  }
16085
17251
  if (!permittedScheme(url) || !this.approvals.documentAllowed(token, url, requester, authorizedOn)) {
16086
17252
  this.registry.bumpEpoch(record.tabId);
17253
+ if (NAVIGATING_ACTIONS.has(method) && url.href !== entryUrl) {
17254
+ this.restoreApprovedDocument(record, entryUrl);
17255
+ }
16087
17256
  this.approvals.refuseDocument(url);
16088
17257
  }
16089
17258
  return url;
@@ -16109,6 +17278,22 @@ class BrowserHost {
16109
17278
  operation
16110
17279
  );
16111
17280
  }
17281
+ /** Put a tab back on the document a call entered on, after that call's work
17282
+ * landed it somewhere it may not hold.
17283
+ *
17284
+ * The restore is a plain load of an already-authorized URL, and it is deliberately
17285
+ * NOT awaited: the caller is on its way to a thrown refusal, and the answer the
17286
+ * model reads is about the refusal rather than about the repaint. A view that died
17287
+ * under the call needs no restore and must not turn into a second error. */
17288
+ restoreApprovedDocument(record, url) {
17289
+ if (!url || record.view.webContents.isDestroyed()) return;
17290
+ const restore = record.view.webContents.loadURL(url);
17291
+ void Promise.resolve(restore).catch((error) => {
17292
+ this.log(
17293
+ `[browser] could not restore tab ${record.tabId} to ${url} after refusing a navigation: ${String(error)}`
17294
+ );
17295
+ });
17296
+ }
16112
17297
  async perform(method, params, requestId2) {
16113
17298
  switch (method) {
16114
17299
  case "open":
@@ -16137,6 +17322,10 @@ class BrowserHost {
16137
17322
  return click(this, params);
16138
17323
  case "type":
16139
17324
  return type(this, params);
17325
+ case "download":
17326
+ return download(this, params);
17327
+ case "upload":
17328
+ return upload(this, params);
16140
17329
  case "request_access":
16141
17330
  return this.approvals.requestAccess(
16142
17331
  params.url,
@@ -16314,6 +17503,14 @@ class BrowserHost {
16314
17503
  loading: this.tabLoading(entry.tabId)
16315
17504
  })),
16316
17505
  activeTabId: activeRecord?.tabId ?? null,
17506
+ // The download surface's facts (§16.4): what is in flight, where it went and
17507
+ // what was refused, so the chrome row renders from the host's own state rather
17508
+ // than from a second copy the renderer would have to keep in step. Shipped in
17509
+ // the same projection as the strip for the reason the approvals list is: one
17510
+ // subscription is one thing that can go stale. FOR ONE TAB (the active one),
17511
+ // which is the round-1 D2 fix: a host-wide projection rendered a decision taken
17512
+ // in another tab into this one's chrome.
17513
+ transfers: this.downloads.activityFor(active?.tabId ?? null),
16317
17514
  url: active ? active.view.webContents.getURL() : "",
16318
17515
  title: active ? this.titleForChrome(active.view.webContents.getTitle()) : "",
16319
17516
  loading: active ? active.view.webContents.isLoading() : false,
@@ -16733,7 +17930,8 @@ const BROWSER_IPC_CHANNELS = [
16733
17930
  "browser-forget-site",
16734
17931
  "browser-clear-data",
16735
17932
  "browser-webauthn-respond",
16736
- "browser-webauthn-pending"
17933
+ "browser-webauthn-pending",
17934
+ "browser-reveal-downloads"
16737
17935
  ];
16738
17936
  function registerBrowserIpc(options) {
16739
17937
  function authorize(event) {
@@ -16862,6 +18060,15 @@ function registerBrowserIpc(options) {
16862
18060
  options.log(`[browser] cleared browsing data: ${what}`);
16863
18061
  return { cleared: what };
16864
18062
  });
18063
+ electron.ipcMain.handle("browser-reveal-downloads", async (event) => {
18064
+ authorize(event);
18065
+ const message = await options.revealDownloads();
18066
+ if (message)
18067
+ options.log(
18068
+ `[browser] could not reveal the download directory: ${message}`
18069
+ );
18070
+ return { opened: message === "", message };
18071
+ });
16865
18072
  }
16866
18073
  function unregisterBrowserIpc() {
16867
18074
  for (const channel of BROWSER_IPC_CHANNELS) electron.ipcMain.removeHandler(channel);
@@ -17250,15 +18457,26 @@ function installBrowserSessionHandlers(browserSession, hooks = {}) {
17250
18457
  return false;
17251
18458
  }
17252
18459
  );
17253
- browserSession.on("will-download", (event, item) => {
17254
- event.preventDefault();
17255
- hooks.onDownloadAttempted?.({
17256
- webContentsId: -1,
17257
- url: item.getURL()
17258
- });
17259
- hooks.log?.(
17260
- `[browser] cancelled a download from ${item.getURL()}: background downloads are not supported`
17261
- );
18460
+ browserSession.on("will-download", (event, item, webContents) => {
18461
+ const webContentsId = webContents?.id ?? -1;
18462
+ const decision = hooks.onDownload?.(item, webContentsId) ?? {
18463
+ cancel: true,
18464
+ reason: "background downloads are not supported"
18465
+ };
18466
+ if (decision.cancel) event.preventDefault();
18467
+ const outcome = {
18468
+ webContentsId,
18469
+ url: item.getURL(),
18470
+ filename: item.getFilename(),
18471
+ savePath: decision.cancel ? null : item.getSavePath(),
18472
+ reason: decision.reason
18473
+ };
18474
+ if (decision.cancel) {
18475
+ hooks.log?.(
18476
+ `[browser] cancelled a download from ${outcome.url}: ${decision.reason}`
18477
+ );
18478
+ }
18479
+ hooks.onDownloadDecided?.(outcome);
17262
18480
  });
17263
18481
  }
17264
18482
  async function clearBrowsingData(browserSession, what) {
@@ -17440,7 +18658,12 @@ async function handle(req, res, options) {
17440
18658
  // rest, and a newer one learns whether the console is up without a
17441
18659
  // second probe (design 10.1). `false` when nobody reported a
17442
18660
  // capability, which is the honest answer for a host that has none.
17443
- console: options.capabilities?.().console ?? false
18661
+ console: options.capabilities?.().console ?? false,
18662
+ // Additive, and the SECOND reader of the same fact (§6.3): a session that
18663
+ // finds a record it cannot trust asks here, and an old reader ignores the
18664
+ // key. It names what this build SERVES, which is what makes a typed
18665
+ // `capability_unsupported` possible without opening a socket.
18666
+ capabilities: [...HOST_CAPABILITIES]
17444
18667
  };
17445
18668
  send(res, 200, body);
17446
18669
  return;
@@ -18562,14 +19785,34 @@ function browserHostEnabled(env = process.env) {
18562
19785
  async function startBrowserHost(options) {
18563
19786
  const { log } = options;
18564
19787
  const browserSession = resolveBrowserSession();
19788
+ let registryForDownloads = null;
19789
+ const downloads = new DownloadArmer({
19790
+ tabForWebContents: (webContentsId) => registryForDownloads?.byWebContents(webContentsId)?.tabId ?? null,
19791
+ // WHO OWNED THE TAB, recorded on the decision (review round 2, U10). Read from
19792
+ // the registry at the moment the note is written, because the row may render it
19793
+ // after the tab is closed — and "· on another tab" about a tab that no longer
19794
+ // exists is a marker pointing at nothing. `null` when the lookup cannot answer
19795
+ // (a tab already gone), which the row renders the old way rather than guessing.
19796
+ ownerKindFor: (tabId) => registryForDownloads?.get(tabId)?.owner ?? null,
19797
+ log,
19798
+ onActivity: () => {
19799
+ if (options.window.isDestroyed()) return;
19800
+ options.window.webContents.send("browser-state-changed");
19801
+ }
19802
+ });
18565
19803
  installBrowserSessionHandlers(browserSession, {
18566
19804
  onPermissionRequested: (details) => {
18567
19805
  log(
18568
19806
  `[browser] denied a ${details.permission} permission request from ${details.origin || "(unknown origin)"}`
18569
19807
  );
18570
19808
  },
18571
- onDownloadAttempted: (details) => {
18572
- log(`[browser] refused a download from ${details.url}`);
19809
+ onDownload: (item, webContentsId) => downloads.decide(item, webContentsId),
19810
+ onDownloadDecided: (outcome) => {
19811
+ if (outcome.savePath) {
19812
+ log(
19813
+ `[browser] saved a download from ${outcome.url} to ${outcome.savePath}`
19814
+ );
19815
+ }
18573
19816
  },
18574
19817
  log
18575
19818
  });
@@ -18668,9 +19911,11 @@ async function startBrowserHost(options) {
18668
19911
  releaseTab,
18669
19912
  notifyChanged
18670
19913
  );
19914
+ registryForDownloads = registry;
18671
19915
  function releaseTab(tabId, webContentsId) {
18672
19916
  const view = views.get(tabId);
18673
19917
  views.delete(tabId);
19918
+ downloads.forget(tabId);
18674
19919
  void cdp.detach(webContentsId).finally(() => {
18675
19920
  releaseView(options.window, view);
18676
19921
  });
@@ -18720,6 +19965,7 @@ async function startBrowserHost(options) {
18720
19965
  cdp,
18721
19966
  approvals,
18722
19967
  ownership,
19968
+ downloads,
18723
19969
  log,
18724
19970
  onChanged: notifyChanged,
18725
19971
  facts: () => facts,
@@ -18782,6 +20028,17 @@ async function startBrowserHost(options) {
18782
20028
  host: () => host,
18783
20029
  webauthn: () => webauthn,
18784
20030
  clearData: (what) => sessionCookies.clearBrowsingData(what),
20031
+ // THE REVEAL TAKES NO PATH FROM THE RENDERER (§16.4): it opens the directory the
20032
+ // host is actually writing into, so the one place a page-derived string could
20033
+ // have become a path stays out of the IPC surface as well. `shell.openPath`
20034
+ // answers "" on success and a message on failure, which is returned rather than
20035
+ // thrown: a reveal that fails is a Finder problem, not a fault in the agent's
20036
+ // download.
20037
+ revealDownloads: async () => {
20038
+ const dir = downloads.downloadDir();
20039
+ if (!dir) return "no download directory yet";
20040
+ return electron.shell.openPath(dir);
20041
+ },
18785
20042
  log
18786
20043
  });
18787
20044
  log(
@@ -20610,6 +21867,232 @@ function clauseTextOf(message, start, end) {
20610
21867
  function stripErrorPrefixes(message) {
20611
21868
  return message.replace(LEADING_ERROR_PREFIXES, "").replace(EMBEDDED_ERROR_PREFIX, ": ");
20612
21869
  }
21870
+ const FLEET_DRAIN_BUDGET_MS = 6e5;
21871
+ const FLEET_DRAIN_POLL_MS = 5e3;
21872
+ const FLEET_RETIRE_GRACE_MS = 6e4;
21873
+ const FLEET_RETIRE_SETTLE_MS = 3e4;
21874
+ async function waitForFleetIdle(input) {
21875
+ const budgetMs = input.budgetMs ?? FLEET_DRAIN_BUDGET_MS;
21876
+ const pollMs = input.pollMs ?? FLEET_DRAIN_POLL_MS;
21877
+ const startedAt = input.now();
21878
+ let workState = await input.readWorkState();
21879
+ let waitedMs = 0;
21880
+ while (workState !== "idle") {
21881
+ waitedMs = input.now() - startedAt;
21882
+ if (waitedMs >= budgetMs) {
21883
+ const unknown = workState === "unknown";
21884
+ const refusedCredentials = unknown && input.readUnreadableReason?.() === "refused-credentials";
21885
+ return {
21886
+ kind: "refused",
21887
+ because: unknown ? "unknown" : "busy",
21888
+ waitedMs,
21889
+ busy: busyRosterRows(await input.readRoster() ?? []),
21890
+ credentialsRefused: refusedCredentials
21891
+ };
21892
+ }
21893
+ input.onWait?.(waitedMs, workState);
21894
+ await input.sleep(Math.min(pollMs, budgetMs - waitedMs));
21895
+ workState = await input.readWorkState();
21896
+ }
21897
+ return {
21898
+ kind: "drained",
21899
+ waitedMs: input.now() - startedAt,
21900
+ fleet: (await input.readRoster())?.length ?? 0
21901
+ };
21902
+ }
21903
+ function fleetDrainRefusalSentence(outcome, installLanded = false) {
21904
+ const minutes = Math.max(1, Math.round(outcome.waitedMs / 6e4));
21905
+ const waited = `${minutes} minute${minutes === 1 ? "" : "s"}`;
21906
+ const closing = installLanded ? "The install itself has landed, and the server keeps running the build it loaded until it can restart onto it; the app will offer this update again." : "Nothing was installed and the server keeps running the build it loaded; the app will offer this update again.";
21907
+ if (outcome.because === "unknown") {
21908
+ const lead2 = outcome.credentialsRefused ? "The server refused this app's credentials, so the app could not read which sessions are running on this machine." : "The app could not read which sessions are running on this machine, so it did not update the server.";
21909
+ return `${lead2}
21910
+
21911
+ Reading an unreadable fleet as idle could cut off a turn that is in flight, so the app waited ${waited} and then stopped. ${closing}`;
21912
+ }
21913
+ const names = outcome.busy.slice(0, 3).map((row) => row.name || row.sessionId).join(", ");
21914
+ const more = outcome.busy.length > 3 ? ` and ${outcome.busy.length - 3} more` : "";
21915
+ const lead = outcome.busy.length === 0 ? "The app could not name the sessions that were still working." : `${outcome.busy.length} session${outcome.busy.length === 1 ? " is" : "s are"} still running a turn on this machine: ${names}${more}.`;
21916
+ return `${lead}
21917
+
21918
+ The app waited ${waited} for them to finish and then stopped rather than cut a turn short. ${closing}`;
21919
+ }
21920
+ const isLiveRow = (row) => row.liveState !== "" && row.liveState !== null && row.sessionId !== "";
21921
+ const sessionHasRuntime = (rows, sessionId2) => {
21922
+ if (rows === null) return null;
21923
+ return rows.some((row) => row.sessionId === sessionId2 && isLiveRow(row));
21924
+ };
21925
+ const liveRosterIds = (rows) => {
21926
+ if (rows === null) return null;
21927
+ return new Set(rows.filter(isLiveRow).map((row) => row.sessionId));
21928
+ };
21929
+ function displacedSessions(before, after) {
21930
+ const liveNow = liveRosterIds(after);
21931
+ if (liveNow === null) return [];
21932
+ return before.filter((row) => isLiveRow(row) && !liveNow.has(row.sessionId));
21933
+ }
21934
+ function unionFleetSnapshots(first, second) {
21935
+ if (first === null) return second === null ? null : [...second];
21936
+ if (second === null) return [...first];
21937
+ const seen = new Set(first.map((row) => row.sessionId));
21938
+ return [...first, ...second.filter((row) => !seen.has(row.sessionId))];
21939
+ }
21940
+ async function reengageDisplacedSessions(input) {
21941
+ const graceMs = input.graceMs ?? FLEET_RETIRE_GRACE_MS;
21942
+ const settleMs = input.settleMs ?? FLEET_RETIRE_SETTLE_MS;
21943
+ const pollMs = input.retirePollMs ?? FLEET_DRAIN_POLL_MS;
21944
+ const startedAt = input.now();
21945
+ const snapshot2 = input.before.filter(isLiveRow);
21946
+ if (snapshot2.length === 0)
21947
+ return { displaced: [], engaged: [], failed: [], stillResident: [] };
21948
+ const snapshotIds = snapshot2.map((row) => row.sessionId);
21949
+ const stillLiveIn = (rows) => {
21950
+ const live = liveRosterIds(rows);
21951
+ if (live === null) return null;
21952
+ return new Set(snapshotIds.filter((id2) => live.has(id2)));
21953
+ };
21954
+ let after = await input.readRoster();
21955
+ let stillLive = stillLiveIn(after) ?? new Set(snapshotIds);
21956
+ let lastChangeAt = input.now();
21957
+ let waveMoved = stillLive.size < snapshotIds.length;
21958
+ for (; ; ) {
21959
+ const elapsed = input.now() - startedAt;
21960
+ if (elapsed >= graceMs) break;
21961
+ if (stillLive.size === 0) break;
21962
+ if (waveMoved && input.now() - lastChangeAt >= settleMs) break;
21963
+ await input.sleep(Math.min(pollMs, Math.max(1, graceMs - elapsed)));
21964
+ after = await input.readRoster();
21965
+ const next = stillLiveIn(after);
21966
+ if (next === null) continue;
21967
+ if (next.size !== stillLive.size || [...next].some((id2) => !stillLive.has(id2))) {
21968
+ stillLive = next;
21969
+ lastChangeAt = input.now();
21970
+ waveMoved = true;
21971
+ }
21972
+ }
21973
+ const finalRead = await input.readRoster();
21974
+ if (finalRead !== null) after = finalRead;
21975
+ const displaced = displacedSessions(input.before, after);
21976
+ const stillResident = snapshot2.filter((row) => stillLive.has(row.sessionId));
21977
+ const engaged = [];
21978
+ const failed = [];
21979
+ for (const row of displaced) {
21980
+ try {
21981
+ if (await input.engage(row)) engaged.push(row.sessionId);
21982
+ else
21983
+ failed.push({
21984
+ sessionId: row.sessionId,
21985
+ reason: "the server did not take the engage"
21986
+ });
21987
+ } catch (error) {
21988
+ failed.push({
21989
+ sessionId: row.sessionId,
21990
+ reason: error instanceof Error ? error.message : String(error)
21991
+ });
21992
+ }
21993
+ }
21994
+ input.log?.(
21995
+ `Re-engaged ${engaged.length} of ${displaced.length} displaced session(s)${failed.length > 0 ? `; ${failed.length} did not answer (${failed.map((row) => row.sessionId).join(", ")})` : ""}${stillResident.length > 0 ? `; ${stillResident.length} pre-swap runtime(s) still resident when the wait ended (${stillResident.map((row) => row.sessionId).join(", ")}), which nothing re-engages afterwards and which go cold on their own schedule` : ""}`
21996
+ );
21997
+ return { displaced, engaged, failed, stillResident };
21998
+ }
21999
+ const SESSION_ENGAGE_OPEN_MS = 5e3;
22000
+ const SESSION_ENGAGE_BEAT_MS = 5e3;
22001
+ const SESSION_ENGAGE_HOLD_MS = 2e4;
22002
+ const subscriptionIdIn = (frame) => {
22003
+ try {
22004
+ const parsed = JSON.parse(frame);
22005
+ if (typeof parsed !== "object" || parsed === null) return null;
22006
+ const payload = parsed.payload;
22007
+ if (typeof payload !== "object" || payload === null) return null;
22008
+ const id2 = payload.subscription_id;
22009
+ return typeof id2 === "string" && id2 !== "" ? id2 : null;
22010
+ } catch {
22011
+ return null;
22012
+ }
22013
+ };
22014
+ async function engageSessionThroughStream(input) {
22015
+ const openMs = input.openMs ?? SESSION_ENGAGE_OPEN_MS;
22016
+ const beatMs = input.beatMs ?? SESSION_ENGAGE_BEAT_MS;
22017
+ const holdMs = input.holdMs ?? SESSION_ENGAGE_HOLD_MS;
22018
+ const startedAt = input.now();
22019
+ let subscriptionId = null;
22020
+ let streamError = null;
22021
+ const handle2 = input.subscribe(input.sessionId, (event) => {
22022
+ if (event.kind === "data") {
22023
+ subscriptionId ??= subscriptionIdIn(event.data);
22024
+ return;
22025
+ }
22026
+ if (event.kind === "error") streamError ??= event.detail;
22027
+ else streamError ??= "the session's events stream ended";
22028
+ });
22029
+ try {
22030
+ while (subscriptionId === null && input.now() - startedAt < openMs) {
22031
+ if (streamError !== null) break;
22032
+ await input.sleep(
22033
+ Math.min(beatMs, Math.max(1, openMs - (input.now() - startedAt)))
22034
+ );
22035
+ }
22036
+ if (subscriptionId === null) {
22037
+ return {
22038
+ engaged: false,
22039
+ reason: streamError ?? "the session's events stream never announced a subscription",
22040
+ beats: 0
22041
+ };
22042
+ }
22043
+ const lease = await input.watch(subscriptionId);
22044
+ if (lease.status !== 200) {
22045
+ return {
22046
+ engaged: false,
22047
+ reason: `the lease answered ${lease.status}`,
22048
+ beats: 0
22049
+ };
22050
+ }
22051
+ const warm = await input.warm(input.sessionId);
22052
+ if (warm.status !== 200) {
22053
+ input.log?.(
22054
+ `The warm for ${input.sessionId} answered ${warm.status}; the lease is still held while the runtime comes up`
22055
+ );
22056
+ }
22057
+ let beats = 0;
22058
+ let cameUp = false;
22059
+ let leaseHeld = true;
22060
+ const leaseAt = input.now();
22061
+ while (input.now() - startedAt < openMs + holdMs) {
22062
+ const live = await input.hasRuntime();
22063
+ if (live === true) {
22064
+ cameUp = true;
22065
+ break;
22066
+ }
22067
+ await input.sleep(
22068
+ Math.min(
22069
+ beatMs,
22070
+ Math.max(1, openMs + holdMs - (input.now() - startedAt))
22071
+ )
22072
+ );
22073
+ if (streamError !== null) {
22074
+ if (leaseHeld) {
22075
+ leaseHeld = false;
22076
+ input.log?.(
22077
+ `The lease for ${input.sessionId} was withdrawn when its stream ended (${streamError}); the roster read still decides whether a runtime came up`
22078
+ );
22079
+ }
22080
+ continue;
22081
+ }
22082
+ await input.watch(subscriptionId);
22083
+ beats += 1;
22084
+ }
22085
+ if (cameUp) return { engaged: true, beats };
22086
+ const heldSeconds = Math.round((input.now() - leaseAt) / 1e3);
22087
+ return {
22088
+ engaged: false,
22089
+ reason: streamError === null ? `no runtime for ${input.sessionId} ${heldSeconds}s after the lease` : `the session's events stream ended ${heldSeconds}s into the hold (${streamError}), so the lease was withdrawn before the runtime could be asked for`,
22090
+ beats
22091
+ };
22092
+ } finally {
22093
+ input.unsubscribe(handle2.streamId);
22094
+ }
22095
+ }
20613
22096
  const GROUP_SIGNAL_GRACE_MS = 5e3;
20614
22097
  const GROUP_EXIT_WAIT_MS = 2e4;
20615
22098
  function isInstallGroupAlive(pid) {
@@ -20936,8 +22419,8 @@ function readPlistValue(plistPath, key) {
20936
22419
  }
20937
22420
  function readStagedBundle(appPath) {
20938
22421
  const plistPath = path$1.join(appPath, "Contents", "Info.plist");
20939
- const executableName = readPlistValue(plistPath, "CFBundleExecutable");
20940
- const executablePath = executableName == null ? null : path$1.join(appPath, "Contents", "MacOS", executableName);
22422
+ const executableName2 = readPlistValue(plistPath, "CFBundleExecutable");
22423
+ const executablePath = executableName2 == null ? null : path$1.join(appPath, "Contents", "MacOS", executableName2);
20941
22424
  const architectures2 = executablePath != null && fs.existsSync(executablePath) ? (readCommandOutput("/usr/bin/lipo", ["-archs", executablePath]) ?? "").split(ARCHITECTURE_SEPARATOR_REGEX).filter((entry) => entry.length > 0) : null;
20942
22425
  return {
20943
22426
  appPath,
@@ -21439,6 +22922,53 @@ class UpdateService {
21439
22922
  * the handler).
21440
22923
  */
21441
22924
  installPreflightInFlight = false;
22925
+ /**
22926
+ * How long an update press may hold back for the fleet, and how often it
22927
+ * re-reads it.
22928
+ *
22929
+ * FIELDS rather than the module's constants read in place, because both halves
22930
+ * are waited on inside one press and the harness has to be able to drive a wait
22931
+ * in milliseconds instead of ten minutes (`scripts/update-robustness.test.mjs`,
22932
+ * the same lever `waitForBackendVersion` and the install budgets already are).
22933
+ * The values they start at are the host tool's own, in
22934
+ * `backend/fleet-drain.ts`, where the reasoning for the ten minutes lives.
22935
+ */
22936
+ fleetDrainBudgetMs = FLEET_DRAIN_BUDGET_MS;
22937
+ fleetDrainPollMs = FLEET_DRAIN_POLL_MS;
22938
+ /**
22939
+ * How long a displaced runtime gets to retire on its own before the app puts
22940
+ * its successor back, and how long the pre-swap live set must hold still first.
22941
+ *
22942
+ * Fields for the reason the pair above are: a service-level case has to drive
22943
+ * the wait in milliseconds instead of the harness's real convergence window
22944
+ * (30 s of settle inside a 60 s grace), and the numbers themselves - with the
22945
+ * harness constants they come from - are argued in `backend/fleet-drain.ts`.
22946
+ */
22947
+ fleetRetireGraceMs = FLEET_RETIRE_GRACE_MS;
22948
+ fleetRetireSettleMs = FLEET_RETIRE_SETTLE_MS;
22949
+ /**
22950
+ * The engage's own three bounds: how long the session's events stream may take
22951
+ * to announce its subscription, how often the held lease is renewed, and how
22952
+ * long the stream is held while the runtime comes up.
22953
+ *
22954
+ * Fields for the reason the four above are - a service-level case drives them in
22955
+ * milliseconds rather than paying twenty seconds per session - and the numbers
22956
+ * themselves, with the harness constants they come from, are argued in
22957
+ * `backend/session-engage.ts`.
22958
+ */
22959
+ sessionEngageOpenMs = SESSION_ENGAGE_OPEN_MS;
22960
+ sessionEngageBeatMs = SESSION_ENGAGE_BEAT_MS;
22961
+ sessionEngageHoldMs = SESSION_ENGAGE_HOLD_MS;
22962
+ /**
22963
+ * How long the press IN FLIGHT has already spent waiting for the fleet.
22964
+ *
22965
+ * The budget above is the press's, not each drain's (review round 1, m2): a
22966
+ * rebuild press drains twice - once before the install and once before the
22967
+ * restart - so a per-drain budget let one press hold the button for twenty
22968
+ * minutes while the panel promised ten. Reset at the top of every press, in
22969
+ * `updateBackend`, because the guarantee is about one press.
22970
+ */
22971
+ fleetDrainSpentMs = 0;
21442
22972
  /**
21443
22973
  * Initialize the update service
21444
22974
  * @param mainWindow - The main application window
@@ -23994,8 +25524,14 @@ ${heal.removed.join("\n")}`,
23994
25524
  * reader is talking to) was missing on the only arm whose press now
23995
25525
  * publishes a generation and restarts a daemon. It is the managed global
23996
25526
  * arm's own wording, aimed at this arm's mechanism.
25527
+ *
25528
+ * AND IT NO LONGER PROMISES A DROPPED TURN. It did, because it described
25529
+ * what the press used to do; the press now waits for the running turns to
25530
+ * finish before it moves the server (`drainFleetForUpdate`), so the honest
25531
+ * consequence is the wait and the sequence, with the cost stated as the
25532
+ * time it takes rather than as work it destroys.
23997
25533
  */
23998
- remedy: "The app publishes a new server environment and then restarts the server it started, so a turn that is in flight is dropped while the server comes back. This can take a minute or two.",
25534
+ remedy: "The app publishes the new build beside the one the server is using, waits for the turns already running on this machine to finish, and then moves the server onto it, so nothing in flight is cut off. This can take a minute or two.",
23999
25535
  detail: `The app started this server itself (${startupMode}).`,
24000
25536
  sourceBuild: false,
24001
25537
  /*
@@ -24298,6 +25834,28 @@ ${heal.removed.join("\n")}`,
24298
25834
  * other two events already carry. It travels here too.
24299
25835
  */
24300
25836
  restartable: this.backendIsAppOwned(),
25837
+ /*
25838
+ * WHETHER THIS PRESS MOVES THE SERVER AT ALL, which is not the same
25839
+ * question as `restartable` above and is not answerable from it.
25840
+ * `restartable` says the daemon reading this app is one the app STARTED;
25841
+ * this says the press would RESTART it. On the harness's generation
25842
+ * layout the install lands in a tree no running process is reading, so
25843
+ * the app installs and announces and the server adopts the new build at
25844
+ * its own next idle - the cost sentence and the install phase's clause
25845
+ * both promised a bounce, and a promise the press does not keep is the
25846
+ * class of copy defect this panel has been reviewed for three times.
25847
+ *
25848
+ * THREE FACTS, and every one of them is already on this panel: the press
25849
+ * runs the app's own publish-and-restart only where the install it would
25850
+ * move is the app's OWN (`appOwnsInstall`, the reading below it); the
25851
+ * global `lop update` route does not bounce anything when its install is
25852
+ * a harness generation (`managedRoute`), and does when the route is the
25853
+ * in-place checkout rebuild; and a daemon this app did not start is never
25854
+ * the app's to move at all. The single case that restarts on a generation
25855
+ * install is a press with nothing left to install - the skew panel's own
25856
+ * control - which is a different offer entirely.
25857
+ */
25858
+ restartsServer: this.backendIsAppOwned() && (this.appOwnsInstall(startupMode, serving) || plan.managedRoute !== "entry-point"),
24301
25859
  /*
24302
25860
  * AND WHOSE INSTALL A PRESS WOULD MOVE (design D5), which is the other
24303
25861
  * ownership reading: `restartable` is true here on a global install too,
@@ -24644,6 +26202,259 @@ ${heal.removed.join("\n")}`,
24644
26202
  backendIsAppOwned() {
24645
26203
  return this.backendService?.servingInstall().owned.owned ?? false;
24646
26204
  }
26205
+ /**
26206
+ * Wait for the fleet this app can see to go idle, or refuse the update.
26207
+ *
26208
+ * THE ONE GATE EVERY RESTART AND EVERY IN-PLACE INSTALL GOES THROUGH. A
26209
+ * restart of the server serving this app is `stop(true)` - SIGTERM, ten
26210
+ * seconds, SIGKILL - and the daemon's own `retire.py` declines that exit for
26211
+ * exactly this reason: its shutdown cancels work it owns. The operator's rule
26212
+ * is that nothing kills runtimes en masse, so the app waits for the fleet to
26213
+ * drain before it touches anything, and REFUSES with a remedy at the end of
26214
+ * its budget rather than cutting off a turn on a timer.
26215
+ *
26216
+ * WHY THE MANAGER'S OWN READERS. `servingWorkState` is the app's existing
26217
+ * busy signal (see `servingWorkStateFromSessions`) and `servingSessionFleet`
26218
+ * is the same route with the rows kept; this method adds the WAIT and the
26219
+ * refusal, not a second notion of what is running.
26220
+ *
26221
+ * A REFUSAL, NOT A FAILURE: it answers the press with the sentence that says
26222
+ * what the app was waiting for, on the same channel the other refusals use
26223
+ * (`backend-update-error`), and the install it guarded does not happen.
26224
+ *
26225
+ * @returns true when the fleet drained (or nothing is running in it), false
26226
+ * when the update was refused and the caller must not proceed
26227
+ */
26228
+ async drainFleetForUpdate(input) {
26229
+ const { backend, what } = input;
26230
+ const spentMs = this.fleetDrainSpentMs;
26231
+ const remainingMs = Math.max(0, this.fleetDrainBudgetMs - spentMs);
26232
+ const outcome = await waitForFleetIdle({
26233
+ readWorkState: () => backend.servingWorkState(),
26234
+ readRoster: () => backend.servingSessionFleet(),
26235
+ // The transport's own reading of WHY an unreadable roster was
26236
+ // unreadable, read only if a refusal is composed from one.
26237
+ readUnreadableReason: () => backend.fleetReadFailureReason(),
26238
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
26239
+ now: () => Date.now(),
26240
+ budgetMs: remainingMs,
26241
+ pollMs: this.fleetDrainPollMs,
26242
+ /*
26243
+ * The panel is told the update is WAITING, and it is told before the
26244
+ * first poll rather than after the tenth: a press that means to install
26245
+ * and instead sits still for minutes is the silence this panel's copy
26246
+ * exists to remove. The ELAPSED reading travels with it (design D3): a
26247
+ * ten-minute wait with nothing moving on the frame is indistinguishable
26248
+ * from a hung app, and `waitedMs` is the one number the app already has.
26249
+ */
26250
+ onWait: (elapsedMs) => {
26251
+ this.sendToRenderer("backend-update-progress", {
26252
+ phase: "draining",
26253
+ waitedMs: spentMs + elapsedMs
26254
+ });
26255
+ logger.info(
26256
+ `Update waiting for the fleet to drain before it may ${what} (${Math.round(elapsedMs / 1e3)}s so far)`,
26257
+ LogFileType.UPDATE_SERVICE
26258
+ );
26259
+ }
26260
+ });
26261
+ this.fleetDrainSpentMs = spentMs + outcome.waitedMs;
26262
+ if (outcome.kind === "drained") {
26263
+ logger.info(
26264
+ `The fleet is idle (${outcome.fleet} session(s) on the roster, ${outcome.waitedMs}ms waited); the update may ${what}`,
26265
+ LogFileType.UPDATE_SERVICE
26266
+ );
26267
+ return true;
26268
+ }
26269
+ const message = fleetDrainRefusalSentence(
26270
+ outcome,
26271
+ input.installLanded === true
26272
+ );
26273
+ const command = input.command ?? null;
26274
+ const pressWaitedMs = spentMs + outcome.waitedMs;
26275
+ logger.warn(
26276
+ `Refusing to ${what}: the fleet did not drain in ${pressWaitedMs}ms (${outcome.because}, ${outcome.busy.length} mid-turn, this leg spent ${outcome.waitedMs}ms). ${message}${command ? ` By hand: ${command}` : ""}`,
26277
+ LogFileType.UPDATE_SERVICE
26278
+ );
26279
+ this.sendToRenderer("backend-update-error", {
26280
+ message,
26281
+ phase: "update",
26282
+ logPath: serverUpdateLogPath(),
26283
+ /*
26284
+ * WHAT WAS WAITED FOR, and the choice made instead of forcing: this is
26285
+ * what lets the panel paint a HELD-BACK update rather than a failed one
26286
+ * (review round 1, D1) and put the sessions it waited for, and the
26287
+ * command that skips the wait, where a reader can act on them.
26288
+ */
26289
+ refusal: {
26290
+ because: outcome.because,
26291
+ waitedMs: pressWaitedMs,
26292
+ command,
26293
+ credentialsRefused: outcome.credentialsRefused === true,
26294
+ /*
26295
+ * WHICH REFUSAL THIS IS (design round 2, D6). The heading is the reader's
26296
+ * takeaway from a panel they have been looking at for ten minutes, and the
26297
+ * three refusal sites are not the same event: the install leg's refusal left
26298
+ * nothing on disk, while the two restart-leg refusals happen AFTER the build
26299
+ * landed. "The update didn't start" is false on the second pair - the
26300
+ * producer's own doc for `installLanded` says so - so the fact travels with
26301
+ * the report and the panel keys its heading on it.
26302
+ */
26303
+ installLanded: input.installLanded === true
26304
+ }
26305
+ });
26306
+ return false;
26307
+ }
26308
+ /**
26309
+ * Put back the runtimes a restart displaced, and say what came back.
26310
+ *
26311
+ * Step 5 of the host tool's own order (`~/tools/lop-fleet-update`): the app
26312
+ * snapshots the fleet, drains it, moves the server, and then re-engages what
26313
+ * the move displaced - the unwatched `daemon`-kind sessions in particular,
26314
+ * which nothing else revives. `reengageDisplacedSessions` owns the retire wait
26315
+ * and the ordering rules.
26316
+ *
26317
+ * BEST EFFORT AND NEVER A BLOCKER: this runs after the server is healthy and
26318
+ * the update has already been reported, so a displace list that will not come
26319
+ * back is a logged fact rather than a failed update. That is deliberate - a
26320
+ * `warm` the daemon refuses must not turn a landed update into an error panel.
26321
+ */
26322
+ async reengageFleetAfterRestart(backend, before) {
26323
+ try {
26324
+ const result = await reengageDisplacedSessions({
26325
+ before,
26326
+ readRoster: () => backend.servingSessionFleet(),
26327
+ engage: (row) => this.engageSessionRuntime(backend, row),
26328
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
26329
+ now: () => Date.now(),
26330
+ graceMs: this.fleetRetireGraceMs,
26331
+ settleMs: this.fleetRetireSettleMs,
26332
+ retirePollMs: this.fleetDrainPollMs,
26333
+ log: (line) => logger.info(line, LogFileType.UPDATE_SERVICE)
26334
+ });
26335
+ if (result.displaced.length === 0) {
26336
+ if (result.stillResident.length > 0) {
26337
+ logger.info(
26338
+ `Nothing was displaced after the restart; ${result.stillResident.length} pre-swap runtime(s) were still resident when the wait ended (${result.stillResident.map((row) => row.sessionId).join(", ")}) and will go cold on their own schedule`,
26339
+ LogFileType.UPDATE_SERVICE
26340
+ );
26341
+ }
26342
+ return;
26343
+ }
26344
+ if (result.failed.length > 0) {
26345
+ logger.warn(
26346
+ `${result.failed.length} displaced session(s) did not come back after the restart: ${result.failed.map((row) => `${row.sessionId} (${row.reason})`).join(", ")}`,
26347
+ LogFileType.UPDATE_SERVICE
26348
+ );
26349
+ }
26350
+ } catch (error) {
26351
+ logger.warn(
26352
+ `Could not re-engage the sessions a restart displaced: ${error instanceof Error ? error.message : String(error)}`,
26353
+ LogFileType.UPDATE_SERVICE
26354
+ );
26355
+ }
26356
+ }
26357
+ /**
26358
+ * Start one session's runtime again, without submitting anything to it.
26359
+ *
26360
+ * THE APP'S OWN PATH FOR OPENING A SESSION (review round 2, R2-M1, which is the
26361
+ * finding that this call was a no-op against the shipped daemon). What this used
26362
+ * to do - post `sessions.watch` with a freshly generated subscription id and then
26363
+ * post `sessions.warm` - breaks BOTH preconditions the two routes carry:
26364
+ *
26365
+ * - **The lease must name a subscription the bridge KNOWS.** The id is minted
26366
+ * server-side by an events subscription (`DesktopSessionBridge.subscribe`) and
26367
+ * `watch` looks it up in that table, raising `KeyError` for one it has never
26368
+ * seen - which the route ladder answers as a **404**, one request before the
26369
+ * warm. A random id therefore never leased anything, and the engage reported
26370
+ * "did not answer" about a call it could have known would fail.
26371
+ * - **A warm only survives while something else holds the bridge.** The bridge is
26372
+ * reference-counted by IN-FLIGHT REQUESTS; a warm issued while nothing holds it
26373
+ * is cancelled the moment its own request returns (`routes/desktop_sessions.py`'s
26374
+ * `warm` docstring, pinned by
26375
+ * `tests/unit/server/test_desktop_sessions.py::test_a_warm_survives_its_own_request_while_a_subscriber_holds_the_bridge`).
26376
+ * The renderer's panel path works because a mounted `SessionPanel` holds an
26377
+ * EVENTS STREAM for its whole life; a one-shot warm from main held nothing, so
26378
+ * the 200 said the call was admitted, not that a runtime exists.
26379
+ *
26380
+ * So the engage is what CLICKING THE SESSION does, through the app's own relay:
26381
+ * hold the session's events stream, take the subscription id its `open` frame
26382
+ * carries, lease that id as a VISIBLE watch (a live visible lease is what CREATES
26383
+ * residency for a cold session - `DesktopSessionBridge.refresh_watch`), warm it,
26384
+ * and keep holding until the roster says the runtime is there.
26385
+ * `session-engage.ts` owns that rule and the evidence each leg rests on.
26386
+ *
26387
+ * AND THE BOOLEAN IS A CLAIM ABOUT THE MACHINE, not about a status code: every
26388
+ * route on this path answers 200 to a call it will not act on, so what decides the
26389
+ * outcome is the same roster read the fleet gate uses (`sessionHasRuntime`). The
26390
+ * caller's "re-engaged N of M" log line is worth exactly that much.
26391
+ *
26392
+ * `visible: true` IS ONLY SAFE WHERE A PRESENCE RECORD EXISTS. The runtime prefers
26393
+ * this app's machine-wide presence record and falls back to exactly this
26394
+ * per-connection flag (`session/runtime/server.py::_desktop_visible`), so on a
26395
+ * machine with no record the app is telling a session's runtime that somebody is
26396
+ * looking at a session nobody has open - which can suppress a banner for it
26397
+ * (`_visible_attach_surfaces`). It is kept because the lease-driven warm requires
26398
+ * a VISIBLE lease (`_lease_warm_loop`), and it is the flag the renderer's own beat
26399
+ * sends for a session it really is showing.
26400
+ *
26401
+ * NOT `sessions.message`, deliberately: a message would admit a turn in every
26402
+ * conversation this machine holds, which is work and spend the user did not ask
26403
+ * for, and the drain that ran before the restart is what makes a notice about
26404
+ * interrupted work unnecessary - by construction nothing was mid-turn when the
26405
+ * server moved.
26406
+ */
26407
+ async engageSessionRuntime(backend, row) {
26408
+ const relay = backend.getStreamRelay();
26409
+ return await engageSessionThroughStream({
26410
+ sessionId: row.sessionId,
26411
+ subscribe: (sessionId2, emit) => relay.subscribe({ sessionId: sessionId2 }, (event) => {
26412
+ if (event.kind === "data") {
26413
+ emit({ kind: "data", data: event.data });
26414
+ return;
26415
+ }
26416
+ if (event.kind === "error") {
26417
+ emit({ kind: "error", detail: event.detail });
26418
+ return;
26419
+ }
26420
+ emit({ kind: "end" });
26421
+ }),
26422
+ unsubscribe: (streamId) => relay.unsubscribe(streamId),
26423
+ watch: (subscriptionId) => backend.requestDesktop({
26424
+ op: "sessions.watch",
26425
+ sessionId: row.sessionId,
26426
+ subscriptionId,
26427
+ visible: true,
26428
+ canNotify: false
26429
+ }),
26430
+ warm: (sessionId2) => backend.requestDesktop({ op: "sessions.warm", sessionId: sessionId2 }),
26431
+ hasRuntime: async () => sessionHasRuntime(await backend.servingSessionFleet(), row.sessionId),
26432
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
26433
+ now: () => Date.now(),
26434
+ openMs: this.sessionEngageOpenMs,
26435
+ beatMs: this.sessionEngageBeatMs,
26436
+ holdMs: this.sessionEngageHoldMs,
26437
+ log: (line) => logger.info(line, LogFileType.UPDATE_SERVICE)
26438
+ }).then((outcome) => {
26439
+ if (!outcome.engaged) {
26440
+ logger.warn(
26441
+ `The re-engage of ${row.sessionId} did not start a runtime: ${outcome.reason ?? "no reason given"}`,
26442
+ LogFileType.UPDATE_SERVICE
26443
+ );
26444
+ }
26445
+ return outcome.engaged;
26446
+ });
26447
+ }
26448
+ /**
26449
+ * The fleet as it is now, for the before/after pair a restart is judged on.
26450
+ *
26451
+ * Null is a roster that could not be read, and every caller treats it as "no
26452
+ * snapshot": a re-engage with no `before` has nothing to put back, and
26453
+ * inventing one would re-engage sessions that were never displaced.
26454
+ */
26455
+ async readFleetSnapshot(backend) {
26456
+ return backend.servingSessionFleet();
26457
+ }
24647
26458
  /**
24648
26459
  * The build the daemon serving this app BOOTED with, or null when its record
24649
26460
  * carries no reading.
@@ -25047,6 +26858,14 @@ ${heal.removed.join("\n")}`,
25047
26858
  const budgetMs = rebuildRoute ? SOURCE_REBUILD_TIMEOUT_MS : GLOBAL_UPDATE_TIMEOUT_MS;
25048
26859
  const before = this.readInstallVersionAt(installPath) ?? plan.installedInstallVersion;
25049
26860
  const markerBefore = rebuildRoute ? this.readRebuildMarkerState(installPath) : null;
26861
+ const fleetBeforeInstall = rebuildRoute ? await this.readFleetSnapshot(backend) : null;
26862
+ if (rebuildRoute && !await this.drainFleetForUpdate({
26863
+ backend,
26864
+ command: freshPlan.updateCommand ?? null,
26865
+ what: "install"
26866
+ })) {
26867
+ return false;
26868
+ }
25050
26869
  this.sendToRenderer("backend-update-progress", {
25051
26870
  phase: "installing",
25052
26871
  sourceRebuild: rebuildRoute
@@ -25139,7 +26958,9 @@ ${heal.removed.join("\n")}`,
25139
26958
  `Global install updated: ${before ?? "unknown"} -> ${after ?? "unknown"}`,
25140
26959
  LogFileType.UPDATE_SERVICE
25141
26960
  );
25142
- if (backend.isUsingExternalBackend()) {
26961
+ const generationInstall = freshPlan.managedRoute === "entry-point";
26962
+ const installAlreadyCurrent = !rebuildRoute && before !== null && after !== null && before === after;
26963
+ if (backend.isUsingExternalBackend() || generationInstall && !installAlreadyCurrent) {
25143
26964
  const health = await this.getInstalledBackendVersion();
25144
26965
  const running2 = this.eventRunningVersion(health);
25145
26966
  logger.info(
@@ -25160,14 +26981,30 @@ ${heal.removed.join("\n")}`,
25160
26981
  });
25161
26982
  return true;
25162
26983
  }
26984
+ if (!await this.drainFleetForUpdate({
26985
+ backend,
26986
+ command: freshPlan.updateCommand ?? null,
26987
+ what: "restart",
26988
+ /*
26989
+ * A press with nothing left to install has no install to report as
26990
+ * landed, and saying one was would be the one false thing in the
26991
+ * refusal.
26992
+ */
26993
+ installLanded: !installAlreadyCurrent
26994
+ })) {
26995
+ logger.info(
26996
+ installAlreadyCurrent ? "Nothing was left to install; the restart was refused because the fleet did not drain. The daemon keeps serving the build it loaded." : "The install landed; the restart was refused because the fleet did not drain. The daemon keeps serving the build it loaded.",
26997
+ LogFileType.UPDATE_SERVICE
26998
+ );
26999
+ return false;
27000
+ }
25163
27001
  logger.info(
25164
27002
  "Restarting backend service onto the updated install...",
25165
27003
  LogFileType.UPDATE_SERVICE
25166
27004
  );
25167
27005
  this.sendToRenderer("backend-update-progress", { phase: "restarting" });
25168
- backend.setAutoUpdating(true);
27006
+ const fleetBeforeRestart = await this.readFleetSnapshot(backend);
25169
27007
  const restartSuccess = await backend.restart();
25170
- backend.setAutoUpdating(false);
25171
27008
  if (!restartSuccess) {
25172
27009
  logger.error(
25173
27010
  "Backend service restart failed after the global install update",
@@ -25220,6 +27057,13 @@ ${heal.removed.join("\n")}`,
25220
27057
  `Backend reports version ${reported} after the global install update`,
25221
27058
  LogFileType.UPDATE_SERVICE
25222
27059
  );
27060
+ const reengageSnapshot = unionFleetSnapshots(
27061
+ fleetBeforeInstall,
27062
+ fleetBeforeRestart
27063
+ );
27064
+ if (reengageSnapshot !== null) {
27065
+ await this.reengageFleetAfterRestart(backend, reengageSnapshot);
27066
+ }
25223
27067
  this.sendToRenderer("backend-update-completed", {
25224
27068
  installVersion: after,
25225
27069
  runningVersion: reported,
@@ -25302,6 +27146,12 @@ ${heal.removed.join("\n")}`,
25302
27146
  }
25303
27147
  async updateBackend(targetVersion) {
25304
27148
  logger.info("Updating backend...", LogFileType.UPDATE_SERVICE);
27149
+ const backend = this.backendService;
27150
+ const heldElsewhere = backend?.checkIsAutoUpdating?.() === true;
27151
+ if (!heldElsewhere) {
27152
+ this.fleetDrainSpentMs = 0;
27153
+ backend?.setAutoUpdating(true);
27154
+ }
25305
27155
  try {
25306
27156
  if (!this.backendService) {
25307
27157
  logger.error(
@@ -25421,6 +27271,8 @@ ${heal.removed.join("\n")}`,
25421
27271
  });
25422
27272
  }
25423
27273
  return false;
27274
+ } finally {
27275
+ if (!heldElsewhere) backend?.setAutoUpdating(false);
25424
27276
  }
25425
27277
  }
25426
27278
  /**
@@ -25563,6 +27415,15 @@ ${reason}` : ""}`,
25563
27415
  });
25564
27416
  return true;
25565
27417
  }
27418
+ const fleetAfterPublish = await this.readFleetSnapshot(backend);
27419
+ if (!await this.drainFleetForUpdate({
27420
+ backend,
27421
+ command: null,
27422
+ what: "restart",
27423
+ installLanded: outcome.replaced
27424
+ })) {
27425
+ return false;
27426
+ }
25566
27427
  logger.info(
25567
27428
  "Restarting backend service onto the published environment...",
25568
27429
  LogFileType.UPDATE_SERVICE
@@ -25570,9 +27431,8 @@ ${reason}` : ""}`,
25570
27431
  if (willPublish) {
25571
27432
  this.sendToRenderer("backend-update-progress", { phase: "restarting" });
25572
27433
  }
25573
- backend.setAutoUpdating(true);
27434
+ const fleetBeforeRestart = await this.readFleetSnapshot(backend);
25574
27435
  const restarted = await backend.restart();
25575
- backend.setAutoUpdating(false);
25576
27436
  const healthy = restarted ? await this.checkBackendHealth() : false;
25577
27437
  if (!restarted || !healthy) {
25578
27438
  logger.error(
@@ -25648,6 +27508,13 @@ ${reason}` : ""}`,
25648
27508
  `The server serving this app reports ${running} after the app-managed environment update`,
25649
27509
  LogFileType.UPDATE_SERVICE
25650
27510
  );
27511
+ const reengageSnapshot = unionFleetSnapshots(
27512
+ fleetAfterPublish,
27513
+ fleetBeforeRestart
27514
+ );
27515
+ if (reengageSnapshot !== null) {
27516
+ await this.reengageFleetAfterRestart(backend, reengageSnapshot);
27517
+ }
25651
27518
  this.sendToRenderer("backend-update-completed", {
25652
27519
  installVersion,
25653
27520
  runningVersion: running,