local-operator-ui 0.29.12 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/out/main/index.js +1911 -79
  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 +2 -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) {
@@ -13309,31 +13429,6 @@ function readJson(path2) {
13309
13429
  function errorData(error) {
13310
13430
  return error instanceof BridgeCommandError ? error.data : {};
13311
13431
  }
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
13432
  function nextSequence(queue) {
13338
13433
  return (queue ?? []).reduce(
13339
13434
  (highest, entry) => Math.max(highest, entry.sequence),
@@ -13351,7 +13446,7 @@ const SCROLL_DIRECTIONS = /* @__PURE__ */ new Set([
13351
13446
  function sleep(ms) {
13352
13447
  return new Promise((resolve) => setTimeout(resolve, ms));
13353
13448
  }
13354
- const WEB_CONTENTS_DEADLINE_MS = CHROME_API_DEADLINE_MS;
13449
+ const WEB_CONTENTS_DEADLINE_MS = 15e3;
13355
13450
  const ONCE_GRANT_TTL_MS = 10 * 6e4;
13356
13451
  function receiptKey(origin, requester) {
13357
13452
  return `${origin}
@@ -14342,6 +14437,30 @@ function routeDebuggerEvent(webContentsId, method, rawParams) {
14342
14437
  });
14343
14438
  }
14344
14439
  }
14440
+ const CDP_DEADLINE_MS = 15e3;
14441
+ const CDP_ATTACH_DEADLINE_MS = 5e3;
14442
+ const SCRIPTING_DEADLINE_MS = 15e3;
14443
+ function deadline(op, ms, what) {
14444
+ return new Promise((resolve, reject) => {
14445
+ const timer = setTimeout(() => {
14446
+ reject(
14447
+ new BridgeCommandError("internal", `${what} did not respond within ${ms}ms`, {
14448
+ stalled: what
14449
+ })
14450
+ );
14451
+ }, ms);
14452
+ op.then(
14453
+ (value) => {
14454
+ clearTimeout(timer);
14455
+ resolve(value);
14456
+ },
14457
+ (error) => {
14458
+ clearTimeout(timer);
14459
+ reject(error);
14460
+ }
14461
+ );
14462
+ });
14463
+ }
14345
14464
  const PROTOCOL_VERSION = "1.3";
14346
14465
  const ALREADY_ATTACHED = /another debugger|already attached/i;
14347
14466
  class CdpPool {
@@ -14623,6 +14742,743 @@ class ConsentNotifier {
14623
14742
  }
14624
14743
  }
14625
14744
  }
14745
+ 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"];
14746
+ 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"];
14747
+ const CAPS = {
14748
+ "downloadMaxBytes": 268435456,
14749
+ "downloadMaxFilesPerCall": 20,
14750
+ "downloadTimeoutS": 120,
14751
+ "downloadTimeoutMaxS": 600,
14752
+ "uploadMaxFiles": 10
14753
+ };
14754
+ const MAX_NAME_BYTES = 200;
14755
+ const FALLBACK_STEM = "download";
14756
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
14757
+ const BIDI_ZERO_WIDTH = /[\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g;
14758
+ const WINDOWS_RESERVED_STEMS = /* @__PURE__ */ new Set([
14759
+ "con",
14760
+ "prn",
14761
+ "aux",
14762
+ "nul",
14763
+ "com1",
14764
+ "com2",
14765
+ "com3",
14766
+ "com4",
14767
+ "com5",
14768
+ "com6",
14769
+ "com7",
14770
+ "com8",
14771
+ "com9",
14772
+ "lpt1",
14773
+ "lpt2",
14774
+ "lpt3",
14775
+ "lpt4",
14776
+ "lpt5",
14777
+ "lpt6",
14778
+ "lpt7",
14779
+ "lpt8",
14780
+ "lpt9"
14781
+ ]);
14782
+ function bytesOf(text) {
14783
+ return new TextEncoder().encode(text).length;
14784
+ }
14785
+ function fallbackDigest(text) {
14786
+ let hash = 2166136261;
14787
+ for (const byte of new TextEncoder().encode(text)) {
14788
+ hash = Math.imul(hash ^ byte, 16777619) >>> 0;
14789
+ }
14790
+ return hash.toString(16).padStart(8, "0");
14791
+ }
14792
+ function truncateBytes(name, limit) {
14793
+ let head = name;
14794
+ let ext = "";
14795
+ const dot = name.lastIndexOf(".");
14796
+ if (dot > 0 && name.length - dot - 1 <= 12) {
14797
+ head = name.slice(0, dot);
14798
+ ext = name.slice(dot + 1);
14799
+ }
14800
+ const budget = limit - (ext ? bytesOf(ext) + 1 : 0);
14801
+ if (budget <= 0) return "";
14802
+ while (bytesOf(head) > budget && head.length > 0) {
14803
+ head = head.slice(0, -1);
14804
+ if (/[\ud800-\udbff]$/.test(head)) head = head.slice(0, -1);
14805
+ }
14806
+ return ext ? `${head}.${ext}` : head;
14807
+ }
14808
+ function fallbackName(raw, sniffedExt = "") {
14809
+ const stem = `${FALLBACK_STEM}-${fallbackDigest(raw)}`;
14810
+ return sniffedExt ? `${stem}.${sniffedExt}` : stem;
14811
+ }
14812
+ function safeName(raw, sniffedExt = "") {
14813
+ const parts = raw.replace(/\\/g, "/").split("/");
14814
+ let name = (parts[parts.length - 1] ?? "").replace(CONTROL, "").replace(BIDI_ZERO_WIDTH, "");
14815
+ name = name.normalize("NFC").trim().replace(/[. ]+$/, "");
14816
+ if (name === "" || name === "." || name === "..") return fallbackName(raw, sniffedExt);
14817
+ const dot = name.lastIndexOf(".");
14818
+ const stem = dot > 0 ? name.slice(0, dot) : name;
14819
+ let ext = dot > 0 ? name.slice(dot + 1) : "";
14820
+ if (WINDOWS_RESERVED_STEMS.has(stem.toLowerCase())) return fallbackName(raw, sniffedExt);
14821
+ if (sniffedExt && ext.toLowerCase() !== sniffedExt.toLowerCase()) {
14822
+ ext = sniffedExt;
14823
+ }
14824
+ return truncateBytes(ext ? `${stem}.${ext}` : stem, MAX_NAME_BYTES);
14825
+ }
14826
+ function extensionOf(raw) {
14827
+ const name = safeName(raw).toLowerCase();
14828
+ const dot = name.lastIndexOf(".");
14829
+ if (dot <= 0) return "";
14830
+ const ext = name.slice(dot + 1);
14831
+ return ext.length > 12 || ext.includes("/") ? "" : ext;
14832
+ }
14833
+ function executableName(raw) {
14834
+ const ext = extensionOf(raw);
14835
+ return ext !== "" && DENY_EXTS.includes(ext);
14836
+ }
14837
+ function globToRegExp(pattern) {
14838
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
14839
+ return new RegExp(`^${escaped}$`);
14840
+ }
14841
+ CREDENTIAL_NAME_PATTERNS.map((pattern) => ({
14842
+ pattern,
14843
+ regex: globToRegExp(pattern)
14844
+ }));
14845
+ const QUIET_MS = 1500;
14846
+ const NOTES_KEPT = 4;
14847
+ const PROGRESS_RENDER_MS = 250;
14848
+ const CAP_SAMPLE_MS = 100;
14849
+ class DownloadArmer {
14850
+ constructor(options) {
14851
+ this.options = options;
14852
+ }
14853
+ captures = /* @__PURE__ */ new Map();
14854
+ notes = [];
14855
+ /** The newest file SAVED into the download directory, and how many have been saved
14856
+ * in this app run, for the durable control's own label (review round 2, U12).
14857
+ *
14858
+ * WHY IT IS NOT DERIVED FROM `notes`: the notes are a bounded NOTIFICATION window
14859
+ * (`NOTES_KEPT` = 4), so a save four decisions ago is gone from them — and the
14860
+ * first draft of this field read the notes, which meant the summary VANISHED
14861
+ * exactly when the user most needed it (a refusal, a refusal, a refusal, and the
14862
+ * trace of the file that did arrive is gone). It is one string and a count, kept
14863
+ * for the life of the process, which is what makes "this session" a true word in
14864
+ * the control's label rather than a bound dressed up as a total. */
14865
+ savedSummary = null;
14866
+ /** The paths this host has PROMISED Chromium, keyed by the DIRECTORY they are
14867
+ * in (review round 2, R2-4).
14868
+ *
14869
+ * WHY IT IS NOT ON THE CAPTURE ANY MORE. A reservation held per capture answers
14870
+ * only the question that capture asked: two same-named downloads on two armed
14871
+ * CALLS into one directory are still decided by the `existsSync` probe alone,
14872
+ * because Chromium creates the file after `setSavePath` returns — so the second
14873
+ * call cannot see the first's path. The directory is what a name can collide in,
14874
+ * so the directory is the key. And a reservation is RELEASED when the write it
14875
+ * was for settles — it either exists on disk (the probe sees it) or was
14876
+ * discarded (the name is free) — rather than consuming its suffix for the rest
14877
+ * of the call, which is how the next download of that name landed as
14878
+ * `name (1).ext` with nothing on disk (review round 2, R2-4). */
14879
+ reserved = /* @__PURE__ */ new Map();
14880
+ /** The owner kind of one tab, or null when this armer cannot say (review round
14881
+ * 2, U10: the kind travels with the decision because the tab may be closed by
14882
+ * the time the row renders it). */
14883
+ ownerKindOf(tabId) {
14884
+ return this.options.ownerKindFor?.(tabId) ?? null;
14885
+ }
14886
+ /** Arm one tab. `dir` is the harness-composed directory (§10.2); it is created
14887
+ * 0700 if missing, because the harness composes it and a race with a session's
14888
+ * own cleanup must not cost the user the file.
14889
+ *
14890
+ * TWO RULES ABOUT THAT DIRECTORY (review round 1, M3), and both exist because
14891
+ * this is the last place between a wire parameter and a `chmod`. ABSOLUTE: a
14892
+ * relative `dir` resolves against the APP's working directory rather than the
14893
+ * session's, which is the shape `upload` already refuses for its own paths. And
14894
+ * the MODE IS ASSERTED ONLY ON A DIRECTORY THIS CALL CREATED: re-moding an
14895
+ * existing path the harness composed — or a symlink to one — would be a chmod on
14896
+ * something this host does not own.
14897
+ */
14898
+ arm(tabId, dir, timeoutMs) {
14899
+ if (!path$1.isAbsolute(dir)) {
14900
+ throw new BridgeCommandError(
14901
+ "internal",
14902
+ "download needs an absolute directory to write into",
14903
+ { param: "dir" }
14904
+ );
14905
+ }
14906
+ this.forget(tabId);
14907
+ const created = !fs.existsSync(dir);
14908
+ try {
14909
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
14910
+ if (created) fs.chmodSync(dir, 448);
14911
+ } catch (error) {
14912
+ throw new BridgeCommandError(
14913
+ "internal",
14914
+ `download needs a directory it can write into: ${describe(error)}`,
14915
+ { param: "dir" }
14916
+ );
14917
+ }
14918
+ const now = this.options.now ?? Date.now;
14919
+ const capture = {
14920
+ tabId,
14921
+ dir,
14922
+ timeoutMs,
14923
+ startedAt: now(),
14924
+ files: [],
14925
+ refusals: [],
14926
+ pending: 0,
14927
+ started: false,
14928
+ live: [],
14929
+ handedOut: /* @__PURE__ */ new Set(),
14930
+ active: null,
14931
+ lastProgressAt: 0,
14932
+ quietTimer: null,
14933
+ deadlineTimer: null,
14934
+ capTimer: null,
14935
+ finish: null,
14936
+ settled: false
14937
+ };
14938
+ this.captures.set(tabId, capture);
14939
+ const deadline2 = setTimeout(() => this.settle(capture), timeoutMs);
14940
+ deadline2.unref?.();
14941
+ capture.deadlineTimer = deadline2;
14942
+ const capSample = setInterval(
14943
+ () => this.sampleCaps(capture),
14944
+ CAP_SAMPLE_MS
14945
+ );
14946
+ capSample.unref?.();
14947
+ capture.capTimer = capSample;
14948
+ this.options.log(
14949
+ `[browser] armed downloads on tab ${tabId} into ${dir} for ${Math.round(timeoutMs / 1e3)}s`
14950
+ );
14951
+ this.options.onActivity?.();
14952
+ return {
14953
+ done: () => new Promise((resolve) => {
14954
+ const answer = () => {
14955
+ capture.settled = true;
14956
+ resolve(this.resultOf(capture));
14957
+ };
14958
+ if (capture.settled) answer();
14959
+ else capture.finish = answer;
14960
+ })
14961
+ };
14962
+ }
14963
+ /** Drop one tab's arm and stop tracking it. Called from the action's `finally`
14964
+ * and from the single tab-removal path, so a closed tab cannot leave a live
14965
+ * capture behind. */
14966
+ forget(tabId) {
14967
+ const capture = this.captures.get(tabId);
14968
+ if (!capture) return;
14969
+ this.captures.delete(tabId);
14970
+ this.clearTimers(capture);
14971
+ this.cancelLive(
14972
+ capture,
14973
+ "was still being written when the download call ended",
14974
+ { rule: "interrupted", bytes: 0, limit: 0 }
14975
+ );
14976
+ for (const path2 of capture.handedOut) this.release(capture, path2);
14977
+ if (!capture.settled) {
14978
+ capture.settled = true;
14979
+ capture.finish?.();
14980
+ }
14981
+ }
14982
+ /**
14983
+ * Decide one download attempt. THE ONLY ENTRY POINT `profile.ts` calls.
14984
+ *
14985
+ * The order is deliberate and is the design's: the NAME first (cheapest, and
14986
+ * the only check that can refuse a `.url`/`.reg`/`.command`, whose bytes are
14987
+ * plain text), then the CAP before the write, then the save path.
14988
+ */
14989
+ decide(item, webContentsId) {
14990
+ const tabId = this.options.tabForWebContents(webContentsId);
14991
+ const capture = tabId === null ? void 0 : this.captures.get(tabId);
14992
+ if (!capture || capture.settled) {
14993
+ return { cancel: true, reason: "no download call is armed on this tab" };
14994
+ }
14995
+ const raw = item.getFilename();
14996
+ const clean = safeName(raw);
14997
+ if (executableName(raw)) {
14998
+ return this.refuse(
14999
+ capture,
15000
+ clean,
15001
+ `refused: \`${clean}\` is an executable/script type; nothing was saved`,
15002
+ { rule: "executable", bytes: item.getTotalBytes(), limit: 0 }
15003
+ );
15004
+ }
15005
+ if (capture.files.length + capture.pending >= CAPS.downloadMaxFilesPerCall) {
15006
+ return this.refuse(
15007
+ capture,
15008
+ clean,
15009
+ // The row shows this verbatim, so it is written for a person who just
15010
+ // clicked something rather than in tool-call jargon (review round 1, D1):
15011
+ // "call" and "landed" described the MODEL's call, and a user reading the
15012
+ // strip made neither.
15013
+ `refused: this download call has already saved its limit of ${CAPS.downloadMaxFilesPerCall} files; nothing was saved`,
15014
+ {
15015
+ rule: "count",
15016
+ bytes: 0,
15017
+ limit: CAPS.downloadMaxFilesPerCall
15018
+ }
15019
+ );
15020
+ }
15021
+ const total = item.getTotalBytes();
15022
+ if (total > CAPS.downloadMaxBytes) {
15023
+ return this.refuse(
15024
+ capture,
15025
+ clean,
15026
+ `refused: \`${clean}\` is over the ${humanBytes(CAPS.downloadMaxBytes)} per-file download limit; nothing was saved (it is ${total} bytes)`,
15027
+ { rule: "limit", bytes: total, limit: CAPS.downloadMaxBytes }
15028
+ );
15029
+ }
15030
+ const savePath = this.uniquePath(capture, clean, raw);
15031
+ try {
15032
+ item.setSavePath(savePath);
15033
+ } catch (error) {
15034
+ return this.refuse(
15035
+ capture,
15036
+ clean,
15037
+ `refused: \`${clean}\` could not be saved — the download folder could not be written to (${describe(error)}); nothing was saved`,
15038
+ { rule: "write", bytes: item.getTotalBytes(), limit: 0 }
15039
+ );
15040
+ }
15041
+ this.track(item, capture, savePath);
15042
+ this.reserve(capture, savePath);
15043
+ return { cancel: false, reason: "" };
15044
+ }
15045
+ /** Promise one path, in this capture's own list and in the directory's index. */
15046
+ reserve(capture, savePath) {
15047
+ capture.handedOut.add(savePath);
15048
+ const held = this.reserved.get(capture.dir);
15049
+ if (held) held.add(savePath);
15050
+ else this.reserved.set(capture.dir, /* @__PURE__ */ new Set([savePath]));
15051
+ }
15052
+ /** Give a reserved name back, because the write it was for has settled (review
15053
+ * round 2, R2-4): the file is on disk, so the probe sees it, or it was
15054
+ * discarded, so the name is free again.
15055
+ *
15056
+ * The capture's OWN list is what decides whether this host ever promised the
15057
+ * path, so a `done` event arriving after the arm was forgotten — the capture
15058
+ * object is still reachable through the listener closure even though the map
15059
+ * entry is gone — cannot release something a different capture reserved. */
15060
+ release(capture, savePath) {
15061
+ if (!capture.handedOut.delete(savePath)) return;
15062
+ const held = this.reserved.get(capture.dir);
15063
+ if (!held) return;
15064
+ held.delete(savePath);
15065
+ if (held.size === 0) this.reserved.delete(capture.dir);
15066
+ }
15067
+ /** What the chrome row renders for ONE tab (§16.4).
15068
+ *
15069
+ * WHY PER TAB (review round 1, D2): `captures` and `notes` are host-wide, and
15070
+ * the row rendered both into EVERY browser tab's chrome — so the strip of the
15071
+ * tab the user is watching could narrate a decision taken in another one, and
15072
+ * did exactly that in the published frames (`04` showed a refusal above a form
15073
+ * holding three attached files). The active tab is the row's subject.
15074
+ *
15075
+ * `null` is "no tab is active", which renders nothing: the strip belongs to a
15076
+ * tab, and there is no tab to speak for.
15077
+ *
15078
+ * WHY THE NOTES ARE HOST-WIDE AND THE TAB IS NAMED INSTEAD OF FILTERED OUT, which
15079
+ * is a correction to this change's own first attempt at D2. Filtering the notes to
15080
+ * the active tab looks like the reviewer's "scope it to the tab whose decision it
15081
+ * was", and it HIDES the case the feature exists for: an agent tab is created
15082
+ * INACTIVE by design (`tabs.ts`: "An agent `open` NEVER changes which tab the user
15083
+ * is looking at", §11.4's focus-safety rule), so an agent's own download would
15084
+ * leave no trace anywhere on screen — the exact complaint U3 files against the
15085
+ * upload path, reintroduced on the download path. What the reviewer's finding
15086
+ * actually needs is that the strip must not APPEAR to be about the page on screen:
15087
+ * so the note keeps its `tabId`, the projection carries `activeTabId`, and the row
15088
+ * says "on the agent's tab" when the two differ.
15089
+ */
15090
+ activityFor(tabId) {
15091
+ const newest = [...this.captures.values()].at(-1);
15092
+ return {
15093
+ active: newest?.active ?? null,
15094
+ // The DIRECTORY is reported from a live capture even before anything has
15095
+ // started, so the reveal is available for the whole of a call rather than
15096
+ // only after the first file lands. The NAME is not: "Downloading <a
15097
+ // directory>" would be a row that appears before the page has decided to
15098
+ // download anything.
15099
+ dir: this.downloadDir(),
15100
+ notes: [...this.notes].reverse(),
15101
+ activeTabId: tabId,
15102
+ recent: this.savedSummary
15103
+ };
15104
+ }
15105
+ /** The directory the reveal opens (§16.4): the live arm's, else the newest one a
15106
+ * decision used.
15107
+ *
15108
+ * HOST-WIDE ON PURPOSE, which is why it is not `activityFor`'s own field: the
15109
+ * button is about the FOLDER the host writes into, not about a tab. Upload notes
15110
+ * carry no directory, so they cannot answer for one. */
15111
+ downloadDir() {
15112
+ const dirs = this.notes.filter((note) => note.dir !== "").map((n) => n.dir);
15113
+ return [...this.captures.values()].at(-1)?.dir ?? dirs.at(-1) ?? null;
15114
+ }
15115
+ /** One upload's own line (review round 1, U3).
15116
+ *
15117
+ * WHY AN UPLOAD HAS A NOTE AT ALL, when §16.4 deliberately has no upload
15118
+ * affordance: the surface asymmetry was defensible while nothing was going
15119
+ * wrong, but an upload is the more dangerous verb — and the QA matrix showed
15120
+ * three files leaving the machine with the strip silent, or (worse) still
15121
+ * narrating an unrelated download refusal while they left. One line naming what
15122
+ * went where is not the per-file list §16.4 rules out; it is the same rule the
15123
+ * download half follows, applied to the half that had none.
15124
+ *
15125
+ * The SITE comes from the page the action actually reported (never from a URL a
15126
+ * caller composed), and the names are the sanitised ones the host attached. */
15127
+ noteUpload(tabId, files, site) {
15128
+ if (files.length === 0) return;
15129
+ this.notes.push({
15130
+ name: files[0]?.name ?? "",
15131
+ count: files.length,
15132
+ dir: "",
15133
+ outcome: "sent",
15134
+ reason: "",
15135
+ at: (this.options.now ?? Date.now)(),
15136
+ direction: "upload",
15137
+ tabId,
15138
+ site,
15139
+ refusal: null,
15140
+ ownerKind: this.ownerKindOf(tabId)
15141
+ });
15142
+ while (this.notes.length > NOTES_KEPT) this.notes.shift();
15143
+ this.options.onActivity?.();
15144
+ }
15145
+ // ---- the item lifecycle --------------------------------------------------
15146
+ track(item, capture, savePath) {
15147
+ capture.started = true;
15148
+ capture.pending += 1;
15149
+ const entry = { item, savePath, weCancelled: false };
15150
+ capture.live.push(entry);
15151
+ this.showProgress(capture, entry);
15152
+ if (capture.quietTimer) {
15153
+ clearTimeout(capture.quietTimer);
15154
+ capture.quietTimer = null;
15155
+ }
15156
+ this.options.onActivity?.();
15157
+ item.on("updated", () => {
15158
+ if (entry.weCancelled) return;
15159
+ if (capture.settled) {
15160
+ this.refuseLive(
15161
+ capture,
15162
+ entry,
15163
+ `\`${path$1.basename(savePath)}\` was still being written after this call's answer`,
15164
+ {
15165
+ rule: "interrupted",
15166
+ bytes: item.getReceivedBytes(),
15167
+ limit: 0
15168
+ }
15169
+ );
15170
+ return;
15171
+ }
15172
+ if (item.getReceivedBytes() > CAPS.downloadMaxBytes) {
15173
+ this.overrun(capture, entry);
15174
+ return;
15175
+ }
15176
+ this.showProgress(capture, entry);
15177
+ });
15178
+ item.once("done", (_event, state) => {
15179
+ capture.pending -= 1;
15180
+ capture.live = capture.live.filter((live) => live !== entry);
15181
+ const name = path$1.basename(savePath);
15182
+ this.release(capture, savePath);
15183
+ if (entry.weCancelled) {
15184
+ if (state !== "completed") discardPartial(savePath, this.options.log);
15185
+ } else if (state === "completed") {
15186
+ restrictMode(savePath, this.options.log);
15187
+ capture.files.push(fileFact(item, savePath));
15188
+ this.note(capture, name, "saved", 1, "", null);
15189
+ } else {
15190
+ 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`;
15191
+ discardPartial(savePath, this.options.log);
15192
+ if (!capture.settled) capture.refusals.push(reason);
15193
+ this.note(capture, name, "refused", 1, reason, {
15194
+ rule: "interrupted",
15195
+ bytes: item.getReceivedBytes(),
15196
+ limit: 0
15197
+ });
15198
+ }
15199
+ this.showProgress(capture);
15200
+ this.options.onActivity?.();
15201
+ if (!capture.settled && capture.pending === 0) this.armQuiet(capture);
15202
+ });
15203
+ }
15204
+ /** What the row says is in flight, refreshed on the render throttle.
15205
+ *
15206
+ * The LAST tracked item is the one the row names, which is the item a page that
15207
+ * starts several files is currently growing; the byte counts come from the item
15208
+ * itself, never from a sum this module keeps (a sum would be a second account of
15209
+ * the same write). The throttle is what keeps a 40 MB download from sending a
15210
+ * message per chunk to the renderer. */
15211
+ showProgress(capture, entry) {
15212
+ const last = entry ?? capture.live.at(-1);
15213
+ capture.active = last ? {
15214
+ name: path$1.basename(last.savePath),
15215
+ received: last.item.getReceivedBytes(),
15216
+ total: last.item.getTotalBytes(),
15217
+ tabId: capture.tabId
15218
+ } : null;
15219
+ const now = (this.options.now ?? Date.now)();
15220
+ if (now - capture.lastProgressAt < PROGRESS_RENDER_MS) return;
15221
+ capture.lastProgressAt = now;
15222
+ this.options.onActivity?.();
15223
+ }
15224
+ /** THE RUNTIME CAP'S REFUSAL, IN ONE PLACE (review round 2, Q4). Two triggers fire
15225
+ * it now — Chromium's `updated` and this host's own sampler — and two spellings
15226
+ * of one rule is exactly how the row's copy and the tool result stop agreeing.
15227
+ *
15228
+ * The RULE is `overrun` rather than `limit` (review round 2, R2-5): this case
15229
+ * cancelled a write that was already on disk, and the row must not tell the user
15230
+ * "Nothing was saved." about a partial it discarded. The SENTENCE is unchanged,
15231
+ * because it is the tool result's and QA and the review both read it as right. */
15232
+ overrun(capture, entry) {
15233
+ this.refuseLive(
15234
+ capture,
15235
+ entry,
15236
+ `\`${path$1.basename(entry.savePath)}\` went over the ${humanBytes(CAPS.downloadMaxBytes)} per-file download limit while it was being written`,
15237
+ {
15238
+ rule: "overrun",
15239
+ bytes: entry.item.getReceivedBytes(),
15240
+ limit: CAPS.downloadMaxBytes
15241
+ }
15242
+ );
15243
+ }
15244
+ /** Sample every still-writing transfer's own byte count for the runtime cap.
15245
+ *
15246
+ * WHY THIS EXISTS BESIDE THE `updated` CHECK (review round 2, Q4): `updated` is
15247
+ * Chromium's event, and QA measured it silent for the whole of a 700 MiB write,
15248
+ * so on its own it bounds the disk by the cap plus however long the origin stays
15249
+ * quiet. This reads `getReceivedBytes()` — the item's own counter, the same one
15250
+ * the refusal reports — so the ceiling is the host's to enforce rather than the
15251
+ * page's throughput to decide. It reports nothing of its own: the refusal, the
15252
+ * note and the cancel all go through `overrun`, so a sample can never produce a
15253
+ * second account of a write the event already cancelled. */
15254
+ sampleCaps(capture) {
15255
+ if (capture.settled) return;
15256
+ for (const entry of [...capture.live]) {
15257
+ if (entry.weCancelled) continue;
15258
+ if (entry.item.getReceivedBytes() <= CAPS.downloadMaxBytes) continue;
15259
+ this.overrun(capture, entry);
15260
+ }
15261
+ }
15262
+ /** Report a download this host is cancelling, and cancel it.
15263
+ *
15264
+ * ONE PATH FOR ALL THREE KINDS OF CANCEL (the runtime cap, the deadline, the
15265
+ * call going away), because they differ only in the clause that explains them
15266
+ * and a second copy would be a second chance to forget the note, the refusal or
15267
+ * the cancel itself.
15268
+ *
15269
+ * `weCancelled` is set HERE rather than by the callers, and it is what makes the
15270
+ * sentence survivable: the `done` event our own `cancel()` produces arrives after
15271
+ * the answer in the deadline case, and the handler must then discard the residue
15272
+ * without writing a second note. */
15273
+ refuseLive(capture, entry, clause, refusal) {
15274
+ entry.weCancelled = true;
15275
+ const reason = `refused: ${clause}; it was cancelled and the partial file was discarded`;
15276
+ capture.refusals.push(reason);
15277
+ this.note(capture, path$1.basename(entry.savePath), "refused", 1, reason, refusal);
15278
+ this.options.log(`[browser] ${reason}`);
15279
+ if (entry.item.getState() !== "progressing") return;
15280
+ try {
15281
+ entry.item.cancel();
15282
+ } catch (error) {
15283
+ this.options.log(
15284
+ `[browser] could not cancel ${path$1.basename(entry.savePath)}: ${describe(error)}`
15285
+ );
15286
+ }
15287
+ if (entry.item.getState() !== "completed") {
15288
+ discardPartial(entry.savePath, this.options.log);
15289
+ this.release(capture, entry.savePath);
15290
+ }
15291
+ this.options.onActivity?.();
15292
+ }
15293
+ /** Cancel every download this capture still has writing. */
15294
+ cancelLive(capture, clause, refusal) {
15295
+ const live = [...capture.live];
15296
+ capture.live = [];
15297
+ for (const entry of live) {
15298
+ if (entry.weCancelled) continue;
15299
+ this.refuseLive(
15300
+ capture,
15301
+ entry,
15302
+ `\`${path$1.basename(entry.savePath)}\` ${clause}`,
15303
+ { ...refusal, bytes: entry.item.getReceivedBytes() }
15304
+ );
15305
+ }
15306
+ }
15307
+ armQuiet(capture) {
15308
+ if (capture.quietTimer) clearTimeout(capture.quietTimer);
15309
+ const timer = setTimeout(
15310
+ () => this.settle(capture),
15311
+ this.options.quietMs ?? QUIET_MS
15312
+ );
15313
+ timer.unref?.();
15314
+ capture.quietTimer = timer;
15315
+ }
15316
+ /**
15317
+ * Answer the call.
15318
+ *
15319
+ * THE TWO ARMS ARE THE DEADLINE AND THE QUIET WINDOW, and the quiet window never
15320
+ * fires with a write in flight (it is only armed at `pending === 0`), so it always
15321
+ * has the finished files Python is about to inspect. A DEADLINE can arrive
15322
+ * mid-write, and it answers anyway — a call that never returns is worse than one
15323
+ * that admits what it could not finish — after cancelling what was still writing,
15324
+ * because that is the one file the harness could not describe honestly.
15325
+ */
15326
+ settle(capture) {
15327
+ if (capture.settled) return;
15328
+ if (capture.pending > 0) {
15329
+ this.cancelLive(
15330
+ capture,
15331
+ `was still being written when the ${Math.round(capture.timeoutMs / 1e3)}s budget expired`,
15332
+ {
15333
+ rule: "deadline",
15334
+ bytes: 0,
15335
+ limit: Math.round(capture.timeoutMs / 1e3)
15336
+ }
15337
+ );
15338
+ }
15339
+ capture.settled = true;
15340
+ capture.active = null;
15341
+ this.clearTimers(capture);
15342
+ capture.finish?.();
15343
+ this.options.onActivity?.();
15344
+ }
15345
+ resultOf(capture) {
15346
+ const reasons = [...capture.refusals];
15347
+ if (capture.files.length === 0 && reasons.length === 0) {
15348
+ const waited = Math.max(
15349
+ 1,
15350
+ Math.round(
15351
+ ((this.options.now ?? Date.now)() - capture.startedAt) / 1e3
15352
+ )
15353
+ );
15354
+ reasons.push(
15355
+ `no download started within ${waited}s; if the page needs a click first, pass a selector, or \`click\` it and retry`
15356
+ );
15357
+ }
15358
+ return {
15359
+ // `armed: false` is a POLICY answer, not a fault (§6.1). The app host always
15360
+ // arms when the harness asks it to and reports true; the harness's own
15361
+ // pre-arm refusals (its per-call checks before dispatching) are the caller of
15362
+ // the other arm.
15363
+ armed: true,
15364
+ files: capture.files,
15365
+ reason: reasons.join("; ")
15366
+ };
15367
+ }
15368
+ refuse(capture, clean, reason, refusal) {
15369
+ capture.refusals.push(reason);
15370
+ this.note(capture, clean, "refused", 1, reason, refusal);
15371
+ this.options.log(`[browser] ${reason}`);
15372
+ return { cancel: true, reason };
15373
+ }
15374
+ note(capture, name, outcome, count, reason, refusal) {
15375
+ this.notes.push({
15376
+ name,
15377
+ count,
15378
+ dir: capture.dir,
15379
+ outcome,
15380
+ reason,
15381
+ at: (this.options.now ?? Date.now)(),
15382
+ direction: "download",
15383
+ tabId: capture.tabId,
15384
+ site: "",
15385
+ refusal,
15386
+ ownerKind: this.ownerKindOf(capture.tabId)
15387
+ });
15388
+ if (outcome === "saved") {
15389
+ this.savedSummary = {
15390
+ name,
15391
+ count: (this.savedSummary?.count ?? 0) + 1
15392
+ };
15393
+ }
15394
+ while (this.notes.length > NOTES_KEPT) this.notes.shift();
15395
+ }
15396
+ clearTimers(capture) {
15397
+ if (capture.quietTimer) clearTimeout(capture.quietTimer);
15398
+ if (capture.deadlineTimer) clearTimeout(capture.deadlineTimer);
15399
+ if (capture.capTimer) clearInterval(capture.capTimer);
15400
+ capture.quietTimer = null;
15401
+ capture.deadlineTimer = null;
15402
+ capture.capTimer = null;
15403
+ }
15404
+ /**
15405
+ * A path in `dir` that neither exists nor has already been PROMISED in this
15406
+ * capture. The page's name is never obeyed beyond its sanitised basename, and
15407
+ * §11.4's rule is "no silent overwrite": the app host uniquifies with
15408
+ * `name (1).ext` rather than letting a second download of the same name replace
15409
+ * the first.
15410
+ *
15411
+ * THE RESERVATION IS THE FIX, and the earlier comment here was simply wrong
15412
+ * (review round 1, Q1/M4). It claimed two downloads of one name on one tab
15413
+ * "cannot race (an arm accepts them one at a time)" — being DECIDED one at a
15414
+ * time is not being CREATED one at a time: Chromium creates the file after
15415
+ * `setSavePath` returns, so two accepted downloads of one name are both in
15416
+ * flight with the disk still empty, both probe the same path, and the second
15417
+ * silently overwrites the first while the result reports two files at one path.
15418
+ * The probe therefore runs against the filesystem AND against the paths this
15419
+ * capture has already handed out.
15420
+ *
15421
+ * It is still not an atomic create, and that is honest rather than sloppy: the
15422
+ * value is handed to Chromium, which opens the file itself, so a
15423
+ * create-and-release probe would only add a window where the file exists empty.
15424
+ * The reservation closes the window the probe could not see.
15425
+ */
15426
+ uniquePath(capture, clean, raw) {
15427
+ const reserved = this.reserved.get(capture.dir);
15428
+ const taken = (path2) => fs.existsSync(path2) || (reserved?.has(path2) ?? false);
15429
+ const first = path$1.join(capture.dir, clean);
15430
+ if (!taken(first)) return first;
15431
+ const dot = clean.lastIndexOf(".");
15432
+ const stem = dot > 0 ? clean.slice(0, dot) : clean;
15433
+ const ext = dot > 0 ? clean.slice(dot + 1) : "";
15434
+ for (let index = 1; index <= 999; index += 1) {
15435
+ const candidate = path$1.join(
15436
+ capture.dir,
15437
+ ext ? `${stem} (${index}).${ext}` : `${stem} (${index})`
15438
+ );
15439
+ if (!taken(candidate)) return candidate;
15440
+ }
15441
+ return path$1.join(capture.dir, safeName(raw, extensionOf(raw) || "bin"));
15442
+ }
15443
+ }
15444
+ function fileFact(item, savePath) {
15445
+ return {
15446
+ name: path$1.basename(savePath),
15447
+ path: savePath,
15448
+ bytes: item.getReceivedBytes(),
15449
+ mime: item.getMimeType() || "",
15450
+ sniffed: "",
15451
+ sha256: ""
15452
+ };
15453
+ }
15454
+ function describe(error) {
15455
+ return error instanceof Error ? error.message : String(error);
15456
+ }
15457
+ function humanBytes(bytes) {
15458
+ const MiB = 1024 * 1024;
15459
+ return `${Math.floor(bytes / MiB)} MiB`;
15460
+ }
15461
+ function discardPartial(path2, log) {
15462
+ try {
15463
+ if (fs.existsSync(path2)) {
15464
+ fs.unlinkSync(path2);
15465
+ log(`[browser] discarded the partial download at ${path$1.basename(path2)}`);
15466
+ }
15467
+ } catch (error) {
15468
+ log(
15469
+ `[browser] could not discard the partial download at ${path$1.basename(path2)}: ${describe(error)}`
15470
+ );
15471
+ }
15472
+ }
15473
+ function restrictMode(path2, log) {
15474
+ try {
15475
+ fs.chmodSync(path2, 384);
15476
+ } catch (error) {
15477
+ log(
15478
+ `[browser] could not restrict ${path$1.basename(path2)} to 0600: ${describe(error)}`
15479
+ );
15480
+ }
15481
+ }
14626
15482
  function requesterOf(params, requestId2) {
14627
15483
  const supplied = typeof params.requester === "string" ? params.requester.trim() : "";
14628
15484
  return supplied.startsWith("session:") ? supplied : requestId2;
@@ -15275,10 +16131,10 @@ async function type(ctx, params) {
15275
16131
  returnByValue: true
15276
16132
  });
15277
16133
  await ctx.cdp.send(contents, "Input.insertText", { text });
15278
- const readBack = await conversationReadBack(ctx, node.objectId, contents);
15279
- if (readBack.includes(text)) {
16134
+ const readBack2 = await conversationReadBack(ctx, node.objectId, contents);
16135
+ if (readBack2.includes(text)) {
15280
16136
  ctx.registry.touch(record);
15281
- return { value: readBack, via: "insert_text", ...pageOf(record.view) };
16137
+ return { value: readBack2, via: "insert_text", ...pageOf(record.view) };
15282
16138
  }
15283
16139
  const set = await ctx.cdp.send(
15284
16140
  contents,
@@ -15292,9 +16148,9 @@ async function type(ctx, params) {
15292
16148
  );
15293
16149
  ctx.registry.touch(record);
15294
16150
  return {
15295
- value: String(set?.result?.value ?? readBack),
16151
+ value: String(set?.result?.value ?? readBack2),
15296
16152
  via: "value_setter",
15297
- insert_text_readback: readBack,
16153
+ insert_text_readback: readBack2,
15298
16154
  ...pageOf(record.view)
15299
16155
  };
15300
16156
  }
@@ -15310,6 +16166,41 @@ async function conversationReadBack(ctx, objectId, contents) {
15310
16166
  );
15311
16167
  return String(out?.result?.value ?? "");
15312
16168
  }
16169
+ async function download(ctx, params) {
16170
+ const record = ctx.registry.requireSurface(params.tab);
16171
+ const dir = stringParam(params, "dir");
16172
+ if (!dir) {
16173
+ throw new BridgeCommandError(
16174
+ "internal",
16175
+ "download needs the harness-composed directory to write into",
16176
+ { param: "dir" }
16177
+ );
16178
+ }
16179
+ const timeoutS = clampTimeout(numberParam(params, "timeout_s"));
16180
+ const selector = stringParam(params, "selector");
16181
+ try {
16182
+ const arm = ctx.downloads.arm(record.tabId, dir, timeoutS * 1e3);
16183
+ if (selector) await click(ctx, params);
16184
+ const result = await arm.done();
16185
+ ctx.registry.touch(record);
16186
+ return {
16187
+ files: result.files,
16188
+ armed: result.armed,
16189
+ reason: result.reason,
16190
+ // The page's own identity, as every other action reports it: a caller that
16191
+ // named a selector wants to know what it was looking at when the file
16192
+ // started, and a click can have moved the page.
16193
+ ...pageOf(record.view)
16194
+ };
16195
+ } finally {
16196
+ ctx.downloads.forget(record.tabId);
16197
+ }
16198
+ }
16199
+ function clampTimeout(requested) {
16200
+ const wanted = requested === void 0 ? CAPS.downloadTimeoutS : requested;
16201
+ if (!Number.isFinite(wanted) || wanted <= 0) return CAPS.downloadTimeoutS;
16202
+ return Math.min(wanted, CAPS.downloadTimeoutMaxS);
16203
+ }
15313
16204
  const MAX_AGENT_TABS = 8;
15314
16205
  const BACKGROUND_VIEWPORT = {
15315
16206
  x: 0,
@@ -15432,6 +16323,24 @@ class TabRegistry {
15432
16323
  (record) => !record.view.webContents.isDestroyed() && record.view.webContents.id === webContentsId
15433
16324
  );
15434
16325
  }
16326
+ /** The tab a webContents belongs to, or null.
16327
+ *
16328
+ * WHY THIS EXISTS: `will-download` is a SESSION-level handler (Electron's own
16329
+ * split, see `profile.ts`), and the only thing it hands the handler that names a
16330
+ * tab is the WebContents that started the download. Without this lookup the
16331
+ * host could not tell an armed tab from an unarmed one, and every download would
16332
+ * have to be refused — which is what it did before the file-transfer feature.
16333
+ *
16334
+ * A linear scan is deliberate: the set is bounded by the agent-tab cap plus the
16335
+ * user's own tabs, it runs once per download, and a second index keyed by
16336
+ * webContents id is one more thing that can go stale when a view dies. */
16337
+ byWebContents(webContentsId) {
16338
+ if (!Number.isSafeInteger(webContentsId)) return null;
16339
+ for (const record of this.tabs.values()) {
16340
+ if (record.view.webContents.id === webContentsId) return record;
16341
+ }
16342
+ return null;
16343
+ }
15435
16344
  get activeTab() {
15436
16345
  return this.activeTabId === null ? null : this.tabs.get(this.activeTabId) ?? null;
15437
16346
  }
@@ -15964,6 +16873,202 @@ function retitle() {
15964
16873
  reason: "the browser tab is labelled with the page title"
15965
16874
  };
15966
16875
  }
16876
+ const READ_FILES_FUNCTION = `function () {
16877
+ if (!("files" in this)) return null;
16878
+ const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "files");
16879
+ let files;
16880
+ try { files = descriptor && descriptor.get ? descriptor.get.call(this) : this.files; }
16881
+ catch (err) { return null; }
16882
+ if (!files) return null;
16883
+ const list = Array.from(files);
16884
+ return { count: list.length, names: list.map((f) => f.name), sizes: list.map((f) => f.size) };
16885
+ }`;
16886
+ const NOT_A_FILE_INPUT = 'that selector is not a file input; snapshot the page and use the element that <input type="file"> names';
16887
+ const CONTEXT_GONE = [
16888
+ "Cannot find context with specified id",
16889
+ "Cannot find execution context",
16890
+ "Execution context was destroyed",
16891
+ "Inspected target navigated or closed"
16892
+ ];
16893
+ const NODE_GONE = [
16894
+ "Node with given id does not belong to the document",
16895
+ "No node with given id found",
16896
+ "Could not find node with given id"
16897
+ ];
16898
+ function isContextGone(error) {
16899
+ const message = error instanceof Error ? error.message : String(error);
16900
+ return CONTEXT_GONE.some((marker) => message.includes(marker));
16901
+ }
16902
+ function isNodeGone(error) {
16903
+ const message = error instanceof Error ? error.message : String(error);
16904
+ return NODE_GONE.some((marker) => message.includes(marker));
16905
+ }
16906
+ function describeError(error) {
16907
+ const message = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim();
16908
+ return message.slice(0, 120) || "no detail";
16909
+ }
16910
+ async function upload(ctx, params) {
16911
+ const record = ctx.registry.requireSurface(params.tab);
16912
+ const selector = stringParam(params, "selector");
16913
+ if (!selector) {
16914
+ throw new BridgeCommandError(
16915
+ "element_not_found",
16916
+ "upload needs a selector naming the file input"
16917
+ );
16918
+ }
16919
+ const paths = uploadPaths(params);
16920
+ const contents = record.view.webContents;
16921
+ const node = await resolveNode(ctx, record, selector);
16922
+ const accept = await acceptsOf(ctx, record, node.nodeId);
16923
+ await ctx.cdp.send(contents, "DOM.setFileInputFiles", {
16924
+ files: paths,
16925
+ nodeId: node.nodeId
16926
+ }).catch((error) => {
16927
+ if (!/not a file input/i.test(String(error))) throw error;
16928
+ throw new BridgeCommandError("element_not_found", NOT_A_FILE_INPUT, {
16929
+ selector,
16930
+ accept
16931
+ });
16932
+ });
16933
+ let held = null;
16934
+ let readback = "";
16935
+ try {
16936
+ held = await readBack(ctx, record, node.objectId);
16937
+ } catch (error) {
16938
+ 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)})`;
16939
+ }
16940
+ if (!readback) {
16941
+ if (!held) {
16942
+ throw new BridgeCommandError("element_not_found", NOT_A_FILE_INPUT, {
16943
+ selector,
16944
+ accept
16945
+ });
16946
+ }
16947
+ assertHolds(paths, held, selector, accept);
16948
+ }
16949
+ ctx.registry.touch(record);
16950
+ const facts = paths.map(factOf);
16951
+ const page = pageOf(record.view);
16952
+ ctx.downloads.noteUpload(record.tabId, facts, siteOf(page.url));
16953
+ return {
16954
+ // The selectors that accepted files. One entry today, because the wire takes
16955
+ // one `selector`; the field is a list because the result vocabulary is shared
16956
+ // with the extension host, whose `DOM.setFileInputFiles` call can address the
16957
+ // same shape.
16958
+ inputs: [selector],
16959
+ accepted: facts,
16960
+ // The host's own word about its read: "" when it completed, a sentence when it
16961
+ // could not. The harness reports the attach as UNVERIFIED when this is set
16962
+ // (`tools/builtin.py`: `verified = count >= 0 and not readback_reported`) rather
16963
+ // than turning a completed egress into an error.
16964
+ readback,
16965
+ // The page's own identity, as every other action reports it: the harness's audit
16966
+ // row for this call records the origin the files went TO, and a host that answered
16967
+ // without it would leave that row naming nowhere.
16968
+ ...page
16969
+ };
16970
+ }
16971
+ function uploadPaths(params) {
16972
+ const raw = params.paths;
16973
+ if (!Array.isArray(raw) || raw.length === 0) {
16974
+ throw new BridgeCommandError(
16975
+ "internal",
16976
+ "upload needs a non-empty list of paths",
16977
+ { param: "paths" }
16978
+ );
16979
+ }
16980
+ const paths = [];
16981
+ for (const entry of raw) {
16982
+ if (typeof entry !== "string" || !entry.trim()) {
16983
+ throw new BridgeCommandError(
16984
+ "internal",
16985
+ "every upload path must be a non-empty string",
16986
+ { param: "paths" }
16987
+ );
16988
+ }
16989
+ const path2 = entry.trim();
16990
+ if (!path$1.isAbsolute(path2)) {
16991
+ throw new BridgeCommandError(
16992
+ "internal",
16993
+ `upload needs absolute paths; ${safeName(path2)} is not one`,
16994
+ { param: "paths" }
16995
+ );
16996
+ }
16997
+ paths.push(path2);
16998
+ }
16999
+ if (paths.length > CAPS.uploadMaxFiles) {
17000
+ throw new BridgeCommandError(
17001
+ "internal",
17002
+ `upload takes at most ${CAPS.uploadMaxFiles} files per call`,
17003
+ { param: "paths", limit: CAPS.uploadMaxFiles }
17004
+ );
17005
+ }
17006
+ return paths;
17007
+ }
17008
+ async function acceptsOf(ctx, record, nodeId) {
17009
+ const attributes = await ctx.cdp.send(
17010
+ record.view.webContents,
17011
+ "DOM.getAttributes",
17012
+ { nodeId }
17013
+ );
17014
+ const list = attributes?.attributes ?? [];
17015
+ for (let index = 0; index + 1 < list.length; index += 2) {
17016
+ if (list[index] === "accept") return list[index + 1] ?? "";
17017
+ }
17018
+ return "";
17019
+ }
17020
+ async function readBack(ctx, record, objectId) {
17021
+ const answer = await ctx.cdp.send(record.view.webContents, "Runtime.callFunctionOn", {
17022
+ objectId,
17023
+ functionDeclaration: READ_FILES_FUNCTION,
17024
+ returnByValue: true
17025
+ });
17026
+ return answer?.result?.value ?? null;
17027
+ }
17028
+ function assertHolds(paths, held, selector, accept) {
17029
+ const expected = paths.map((path2) => path$1.basename(path2));
17030
+ const mismatch = held.count !== expected.length || expected.some((name, index) => held.names[index] !== name) || paths.some((path2, index) => sizeOf(path2) !== held.sizes[index]);
17031
+ if (!mismatch) return;
17032
+ throw new BridgeCommandError(
17033
+ "internal",
17034
+ `the file input did not take the files: it holds ${summarise(held)}, and ${expected.length} were attached (${expected.join(", ")})`,
17035
+ {
17036
+ selector,
17037
+ attached: expected,
17038
+ held: held.names,
17039
+ accept,
17040
+ reason: "read_back_mismatch"
17041
+ }
17042
+ );
17043
+ }
17044
+ function summarise(held) {
17045
+ if (held.count === 0) return "nothing";
17046
+ return `${held.count} file(s) [${held.names.join(", ")}]`;
17047
+ }
17048
+ function sizeOf(path2) {
17049
+ try {
17050
+ return fs.statSync(path2).size;
17051
+ } catch {
17052
+ return -1;
17053
+ }
17054
+ }
17055
+ function siteOf(url) {
17056
+ try {
17057
+ return new URL(url).host;
17058
+ } catch {
17059
+ return "";
17060
+ }
17061
+ }
17062
+ function factOf(path2) {
17063
+ return {
17064
+ name: path$1.basename(path2),
17065
+ path: path2,
17066
+ bytes: sizeOf(path2),
17067
+ mime: "",
17068
+ sniffed: "",
17069
+ sha256: ""
17070
+ };
17071
+ }
15967
17072
  const DOCUMENT_SCOPED = /* @__PURE__ */ new Set([
15968
17073
  "read",
15969
17074
  "snapshot",
@@ -15971,7 +17076,23 @@ const DOCUMENT_SCOPED = /* @__PURE__ */ new Set([
15971
17076
  "click",
15972
17077
  "type",
15973
17078
  "scroll",
15974
- "logs"
17079
+ "logs",
17080
+ // `download` names a control on the CURRENT document (the link or button that
17081
+ // starts the file) and `upload` names a file input on it, so both are authorized
17082
+ // against that document on entry and again on the result. BOTH are in the
17083
+ // NAVIGATING_ACTIONS set below — each reaches its own control by driving the page,
17084
+ // and an auto-submitting form turns an attach into a navigation — and NEITHER is
17085
+ // in NAVIGATION_ACTIONS (the per-hop gate stays off for both); the set comments
17086
+ // say why, and the pair is what replaced the single post-hoc check the round-1
17087
+ // review refused (M2).
17088
+ "download",
17089
+ "upload"
17090
+ ]);
17091
+ const NAVIGATING_ACTIONS = /* @__PURE__ */ new Set([
17092
+ "click",
17093
+ "type",
17094
+ "download",
17095
+ "upload"
15975
17096
  ]);
15976
17097
  const NAVIGATION_ACTIONS = /* @__PURE__ */ new Set(["click", "type"]);
15977
17098
  const TAB_SCOPED = /* @__PURE__ */ new Set([
@@ -15984,7 +17105,14 @@ const TAB_SCOPED = /* @__PURE__ */ new Set([
15984
17105
  "scroll",
15985
17106
  "logs",
15986
17107
  "close",
15987
- "retitle"
17108
+ "retitle",
17109
+ // The two file verbs take the tab's lane for the same reason every other
17110
+ // tab-addressed action does, and for one more: `download` ARMS the tab for the
17111
+ // duration of the call, and two concurrent arms on one tab would be two captures
17112
+ // writing into two harness directories with no way to tell which file came from
17113
+ // which call.
17114
+ "download",
17115
+ "upload"
15988
17116
  ]);
15989
17117
  const RESTORE_CONCURRENCY = 4;
15990
17118
  const RESTORE_TAB_TIMEOUT_MS = 1e4;
@@ -16021,6 +17149,7 @@ class BrowserHost {
16021
17149
  cdp;
16022
17150
  approvals;
16023
17151
  ownership;
17152
+ downloads;
16024
17153
  log;
16025
17154
  onChanged;
16026
17155
  facts;
@@ -16044,6 +17173,7 @@ class BrowserHost {
16044
17173
  this.cdp = options.cdp;
16045
17174
  this.approvals = options.approvals;
16046
17175
  this.ownership = options.ownership;
17176
+ this.downloads = options.downloads;
16047
17177
  this.log = options.log;
16048
17178
  this.onChanged = options.onChanged;
16049
17179
  this.facts = options.facts;
@@ -16069,10 +17199,11 @@ class BrowserHost {
16069
17199
  const record = this.registry.requireSurface(token);
16070
17200
  const requester = requesterOf(params, requestId2);
16071
17201
  let authorizedOn = record.documentEpoch;
17202
+ const entryUrl = record.view.webContents.getURL();
16072
17203
  const assertDocument = () => {
16073
17204
  const url = new URL(record.view.webContents.getURL() || "about:blank");
16074
17205
  if (record.documentEpoch !== authorizedOn) {
16075
- if (!NAVIGATION_ACTIONS.has(method)) {
17206
+ if (!NAVIGATING_ACTIONS.has(method)) {
16076
17207
  this.registry.bumpEpoch(record.tabId);
16077
17208
  throw new BridgeCommandError(
16078
17209
  "origin_not_allowed",
@@ -16084,6 +17215,9 @@ class BrowserHost {
16084
17215
  }
16085
17216
  if (!permittedScheme(url) || !this.approvals.documentAllowed(token, url, requester, authorizedOn)) {
16086
17217
  this.registry.bumpEpoch(record.tabId);
17218
+ if (NAVIGATING_ACTIONS.has(method) && url.href !== entryUrl) {
17219
+ this.restoreApprovedDocument(record, entryUrl);
17220
+ }
16087
17221
  this.approvals.refuseDocument(url);
16088
17222
  }
16089
17223
  return url;
@@ -16109,6 +17243,22 @@ class BrowserHost {
16109
17243
  operation
16110
17244
  );
16111
17245
  }
17246
+ /** Put a tab back on the document a call entered on, after that call's work
17247
+ * landed it somewhere it may not hold.
17248
+ *
17249
+ * The restore is a plain load of an already-authorized URL, and it is deliberately
17250
+ * NOT awaited: the caller is on its way to a thrown refusal, and the answer the
17251
+ * model reads is about the refusal rather than about the repaint. A view that died
17252
+ * under the call needs no restore and must not turn into a second error. */
17253
+ restoreApprovedDocument(record, url) {
17254
+ if (!url || record.view.webContents.isDestroyed()) return;
17255
+ const restore = record.view.webContents.loadURL(url);
17256
+ void Promise.resolve(restore).catch((error) => {
17257
+ this.log(
17258
+ `[browser] could not restore tab ${record.tabId} to ${url} after refusing a navigation: ${String(error)}`
17259
+ );
17260
+ });
17261
+ }
16112
17262
  async perform(method, params, requestId2) {
16113
17263
  switch (method) {
16114
17264
  case "open":
@@ -16137,6 +17287,10 @@ class BrowserHost {
16137
17287
  return click(this, params);
16138
17288
  case "type":
16139
17289
  return type(this, params);
17290
+ case "download":
17291
+ return download(this, params);
17292
+ case "upload":
17293
+ return upload(this, params);
16140
17294
  case "request_access":
16141
17295
  return this.approvals.requestAccess(
16142
17296
  params.url,
@@ -16314,6 +17468,14 @@ class BrowserHost {
16314
17468
  loading: this.tabLoading(entry.tabId)
16315
17469
  })),
16316
17470
  activeTabId: activeRecord?.tabId ?? null,
17471
+ // The download surface's facts (§16.4): what is in flight, where it went and
17472
+ // what was refused, so the chrome row renders from the host's own state rather
17473
+ // than from a second copy the renderer would have to keep in step. Shipped in
17474
+ // the same projection as the strip for the reason the approvals list is: one
17475
+ // subscription is one thing that can go stale. FOR ONE TAB (the active one),
17476
+ // which is the round-1 D2 fix: a host-wide projection rendered a decision taken
17477
+ // in another tab into this one's chrome.
17478
+ transfers: this.downloads.activityFor(active?.tabId ?? null),
16317
17479
  url: active ? active.view.webContents.getURL() : "",
16318
17480
  title: active ? this.titleForChrome(active.view.webContents.getTitle()) : "",
16319
17481
  loading: active ? active.view.webContents.isLoading() : false,
@@ -16733,7 +17895,8 @@ const BROWSER_IPC_CHANNELS = [
16733
17895
  "browser-forget-site",
16734
17896
  "browser-clear-data",
16735
17897
  "browser-webauthn-respond",
16736
- "browser-webauthn-pending"
17898
+ "browser-webauthn-pending",
17899
+ "browser-reveal-downloads"
16737
17900
  ];
16738
17901
  function registerBrowserIpc(options) {
16739
17902
  function authorize(event) {
@@ -16862,6 +18025,15 @@ function registerBrowserIpc(options) {
16862
18025
  options.log(`[browser] cleared browsing data: ${what}`);
16863
18026
  return { cleared: what };
16864
18027
  });
18028
+ electron.ipcMain.handle("browser-reveal-downloads", async (event) => {
18029
+ authorize(event);
18030
+ const message = await options.revealDownloads();
18031
+ if (message)
18032
+ options.log(
18033
+ `[browser] could not reveal the download directory: ${message}`
18034
+ );
18035
+ return { opened: message === "", message };
18036
+ });
16865
18037
  }
16866
18038
  function unregisterBrowserIpc() {
16867
18039
  for (const channel of BROWSER_IPC_CHANNELS) electron.ipcMain.removeHandler(channel);
@@ -17250,15 +18422,26 @@ function installBrowserSessionHandlers(browserSession, hooks = {}) {
17250
18422
  return false;
17251
18423
  }
17252
18424
  );
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
- );
18425
+ browserSession.on("will-download", (event, item, webContents) => {
18426
+ const webContentsId = webContents?.id ?? -1;
18427
+ const decision = hooks.onDownload?.(item, webContentsId) ?? {
18428
+ cancel: true,
18429
+ reason: "background downloads are not supported"
18430
+ };
18431
+ if (decision.cancel) event.preventDefault();
18432
+ const outcome = {
18433
+ webContentsId,
18434
+ url: item.getURL(),
18435
+ filename: item.getFilename(),
18436
+ savePath: decision.cancel ? null : item.getSavePath(),
18437
+ reason: decision.reason
18438
+ };
18439
+ if (decision.cancel) {
18440
+ hooks.log?.(
18441
+ `[browser] cancelled a download from ${outcome.url}: ${decision.reason}`
18442
+ );
18443
+ }
18444
+ hooks.onDownloadDecided?.(outcome);
17262
18445
  });
17263
18446
  }
17264
18447
  async function clearBrowsingData(browserSession, what) {
@@ -17440,7 +18623,12 @@ async function handle(req, res, options) {
17440
18623
  // rest, and a newer one learns whether the console is up without a
17441
18624
  // second probe (design 10.1). `false` when nobody reported a
17442
18625
  // capability, which is the honest answer for a host that has none.
17443
- console: options.capabilities?.().console ?? false
18626
+ console: options.capabilities?.().console ?? false,
18627
+ // Additive, and the SECOND reader of the same fact (§6.3): a session that
18628
+ // finds a record it cannot trust asks here, and an old reader ignores the
18629
+ // key. It names what this build SERVES, which is what makes a typed
18630
+ // `capability_unsupported` possible without opening a socket.
18631
+ capabilities: [...HOST_CAPABILITIES]
17444
18632
  };
17445
18633
  send(res, 200, body);
17446
18634
  return;
@@ -18562,14 +19750,34 @@ function browserHostEnabled(env = process.env) {
18562
19750
  async function startBrowserHost(options) {
18563
19751
  const { log } = options;
18564
19752
  const browserSession = resolveBrowserSession();
19753
+ let registryForDownloads = null;
19754
+ const downloads = new DownloadArmer({
19755
+ tabForWebContents: (webContentsId) => registryForDownloads?.byWebContents(webContentsId)?.tabId ?? null,
19756
+ // WHO OWNED THE TAB, recorded on the decision (review round 2, U10). Read from
19757
+ // the registry at the moment the note is written, because the row may render it
19758
+ // after the tab is closed — and "· on another tab" about a tab that no longer
19759
+ // exists is a marker pointing at nothing. `null` when the lookup cannot answer
19760
+ // (a tab already gone), which the row renders the old way rather than guessing.
19761
+ ownerKindFor: (tabId) => registryForDownloads?.get(tabId)?.owner ?? null,
19762
+ log,
19763
+ onActivity: () => {
19764
+ if (options.window.isDestroyed()) return;
19765
+ options.window.webContents.send("browser-state-changed");
19766
+ }
19767
+ });
18565
19768
  installBrowserSessionHandlers(browserSession, {
18566
19769
  onPermissionRequested: (details) => {
18567
19770
  log(
18568
19771
  `[browser] denied a ${details.permission} permission request from ${details.origin || "(unknown origin)"}`
18569
19772
  );
18570
19773
  },
18571
- onDownloadAttempted: (details) => {
18572
- log(`[browser] refused a download from ${details.url}`);
19774
+ onDownload: (item, webContentsId) => downloads.decide(item, webContentsId),
19775
+ onDownloadDecided: (outcome) => {
19776
+ if (outcome.savePath) {
19777
+ log(
19778
+ `[browser] saved a download from ${outcome.url} to ${outcome.savePath}`
19779
+ );
19780
+ }
18573
19781
  },
18574
19782
  log
18575
19783
  });
@@ -18668,9 +19876,11 @@ async function startBrowserHost(options) {
18668
19876
  releaseTab,
18669
19877
  notifyChanged
18670
19878
  );
19879
+ registryForDownloads = registry;
18671
19880
  function releaseTab(tabId, webContentsId) {
18672
19881
  const view = views.get(tabId);
18673
19882
  views.delete(tabId);
19883
+ downloads.forget(tabId);
18674
19884
  void cdp.detach(webContentsId).finally(() => {
18675
19885
  releaseView(options.window, view);
18676
19886
  });
@@ -18720,6 +19930,7 @@ async function startBrowserHost(options) {
18720
19930
  cdp,
18721
19931
  approvals,
18722
19932
  ownership,
19933
+ downloads,
18723
19934
  log,
18724
19935
  onChanged: notifyChanged,
18725
19936
  facts: () => facts,
@@ -18782,6 +19993,17 @@ async function startBrowserHost(options) {
18782
19993
  host: () => host,
18783
19994
  webauthn: () => webauthn,
18784
19995
  clearData: (what) => sessionCookies.clearBrowsingData(what),
19996
+ // THE REVEAL TAKES NO PATH FROM THE RENDERER (§16.4): it opens the directory the
19997
+ // host is actually writing into, so the one place a page-derived string could
19998
+ // have become a path stays out of the IPC surface as well. `shell.openPath`
19999
+ // answers "" on success and a message on failure, which is returned rather than
20000
+ // thrown: a reveal that fails is a Finder problem, not a fault in the agent's
20001
+ // download.
20002
+ revealDownloads: async () => {
20003
+ const dir = downloads.downloadDir();
20004
+ if (!dir) return "no download directory yet";
20005
+ return electron.shell.openPath(dir);
20006
+ },
18785
20007
  log
18786
20008
  });
18787
20009
  log(
@@ -20610,6 +21832,232 @@ function clauseTextOf(message, start, end) {
20610
21832
  function stripErrorPrefixes(message) {
20611
21833
  return message.replace(LEADING_ERROR_PREFIXES, "").replace(EMBEDDED_ERROR_PREFIX, ": ");
20612
21834
  }
21835
+ const FLEET_DRAIN_BUDGET_MS = 6e5;
21836
+ const FLEET_DRAIN_POLL_MS = 5e3;
21837
+ const FLEET_RETIRE_GRACE_MS = 6e4;
21838
+ const FLEET_RETIRE_SETTLE_MS = 3e4;
21839
+ async function waitForFleetIdle(input) {
21840
+ const budgetMs = input.budgetMs ?? FLEET_DRAIN_BUDGET_MS;
21841
+ const pollMs = input.pollMs ?? FLEET_DRAIN_POLL_MS;
21842
+ const startedAt = input.now();
21843
+ let workState = await input.readWorkState();
21844
+ let waitedMs = 0;
21845
+ while (workState !== "idle") {
21846
+ waitedMs = input.now() - startedAt;
21847
+ if (waitedMs >= budgetMs) {
21848
+ const unknown = workState === "unknown";
21849
+ const refusedCredentials = unknown && input.readUnreadableReason?.() === "refused-credentials";
21850
+ return {
21851
+ kind: "refused",
21852
+ because: unknown ? "unknown" : "busy",
21853
+ waitedMs,
21854
+ busy: busyRosterRows(await input.readRoster() ?? []),
21855
+ credentialsRefused: refusedCredentials
21856
+ };
21857
+ }
21858
+ input.onWait?.(waitedMs, workState);
21859
+ await input.sleep(Math.min(pollMs, budgetMs - waitedMs));
21860
+ workState = await input.readWorkState();
21861
+ }
21862
+ return {
21863
+ kind: "drained",
21864
+ waitedMs: input.now() - startedAt,
21865
+ fleet: (await input.readRoster())?.length ?? 0
21866
+ };
21867
+ }
21868
+ function fleetDrainRefusalSentence(outcome, installLanded = false) {
21869
+ const minutes = Math.max(1, Math.round(outcome.waitedMs / 6e4));
21870
+ const waited = `${minutes} minute${minutes === 1 ? "" : "s"}`;
21871
+ 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.";
21872
+ if (outcome.because === "unknown") {
21873
+ 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.";
21874
+ return `${lead2}
21875
+
21876
+ Reading an unreadable fleet as idle could cut off a turn that is in flight, so the app waited ${waited} and then stopped. ${closing}`;
21877
+ }
21878
+ const names = outcome.busy.slice(0, 3).map((row) => row.name || row.sessionId).join(", ");
21879
+ const more = outcome.busy.length > 3 ? ` and ${outcome.busy.length - 3} more` : "";
21880
+ 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}.`;
21881
+ return `${lead}
21882
+
21883
+ The app waited ${waited} for them to finish and then stopped rather than cut a turn short. ${closing}`;
21884
+ }
21885
+ const isLiveRow = (row) => row.liveState !== "" && row.liveState !== null && row.sessionId !== "";
21886
+ const sessionHasRuntime = (rows, sessionId2) => {
21887
+ if (rows === null) return null;
21888
+ return rows.some((row) => row.sessionId === sessionId2 && isLiveRow(row));
21889
+ };
21890
+ const liveRosterIds = (rows) => {
21891
+ if (rows === null) return null;
21892
+ return new Set(rows.filter(isLiveRow).map((row) => row.sessionId));
21893
+ };
21894
+ function displacedSessions(before, after) {
21895
+ const liveNow = liveRosterIds(after);
21896
+ if (liveNow === null) return [];
21897
+ return before.filter((row) => isLiveRow(row) && !liveNow.has(row.sessionId));
21898
+ }
21899
+ function unionFleetSnapshots(first, second) {
21900
+ if (first === null) return second === null ? null : [...second];
21901
+ if (second === null) return [...first];
21902
+ const seen = new Set(first.map((row) => row.sessionId));
21903
+ return [...first, ...second.filter((row) => !seen.has(row.sessionId))];
21904
+ }
21905
+ async function reengageDisplacedSessions(input) {
21906
+ const graceMs = input.graceMs ?? FLEET_RETIRE_GRACE_MS;
21907
+ const settleMs = input.settleMs ?? FLEET_RETIRE_SETTLE_MS;
21908
+ const pollMs = input.retirePollMs ?? FLEET_DRAIN_POLL_MS;
21909
+ const startedAt = input.now();
21910
+ const snapshot2 = input.before.filter(isLiveRow);
21911
+ if (snapshot2.length === 0)
21912
+ return { displaced: [], engaged: [], failed: [], stillResident: [] };
21913
+ const snapshotIds = snapshot2.map((row) => row.sessionId);
21914
+ const stillLiveIn = (rows) => {
21915
+ const live = liveRosterIds(rows);
21916
+ if (live === null) return null;
21917
+ return new Set(snapshotIds.filter((id2) => live.has(id2)));
21918
+ };
21919
+ let after = await input.readRoster();
21920
+ let stillLive = stillLiveIn(after) ?? new Set(snapshotIds);
21921
+ let lastChangeAt = input.now();
21922
+ let waveMoved = stillLive.size < snapshotIds.length;
21923
+ for (; ; ) {
21924
+ const elapsed = input.now() - startedAt;
21925
+ if (elapsed >= graceMs) break;
21926
+ if (stillLive.size === 0) break;
21927
+ if (waveMoved && input.now() - lastChangeAt >= settleMs) break;
21928
+ await input.sleep(Math.min(pollMs, Math.max(1, graceMs - elapsed)));
21929
+ after = await input.readRoster();
21930
+ const next = stillLiveIn(after);
21931
+ if (next === null) continue;
21932
+ if (next.size !== stillLive.size || [...next].some((id2) => !stillLive.has(id2))) {
21933
+ stillLive = next;
21934
+ lastChangeAt = input.now();
21935
+ waveMoved = true;
21936
+ }
21937
+ }
21938
+ const finalRead = await input.readRoster();
21939
+ if (finalRead !== null) after = finalRead;
21940
+ const displaced = displacedSessions(input.before, after);
21941
+ const stillResident = snapshot2.filter((row) => stillLive.has(row.sessionId));
21942
+ const engaged = [];
21943
+ const failed = [];
21944
+ for (const row of displaced) {
21945
+ try {
21946
+ if (await input.engage(row)) engaged.push(row.sessionId);
21947
+ else
21948
+ failed.push({
21949
+ sessionId: row.sessionId,
21950
+ reason: "the server did not take the engage"
21951
+ });
21952
+ } catch (error) {
21953
+ failed.push({
21954
+ sessionId: row.sessionId,
21955
+ reason: error instanceof Error ? error.message : String(error)
21956
+ });
21957
+ }
21958
+ }
21959
+ input.log?.(
21960
+ `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` : ""}`
21961
+ );
21962
+ return { displaced, engaged, failed, stillResident };
21963
+ }
21964
+ const SESSION_ENGAGE_OPEN_MS = 5e3;
21965
+ const SESSION_ENGAGE_BEAT_MS = 5e3;
21966
+ const SESSION_ENGAGE_HOLD_MS = 2e4;
21967
+ const subscriptionIdIn = (frame) => {
21968
+ try {
21969
+ const parsed = JSON.parse(frame);
21970
+ if (typeof parsed !== "object" || parsed === null) return null;
21971
+ const payload = parsed.payload;
21972
+ if (typeof payload !== "object" || payload === null) return null;
21973
+ const id2 = payload.subscription_id;
21974
+ return typeof id2 === "string" && id2 !== "" ? id2 : null;
21975
+ } catch {
21976
+ return null;
21977
+ }
21978
+ };
21979
+ async function engageSessionThroughStream(input) {
21980
+ const openMs = input.openMs ?? SESSION_ENGAGE_OPEN_MS;
21981
+ const beatMs = input.beatMs ?? SESSION_ENGAGE_BEAT_MS;
21982
+ const holdMs = input.holdMs ?? SESSION_ENGAGE_HOLD_MS;
21983
+ const startedAt = input.now();
21984
+ let subscriptionId = null;
21985
+ let streamError = null;
21986
+ const handle2 = input.subscribe(input.sessionId, (event) => {
21987
+ if (event.kind === "data") {
21988
+ subscriptionId ??= subscriptionIdIn(event.data);
21989
+ return;
21990
+ }
21991
+ if (event.kind === "error") streamError ??= event.detail;
21992
+ else streamError ??= "the session's events stream ended";
21993
+ });
21994
+ try {
21995
+ while (subscriptionId === null && input.now() - startedAt < openMs) {
21996
+ if (streamError !== null) break;
21997
+ await input.sleep(
21998
+ Math.min(beatMs, Math.max(1, openMs - (input.now() - startedAt)))
21999
+ );
22000
+ }
22001
+ if (subscriptionId === null) {
22002
+ return {
22003
+ engaged: false,
22004
+ reason: streamError ?? "the session's events stream never announced a subscription",
22005
+ beats: 0
22006
+ };
22007
+ }
22008
+ const lease = await input.watch(subscriptionId);
22009
+ if (lease.status !== 200) {
22010
+ return {
22011
+ engaged: false,
22012
+ reason: `the lease answered ${lease.status}`,
22013
+ beats: 0
22014
+ };
22015
+ }
22016
+ const warm = await input.warm(input.sessionId);
22017
+ if (warm.status !== 200) {
22018
+ input.log?.(
22019
+ `The warm for ${input.sessionId} answered ${warm.status}; the lease is still held while the runtime comes up`
22020
+ );
22021
+ }
22022
+ let beats = 0;
22023
+ let cameUp = false;
22024
+ let leaseHeld = true;
22025
+ const leaseAt = input.now();
22026
+ while (input.now() - startedAt < openMs + holdMs) {
22027
+ const live = await input.hasRuntime();
22028
+ if (live === true) {
22029
+ cameUp = true;
22030
+ break;
22031
+ }
22032
+ await input.sleep(
22033
+ Math.min(
22034
+ beatMs,
22035
+ Math.max(1, openMs + holdMs - (input.now() - startedAt))
22036
+ )
22037
+ );
22038
+ if (streamError !== null) {
22039
+ if (leaseHeld) {
22040
+ leaseHeld = false;
22041
+ input.log?.(
22042
+ `The lease for ${input.sessionId} was withdrawn when its stream ended (${streamError}); the roster read still decides whether a runtime came up`
22043
+ );
22044
+ }
22045
+ continue;
22046
+ }
22047
+ await input.watch(subscriptionId);
22048
+ beats += 1;
22049
+ }
22050
+ if (cameUp) return { engaged: true, beats };
22051
+ const heldSeconds = Math.round((input.now() - leaseAt) / 1e3);
22052
+ return {
22053
+ engaged: false,
22054
+ 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`,
22055
+ beats
22056
+ };
22057
+ } finally {
22058
+ input.unsubscribe(handle2.streamId);
22059
+ }
22060
+ }
20613
22061
  const GROUP_SIGNAL_GRACE_MS = 5e3;
20614
22062
  const GROUP_EXIT_WAIT_MS = 2e4;
20615
22063
  function isInstallGroupAlive(pid) {
@@ -20936,8 +22384,8 @@ function readPlistValue(plistPath, key) {
20936
22384
  }
20937
22385
  function readStagedBundle(appPath) {
20938
22386
  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);
22387
+ const executableName2 = readPlistValue(plistPath, "CFBundleExecutable");
22388
+ const executablePath = executableName2 == null ? null : path$1.join(appPath, "Contents", "MacOS", executableName2);
20941
22389
  const architectures2 = executablePath != null && fs.existsSync(executablePath) ? (readCommandOutput("/usr/bin/lipo", ["-archs", executablePath]) ?? "").split(ARCHITECTURE_SEPARATOR_REGEX).filter((entry) => entry.length > 0) : null;
20942
22390
  return {
20943
22391
  appPath,
@@ -21439,6 +22887,53 @@ class UpdateService {
21439
22887
  * the handler).
21440
22888
  */
21441
22889
  installPreflightInFlight = false;
22890
+ /**
22891
+ * How long an update press may hold back for the fleet, and how often it
22892
+ * re-reads it.
22893
+ *
22894
+ * FIELDS rather than the module's constants read in place, because both halves
22895
+ * are waited on inside one press and the harness has to be able to drive a wait
22896
+ * in milliseconds instead of ten minutes (`scripts/update-robustness.test.mjs`,
22897
+ * the same lever `waitForBackendVersion` and the install budgets already are).
22898
+ * The values they start at are the host tool's own, in
22899
+ * `backend/fleet-drain.ts`, where the reasoning for the ten minutes lives.
22900
+ */
22901
+ fleetDrainBudgetMs = FLEET_DRAIN_BUDGET_MS;
22902
+ fleetDrainPollMs = FLEET_DRAIN_POLL_MS;
22903
+ /**
22904
+ * How long a displaced runtime gets to retire on its own before the app puts
22905
+ * its successor back, and how long the pre-swap live set must hold still first.
22906
+ *
22907
+ * Fields for the reason the pair above are: a service-level case has to drive
22908
+ * the wait in milliseconds instead of the harness's real convergence window
22909
+ * (30 s of settle inside a 60 s grace), and the numbers themselves - with the
22910
+ * harness constants they come from - are argued in `backend/fleet-drain.ts`.
22911
+ */
22912
+ fleetRetireGraceMs = FLEET_RETIRE_GRACE_MS;
22913
+ fleetRetireSettleMs = FLEET_RETIRE_SETTLE_MS;
22914
+ /**
22915
+ * The engage's own three bounds: how long the session's events stream may take
22916
+ * to announce its subscription, how often the held lease is renewed, and how
22917
+ * long the stream is held while the runtime comes up.
22918
+ *
22919
+ * Fields for the reason the four above are - a service-level case drives them in
22920
+ * milliseconds rather than paying twenty seconds per session - and the numbers
22921
+ * themselves, with the harness constants they come from, are argued in
22922
+ * `backend/session-engage.ts`.
22923
+ */
22924
+ sessionEngageOpenMs = SESSION_ENGAGE_OPEN_MS;
22925
+ sessionEngageBeatMs = SESSION_ENGAGE_BEAT_MS;
22926
+ sessionEngageHoldMs = SESSION_ENGAGE_HOLD_MS;
22927
+ /**
22928
+ * How long the press IN FLIGHT has already spent waiting for the fleet.
22929
+ *
22930
+ * The budget above is the press's, not each drain's (review round 1, m2): a
22931
+ * rebuild press drains twice - once before the install and once before the
22932
+ * restart - so a per-drain budget let one press hold the button for twenty
22933
+ * minutes while the panel promised ten. Reset at the top of every press, in
22934
+ * `updateBackend`, because the guarantee is about one press.
22935
+ */
22936
+ fleetDrainSpentMs = 0;
21442
22937
  /**
21443
22938
  * Initialize the update service
21444
22939
  * @param mainWindow - The main application window
@@ -23994,8 +25489,14 @@ ${heal.removed.join("\n")}`,
23994
25489
  * reader is talking to) was missing on the only arm whose press now
23995
25490
  * publishes a generation and restarts a daemon. It is the managed global
23996
25491
  * arm's own wording, aimed at this arm's mechanism.
25492
+ *
25493
+ * AND IT NO LONGER PROMISES A DROPPED TURN. It did, because it described
25494
+ * what the press used to do; the press now waits for the running turns to
25495
+ * finish before it moves the server (`drainFleetForUpdate`), so the honest
25496
+ * consequence is the wait and the sequence, with the cost stated as the
25497
+ * time it takes rather than as work it destroys.
23997
25498
  */
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.",
25499
+ 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
25500
  detail: `The app started this server itself (${startupMode}).`,
24000
25501
  sourceBuild: false,
24001
25502
  /*
@@ -24298,6 +25799,28 @@ ${heal.removed.join("\n")}`,
24298
25799
  * other two events already carry. It travels here too.
24299
25800
  */
24300
25801
  restartable: this.backendIsAppOwned(),
25802
+ /*
25803
+ * WHETHER THIS PRESS MOVES THE SERVER AT ALL, which is not the same
25804
+ * question as `restartable` above and is not answerable from it.
25805
+ * `restartable` says the daemon reading this app is one the app STARTED;
25806
+ * this says the press would RESTART it. On the harness's generation
25807
+ * layout the install lands in a tree no running process is reading, so
25808
+ * the app installs and announces and the server adopts the new build at
25809
+ * its own next idle - the cost sentence and the install phase's clause
25810
+ * both promised a bounce, and a promise the press does not keep is the
25811
+ * class of copy defect this panel has been reviewed for three times.
25812
+ *
25813
+ * THREE FACTS, and every one of them is already on this panel: the press
25814
+ * runs the app's own publish-and-restart only where the install it would
25815
+ * move is the app's OWN (`appOwnsInstall`, the reading below it); the
25816
+ * global `lop update` route does not bounce anything when its install is
25817
+ * a harness generation (`managedRoute`), and does when the route is the
25818
+ * in-place checkout rebuild; and a daemon this app did not start is never
25819
+ * the app's to move at all. The single case that restarts on a generation
25820
+ * install is a press with nothing left to install - the skew panel's own
25821
+ * control - which is a different offer entirely.
25822
+ */
25823
+ restartsServer: this.backendIsAppOwned() && (this.appOwnsInstall(startupMode, serving) || plan.managedRoute !== "entry-point"),
24301
25824
  /*
24302
25825
  * AND WHOSE INSTALL A PRESS WOULD MOVE (design D5), which is the other
24303
25826
  * ownership reading: `restartable` is true here on a global install too,
@@ -24644,6 +26167,259 @@ ${heal.removed.join("\n")}`,
24644
26167
  backendIsAppOwned() {
24645
26168
  return this.backendService?.servingInstall().owned.owned ?? false;
24646
26169
  }
26170
+ /**
26171
+ * Wait for the fleet this app can see to go idle, or refuse the update.
26172
+ *
26173
+ * THE ONE GATE EVERY RESTART AND EVERY IN-PLACE INSTALL GOES THROUGH. A
26174
+ * restart of the server serving this app is `stop(true)` - SIGTERM, ten
26175
+ * seconds, SIGKILL - and the daemon's own `retire.py` declines that exit for
26176
+ * exactly this reason: its shutdown cancels work it owns. The operator's rule
26177
+ * is that nothing kills runtimes en masse, so the app waits for the fleet to
26178
+ * drain before it touches anything, and REFUSES with a remedy at the end of
26179
+ * its budget rather than cutting off a turn on a timer.
26180
+ *
26181
+ * WHY THE MANAGER'S OWN READERS. `servingWorkState` is the app's existing
26182
+ * busy signal (see `servingWorkStateFromSessions`) and `servingSessionFleet`
26183
+ * is the same route with the rows kept; this method adds the WAIT and the
26184
+ * refusal, not a second notion of what is running.
26185
+ *
26186
+ * A REFUSAL, NOT A FAILURE: it answers the press with the sentence that says
26187
+ * what the app was waiting for, on the same channel the other refusals use
26188
+ * (`backend-update-error`), and the install it guarded does not happen.
26189
+ *
26190
+ * @returns true when the fleet drained (or nothing is running in it), false
26191
+ * when the update was refused and the caller must not proceed
26192
+ */
26193
+ async drainFleetForUpdate(input) {
26194
+ const { backend, what } = input;
26195
+ const spentMs = this.fleetDrainSpentMs;
26196
+ const remainingMs = Math.max(0, this.fleetDrainBudgetMs - spentMs);
26197
+ const outcome = await waitForFleetIdle({
26198
+ readWorkState: () => backend.servingWorkState(),
26199
+ readRoster: () => backend.servingSessionFleet(),
26200
+ // The transport's own reading of WHY an unreadable roster was
26201
+ // unreadable, read only if a refusal is composed from one.
26202
+ readUnreadableReason: () => backend.fleetReadFailureReason(),
26203
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
26204
+ now: () => Date.now(),
26205
+ budgetMs: remainingMs,
26206
+ pollMs: this.fleetDrainPollMs,
26207
+ /*
26208
+ * The panel is told the update is WAITING, and it is told before the
26209
+ * first poll rather than after the tenth: a press that means to install
26210
+ * and instead sits still for minutes is the silence this panel's copy
26211
+ * exists to remove. The ELAPSED reading travels with it (design D3): a
26212
+ * ten-minute wait with nothing moving on the frame is indistinguishable
26213
+ * from a hung app, and `waitedMs` is the one number the app already has.
26214
+ */
26215
+ onWait: (elapsedMs) => {
26216
+ this.sendToRenderer("backend-update-progress", {
26217
+ phase: "draining",
26218
+ waitedMs: spentMs + elapsedMs
26219
+ });
26220
+ logger.info(
26221
+ `Update waiting for the fleet to drain before it may ${what} (${Math.round(elapsedMs / 1e3)}s so far)`,
26222
+ LogFileType.UPDATE_SERVICE
26223
+ );
26224
+ }
26225
+ });
26226
+ this.fleetDrainSpentMs = spentMs + outcome.waitedMs;
26227
+ if (outcome.kind === "drained") {
26228
+ logger.info(
26229
+ `The fleet is idle (${outcome.fleet} session(s) on the roster, ${outcome.waitedMs}ms waited); the update may ${what}`,
26230
+ LogFileType.UPDATE_SERVICE
26231
+ );
26232
+ return true;
26233
+ }
26234
+ const message = fleetDrainRefusalSentence(
26235
+ outcome,
26236
+ input.installLanded === true
26237
+ );
26238
+ const command = input.command ?? null;
26239
+ const pressWaitedMs = spentMs + outcome.waitedMs;
26240
+ logger.warn(
26241
+ `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}` : ""}`,
26242
+ LogFileType.UPDATE_SERVICE
26243
+ );
26244
+ this.sendToRenderer("backend-update-error", {
26245
+ message,
26246
+ phase: "update",
26247
+ logPath: serverUpdateLogPath(),
26248
+ /*
26249
+ * WHAT WAS WAITED FOR, and the choice made instead of forcing: this is
26250
+ * what lets the panel paint a HELD-BACK update rather than a failed one
26251
+ * (review round 1, D1) and put the sessions it waited for, and the
26252
+ * command that skips the wait, where a reader can act on them.
26253
+ */
26254
+ refusal: {
26255
+ because: outcome.because,
26256
+ waitedMs: pressWaitedMs,
26257
+ command,
26258
+ credentialsRefused: outcome.credentialsRefused === true,
26259
+ /*
26260
+ * WHICH REFUSAL THIS IS (design round 2, D6). The heading is the reader's
26261
+ * takeaway from a panel they have been looking at for ten minutes, and the
26262
+ * three refusal sites are not the same event: the install leg's refusal left
26263
+ * nothing on disk, while the two restart-leg refusals happen AFTER the build
26264
+ * landed. "The update didn't start" is false on the second pair - the
26265
+ * producer's own doc for `installLanded` says so - so the fact travels with
26266
+ * the report and the panel keys its heading on it.
26267
+ */
26268
+ installLanded: input.installLanded === true
26269
+ }
26270
+ });
26271
+ return false;
26272
+ }
26273
+ /**
26274
+ * Put back the runtimes a restart displaced, and say what came back.
26275
+ *
26276
+ * Step 5 of the host tool's own order (`~/tools/lop-fleet-update`): the app
26277
+ * snapshots the fleet, drains it, moves the server, and then re-engages what
26278
+ * the move displaced - the unwatched `daemon`-kind sessions in particular,
26279
+ * which nothing else revives. `reengageDisplacedSessions` owns the retire wait
26280
+ * and the ordering rules.
26281
+ *
26282
+ * BEST EFFORT AND NEVER A BLOCKER: this runs after the server is healthy and
26283
+ * the update has already been reported, so a displace list that will not come
26284
+ * back is a logged fact rather than a failed update. That is deliberate - a
26285
+ * `warm` the daemon refuses must not turn a landed update into an error panel.
26286
+ */
26287
+ async reengageFleetAfterRestart(backend, before) {
26288
+ try {
26289
+ const result = await reengageDisplacedSessions({
26290
+ before,
26291
+ readRoster: () => backend.servingSessionFleet(),
26292
+ engage: (row) => this.engageSessionRuntime(backend, row),
26293
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
26294
+ now: () => Date.now(),
26295
+ graceMs: this.fleetRetireGraceMs,
26296
+ settleMs: this.fleetRetireSettleMs,
26297
+ retirePollMs: this.fleetDrainPollMs,
26298
+ log: (line) => logger.info(line, LogFileType.UPDATE_SERVICE)
26299
+ });
26300
+ if (result.displaced.length === 0) {
26301
+ if (result.stillResident.length > 0) {
26302
+ logger.info(
26303
+ `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`,
26304
+ LogFileType.UPDATE_SERVICE
26305
+ );
26306
+ }
26307
+ return;
26308
+ }
26309
+ if (result.failed.length > 0) {
26310
+ logger.warn(
26311
+ `${result.failed.length} displaced session(s) did not come back after the restart: ${result.failed.map((row) => `${row.sessionId} (${row.reason})`).join(", ")}`,
26312
+ LogFileType.UPDATE_SERVICE
26313
+ );
26314
+ }
26315
+ } catch (error) {
26316
+ logger.warn(
26317
+ `Could not re-engage the sessions a restart displaced: ${error instanceof Error ? error.message : String(error)}`,
26318
+ LogFileType.UPDATE_SERVICE
26319
+ );
26320
+ }
26321
+ }
26322
+ /**
26323
+ * Start one session's runtime again, without submitting anything to it.
26324
+ *
26325
+ * THE APP'S OWN PATH FOR OPENING A SESSION (review round 2, R2-M1, which is the
26326
+ * finding that this call was a no-op against the shipped daemon). What this used
26327
+ * to do - post `sessions.watch` with a freshly generated subscription id and then
26328
+ * post `sessions.warm` - breaks BOTH preconditions the two routes carry:
26329
+ *
26330
+ * - **The lease must name a subscription the bridge KNOWS.** The id is minted
26331
+ * server-side by an events subscription (`DesktopSessionBridge.subscribe`) and
26332
+ * `watch` looks it up in that table, raising `KeyError` for one it has never
26333
+ * seen - which the route ladder answers as a **404**, one request before the
26334
+ * warm. A random id therefore never leased anything, and the engage reported
26335
+ * "did not answer" about a call it could have known would fail.
26336
+ * - **A warm only survives while something else holds the bridge.** The bridge is
26337
+ * reference-counted by IN-FLIGHT REQUESTS; a warm issued while nothing holds it
26338
+ * is cancelled the moment its own request returns (`routes/desktop_sessions.py`'s
26339
+ * `warm` docstring, pinned by
26340
+ * `tests/unit/server/test_desktop_sessions.py::test_a_warm_survives_its_own_request_while_a_subscriber_holds_the_bridge`).
26341
+ * The renderer's panel path works because a mounted `SessionPanel` holds an
26342
+ * EVENTS STREAM for its whole life; a one-shot warm from main held nothing, so
26343
+ * the 200 said the call was admitted, not that a runtime exists.
26344
+ *
26345
+ * So the engage is what CLICKING THE SESSION does, through the app's own relay:
26346
+ * hold the session's events stream, take the subscription id its `open` frame
26347
+ * carries, lease that id as a VISIBLE watch (a live visible lease is what CREATES
26348
+ * residency for a cold session - `DesktopSessionBridge.refresh_watch`), warm it,
26349
+ * and keep holding until the roster says the runtime is there.
26350
+ * `session-engage.ts` owns that rule and the evidence each leg rests on.
26351
+ *
26352
+ * AND THE BOOLEAN IS A CLAIM ABOUT THE MACHINE, not about a status code: every
26353
+ * route on this path answers 200 to a call it will not act on, so what decides the
26354
+ * outcome is the same roster read the fleet gate uses (`sessionHasRuntime`). The
26355
+ * caller's "re-engaged N of M" log line is worth exactly that much.
26356
+ *
26357
+ * `visible: true` IS ONLY SAFE WHERE A PRESENCE RECORD EXISTS. The runtime prefers
26358
+ * this app's machine-wide presence record and falls back to exactly this
26359
+ * per-connection flag (`session/runtime/server.py::_desktop_visible`), so on a
26360
+ * machine with no record the app is telling a session's runtime that somebody is
26361
+ * looking at a session nobody has open - which can suppress a banner for it
26362
+ * (`_visible_attach_surfaces`). It is kept because the lease-driven warm requires
26363
+ * a VISIBLE lease (`_lease_warm_loop`), and it is the flag the renderer's own beat
26364
+ * sends for a session it really is showing.
26365
+ *
26366
+ * NOT `sessions.message`, deliberately: a message would admit a turn in every
26367
+ * conversation this machine holds, which is work and spend the user did not ask
26368
+ * for, and the drain that ran before the restart is what makes a notice about
26369
+ * interrupted work unnecessary - by construction nothing was mid-turn when the
26370
+ * server moved.
26371
+ */
26372
+ async engageSessionRuntime(backend, row) {
26373
+ const relay = backend.getStreamRelay();
26374
+ return await engageSessionThroughStream({
26375
+ sessionId: row.sessionId,
26376
+ subscribe: (sessionId2, emit) => relay.subscribe({ sessionId: sessionId2 }, (event) => {
26377
+ if (event.kind === "data") {
26378
+ emit({ kind: "data", data: event.data });
26379
+ return;
26380
+ }
26381
+ if (event.kind === "error") {
26382
+ emit({ kind: "error", detail: event.detail });
26383
+ return;
26384
+ }
26385
+ emit({ kind: "end" });
26386
+ }),
26387
+ unsubscribe: (streamId) => relay.unsubscribe(streamId),
26388
+ watch: (subscriptionId) => backend.requestDesktop({
26389
+ op: "sessions.watch",
26390
+ sessionId: row.sessionId,
26391
+ subscriptionId,
26392
+ visible: true,
26393
+ canNotify: false
26394
+ }),
26395
+ warm: (sessionId2) => backend.requestDesktop({ op: "sessions.warm", sessionId: sessionId2 }),
26396
+ hasRuntime: async () => sessionHasRuntime(await backend.servingSessionFleet(), row.sessionId),
26397
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
26398
+ now: () => Date.now(),
26399
+ openMs: this.sessionEngageOpenMs,
26400
+ beatMs: this.sessionEngageBeatMs,
26401
+ holdMs: this.sessionEngageHoldMs,
26402
+ log: (line) => logger.info(line, LogFileType.UPDATE_SERVICE)
26403
+ }).then((outcome) => {
26404
+ if (!outcome.engaged) {
26405
+ logger.warn(
26406
+ `The re-engage of ${row.sessionId} did not start a runtime: ${outcome.reason ?? "no reason given"}`,
26407
+ LogFileType.UPDATE_SERVICE
26408
+ );
26409
+ }
26410
+ return outcome.engaged;
26411
+ });
26412
+ }
26413
+ /**
26414
+ * The fleet as it is now, for the before/after pair a restart is judged on.
26415
+ *
26416
+ * Null is a roster that could not be read, and every caller treats it as "no
26417
+ * snapshot": a re-engage with no `before` has nothing to put back, and
26418
+ * inventing one would re-engage sessions that were never displaced.
26419
+ */
26420
+ async readFleetSnapshot(backend) {
26421
+ return backend.servingSessionFleet();
26422
+ }
24647
26423
  /**
24648
26424
  * The build the daemon serving this app BOOTED with, or null when its record
24649
26425
  * carries no reading.
@@ -25047,6 +26823,14 @@ ${heal.removed.join("\n")}`,
25047
26823
  const budgetMs = rebuildRoute ? SOURCE_REBUILD_TIMEOUT_MS : GLOBAL_UPDATE_TIMEOUT_MS;
25048
26824
  const before = this.readInstallVersionAt(installPath) ?? plan.installedInstallVersion;
25049
26825
  const markerBefore = rebuildRoute ? this.readRebuildMarkerState(installPath) : null;
26826
+ const fleetBeforeInstall = rebuildRoute ? await this.readFleetSnapshot(backend) : null;
26827
+ if (rebuildRoute && !await this.drainFleetForUpdate({
26828
+ backend,
26829
+ command: freshPlan.updateCommand ?? null,
26830
+ what: "install"
26831
+ })) {
26832
+ return false;
26833
+ }
25050
26834
  this.sendToRenderer("backend-update-progress", {
25051
26835
  phase: "installing",
25052
26836
  sourceRebuild: rebuildRoute
@@ -25139,7 +26923,9 @@ ${heal.removed.join("\n")}`,
25139
26923
  `Global install updated: ${before ?? "unknown"} -> ${after ?? "unknown"}`,
25140
26924
  LogFileType.UPDATE_SERVICE
25141
26925
  );
25142
- if (backend.isUsingExternalBackend()) {
26926
+ const generationInstall = freshPlan.managedRoute === "entry-point";
26927
+ const installAlreadyCurrent = !rebuildRoute && before !== null && after !== null && before === after;
26928
+ if (backend.isUsingExternalBackend() || generationInstall && !installAlreadyCurrent) {
25143
26929
  const health = await this.getInstalledBackendVersion();
25144
26930
  const running2 = this.eventRunningVersion(health);
25145
26931
  logger.info(
@@ -25160,14 +26946,30 @@ ${heal.removed.join("\n")}`,
25160
26946
  });
25161
26947
  return true;
25162
26948
  }
26949
+ if (!await this.drainFleetForUpdate({
26950
+ backend,
26951
+ command: freshPlan.updateCommand ?? null,
26952
+ what: "restart",
26953
+ /*
26954
+ * A press with nothing left to install has no install to report as
26955
+ * landed, and saying one was would be the one false thing in the
26956
+ * refusal.
26957
+ */
26958
+ installLanded: !installAlreadyCurrent
26959
+ })) {
26960
+ logger.info(
26961
+ 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.",
26962
+ LogFileType.UPDATE_SERVICE
26963
+ );
26964
+ return false;
26965
+ }
25163
26966
  logger.info(
25164
26967
  "Restarting backend service onto the updated install...",
25165
26968
  LogFileType.UPDATE_SERVICE
25166
26969
  );
25167
26970
  this.sendToRenderer("backend-update-progress", { phase: "restarting" });
25168
- backend.setAutoUpdating(true);
26971
+ const fleetBeforeRestart = await this.readFleetSnapshot(backend);
25169
26972
  const restartSuccess = await backend.restart();
25170
- backend.setAutoUpdating(false);
25171
26973
  if (!restartSuccess) {
25172
26974
  logger.error(
25173
26975
  "Backend service restart failed after the global install update",
@@ -25220,6 +27022,13 @@ ${heal.removed.join("\n")}`,
25220
27022
  `Backend reports version ${reported} after the global install update`,
25221
27023
  LogFileType.UPDATE_SERVICE
25222
27024
  );
27025
+ const reengageSnapshot = unionFleetSnapshots(
27026
+ fleetBeforeInstall,
27027
+ fleetBeforeRestart
27028
+ );
27029
+ if (reengageSnapshot !== null) {
27030
+ await this.reengageFleetAfterRestart(backend, reengageSnapshot);
27031
+ }
25223
27032
  this.sendToRenderer("backend-update-completed", {
25224
27033
  installVersion: after,
25225
27034
  runningVersion: reported,
@@ -25302,6 +27111,12 @@ ${heal.removed.join("\n")}`,
25302
27111
  }
25303
27112
  async updateBackend(targetVersion) {
25304
27113
  logger.info("Updating backend...", LogFileType.UPDATE_SERVICE);
27114
+ const backend = this.backendService;
27115
+ const heldElsewhere = backend?.checkIsAutoUpdating?.() === true;
27116
+ if (!heldElsewhere) {
27117
+ this.fleetDrainSpentMs = 0;
27118
+ backend?.setAutoUpdating(true);
27119
+ }
25305
27120
  try {
25306
27121
  if (!this.backendService) {
25307
27122
  logger.error(
@@ -25421,6 +27236,8 @@ ${heal.removed.join("\n")}`,
25421
27236
  });
25422
27237
  }
25423
27238
  return false;
27239
+ } finally {
27240
+ if (!heldElsewhere) backend?.setAutoUpdating(false);
25424
27241
  }
25425
27242
  }
25426
27243
  /**
@@ -25563,6 +27380,15 @@ ${reason}` : ""}`,
25563
27380
  });
25564
27381
  return true;
25565
27382
  }
27383
+ const fleetAfterPublish = await this.readFleetSnapshot(backend);
27384
+ if (!await this.drainFleetForUpdate({
27385
+ backend,
27386
+ command: null,
27387
+ what: "restart",
27388
+ installLanded: outcome.replaced
27389
+ })) {
27390
+ return false;
27391
+ }
25566
27392
  logger.info(
25567
27393
  "Restarting backend service onto the published environment...",
25568
27394
  LogFileType.UPDATE_SERVICE
@@ -25570,9 +27396,8 @@ ${reason}` : ""}`,
25570
27396
  if (willPublish) {
25571
27397
  this.sendToRenderer("backend-update-progress", { phase: "restarting" });
25572
27398
  }
25573
- backend.setAutoUpdating(true);
27399
+ const fleetBeforeRestart = await this.readFleetSnapshot(backend);
25574
27400
  const restarted = await backend.restart();
25575
- backend.setAutoUpdating(false);
25576
27401
  const healthy = restarted ? await this.checkBackendHealth() : false;
25577
27402
  if (!restarted || !healthy) {
25578
27403
  logger.error(
@@ -25648,6 +27473,13 @@ ${reason}` : ""}`,
25648
27473
  `The server serving this app reports ${running} after the app-managed environment update`,
25649
27474
  LogFileType.UPDATE_SERVICE
25650
27475
  );
27476
+ const reengageSnapshot = unionFleetSnapshots(
27477
+ fleetAfterPublish,
27478
+ fleetBeforeRestart
27479
+ );
27480
+ if (reengageSnapshot !== null) {
27481
+ await this.reengageFleetAfterRestart(backend, reengageSnapshot);
27482
+ }
25651
27483
  this.sendToRenderer("backend-update-completed", {
25652
27484
  installVersion,
25653
27485
  runningVersion: running,