@phnx-labs/agents-cli 1.22.70 → 1.22.71

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 (72) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +31 -1
  3. package/dist/bootstrap.js +4 -4
  4. package/dist/commands/repo.js +2 -2
  5. package/dist/commands/sessions-export.d.ts +5 -1
  6. package/dist/commands/sessions-export.js +100 -24
  7. package/dist/commands/sessions-import.d.ts +2 -1
  8. package/dist/commands/sessions-import.js +85 -21
  9. package/dist/lib/accounting/usage-sync.d.ts +1 -1
  10. package/dist/lib/accounting/usage-sync.js +3 -3
  11. package/dist/lib/browser/ipc.d.ts +34 -0
  12. package/dist/lib/browser/ipc.js +140 -19
  13. package/dist/lib/browser/types.d.ts +3 -1
  14. package/dist/lib/daemon/auth-sync-service.js +1 -1
  15. package/dist/lib/daemon/browser-task-reap-service.js +1 -1
  16. package/dist/lib/daemon/daemon.js +13 -3
  17. package/dist/lib/daemon/heartbeat-service.js +3 -3
  18. package/dist/lib/daemon/keychain-reap-service.js +1 -1
  19. package/dist/lib/daemon/runner.d.ts +18 -1
  20. package/dist/lib/daemon/runner.js +231 -78
  21. package/dist/lib/daemon/self-heal-service.js +13 -3
  22. package/dist/lib/daemon/self-update-service.d.ts +174 -0
  23. package/dist/lib/daemon/self-update-service.js +353 -0
  24. package/dist/lib/daemon/state-dir-check-service.js +3 -3
  25. package/dist/lib/daemon/usage-sync-service.js +1 -1
  26. package/dist/lib/daemon/watchdog-service.js +4 -4
  27. package/dist/lib/daemon-services.d.ts +1 -1
  28. package/dist/lib/daemon-services.js +5 -0
  29. package/dist/lib/device-config.d.ts +12 -1
  30. package/dist/lib/device-config.js +63 -13
  31. package/dist/lib/exec-bounded.d.ts +52 -0
  32. package/dist/lib/exec-bounded.js +113 -0
  33. package/dist/lib/feed/events.d.ts +22 -14
  34. package/dist/lib/feed/events.js +84 -44
  35. package/dist/lib/fleet-shared-state.d.ts +12 -5
  36. package/dist/lib/fleet-shared-state.js +50 -20
  37. package/dist/lib/fs-atomic.d.ts +11 -0
  38. package/dist/lib/fs-atomic.js +60 -0
  39. package/dist/lib/hosts/reconcile.d.ts +11 -4
  40. package/dist/lib/hosts/reconcile.js +31 -5
  41. package/dist/lib/project-resources.d.ts +12 -0
  42. package/dist/lib/project-resources.js +129 -0
  43. package/dist/lib/routine-process-cleanup.d.ts +2 -2
  44. package/dist/lib/routine-process-cleanup.js +45 -34
  45. package/dist/lib/secrets/reaper.d.ts +2 -2
  46. package/dist/lib/secrets/reaper.js +13 -10
  47. package/dist/lib/secrets/reserved-sync.d.ts +1 -1
  48. package/dist/lib/secrets/reserved-sync.js +4 -4
  49. package/dist/lib/self-update.d.ts +21 -8
  50. package/dist/lib/self-update.js +54 -31
  51. package/dist/lib/session/sync/backend.d.ts +61 -0
  52. package/dist/lib/session/sync/backend.js +89 -0
  53. package/dist/lib/session/sync/managed-config.d.ts +29 -0
  54. package/dist/lib/session/sync/managed-config.js +23 -0
  55. package/dist/lib/session/sync/managed-key.d.ts +45 -0
  56. package/dist/lib/session/sync/managed-key.js +128 -0
  57. package/dist/lib/session/sync/net-client.d.ts +65 -0
  58. package/dist/lib/session/sync/net-client.js +117 -0
  59. package/dist/lib/session/sync/provision.d.ts +19 -0
  60. package/dist/lib/session/sync/provision.js +38 -0
  61. package/dist/lib/session/sync/r2.d.ts +5 -2
  62. package/dist/lib/session/sync/r2.js +5 -2
  63. package/dist/lib/session/sync/worker-template.d.ts +6 -0
  64. package/dist/lib/session/sync/worker-template.js +847 -0
  65. package/dist/lib/tmux/orphan-reap.js +6 -4
  66. package/dist/lib/tmux/session.js +4 -1
  67. package/dist/lib/traces/classify.d.ts +8 -1
  68. package/dist/lib/traces/insights.d.ts +13 -1
  69. package/dist/lib/traces/insights.js +78 -3
  70. package/dist/lib/traces/sync.js +8 -3
  71. package/dist/lib/traces/worker-template.js +9 -5
  72. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.71
4
+
5
+ - **The daemon now keeps itself current instead of running stale code for days (PHNX-3695).** The long-running daemon (`agents __daemon-run`) deliberately opted out of the interactive CLI's auto-update (`AGENTS_CLI_DISABLE_AUTO_UPDATE=1` forced on for `__daemon-run`), so new agents-cli code never reached a running daemon until a human ran `agents daemon restart` — R5 ("the installed CLI auto-updates") silently did not hold for the one process that runs unattended the longest. A new supervised `self-update` service checks npm roughly every 75 minutes, installs and byte-verifies a newer version with the same primitives `agents upgrade` already uses (never a bare `npm install -g`), best-effort pulls the `.system` companion repo and reconciles with `agents sync --local`, then exits so the OS supervisor (launchd `KeepAlive` / systemd `Restart=always`) relaunches it onto the new code — clients reconnect on their own, and the scheduler's atomic `(routine, scheduledFor)` claim dedupes any routine mid-fire across the restart. It fails closed on every step: an install or verify failure leaves the running daemon untouched and retries next tick, and it no-ops on a dev build or a shadowed install. A version-skewed browser-IPC client no longer just prints "run agents daemon restart" — `reconcileDaemonVersion` now asks the daemon to run this same fail-closed path on demand (`request-self-update`), sharing one in-flight attempt with the periodic tick so concurrent requests can't race two installs. That on-demand trigger is DECOUPLED from the install: because every version-skewed `agents browser <verb>` routes through it, the daemon kicks the self-update off in the background and answers "triggered" immediately rather than making the browser verb wait out the whole check→download→install→verify (tens of seconds, worst case ~15 min) — the daemon installs, verifies, and exits on its own, and the browser reconnects. Source: `cli/src/lib/daemon/self-update-service.ts`, `cli/src/lib/browser/ipc.ts`.
6
+
7
+ - **The daemon's event loop is no longer starved by synchronous subprocess spawns on its service ticks (PHNX-3695).** Every daemon background service runs on one Node event loop, so a synchronous `execFileSync`/`readFileSync` inside a service tick froze that loop for the whole call — and while frozen the supervisor's per-tick deadline timer could not fire and the browser IPC server could not answer, which is the "accept but never reply" wedge the trivial `version` probe (`cli/src/lib/browser/ipc.ts`) surfaces. A new async, deadline-bounded, process-group-killable exec helper (`cli/src/lib/exec-bounded.ts` `execFileBounded`) replaces the tick-reachable sync spawns. The heartbeat tick is now fully async: it reaps exited routine children through `reapExitedRunningJobs` (an async twin of the sync `monitorRunningJobs`, sharing one reconciliation core so the CLI `routines list/status` builders keep their synchronous, off-loop path) with an `execFileBounded`-bounded `ps` identity probe and an async `sshExecAsync` read of a `host:`-placed run's remote exit (a synchronous `ssh` there would freeze the loop on a host timeout), plus the async terminal-routine reaper; the self-heal and state-dir-check service ticks moved their `existsSync`/`readFileSync` to `fs/promises`. The worst halt was the event-log **file lock** (`withFileLock` → `lockfile.lockSync` + `Atomics.wait`, up to 30s), reached from every `ctx.log` on every tick as well as the watchdog and routine-reaper emits; a new `withFileLockAsync` + `emitAsync` (`cli/src/lib/fs-atomic.ts`, `cli/src/lib/feed/events.ts`) acquire it without freezing the loop. The other unconditional every-tick halts are converted the same way: usage-sync/auth-sync publish through `updateFleetSharedDeviceStateAsync`, keychain-reap's whole-process-table `ps` uses `execFileBounded`, watchdog/browser-task-reap read config via async paths, and tmux-reap's `/proc` scan uses `fs/promises`. A structural guard test (`cli/src/lib/daemon/guard-no-sync-io.test.ts`) scans every `*-service.ts` tick/start body for a synchronous fs/exec/lock call AND pins that each hot tick call site uses its async variant (failing if a fix is reverted to its sync twin). Startup and stop/uninstall lifecycle code (pid/lock, `launchctl`/`systemctl`, the `ps` daemon-identity probe) stays synchronous — it runs in a short-lived CLI process, never on the served loop. Source: `cli/src/lib/{exec-bounded,fs-atomic,device-config,fleet-shared-state}.ts`, `cli/src/lib/feed/events.ts`, `cli/src/lib/secrets/{reaper,reserved-sync}.ts`, `cli/src/lib/accounting/usage-sync.ts`, `cli/src/lib/tmux/{session,orphan-reap}.ts`, `cli/src/lib/daemon/{runner,daemon,heartbeat-service,watchdog-service,usage-sync-service,auth-sync-service,keychain-reap-service,browser-task-reap-service,self-heal-service,state-dir-check-service}.ts`, `cli/src/lib/hosts/reconcile.ts`, `cli/src/lib/routine-process-cleanup.ts`, `cli/src/lib/daemon/AGENTS.md`.
8
+
9
+ - **The required PR check no longer reinstalls 273 packages on every run.** Measured across recent Linux required runs, `bun install` took 14–22s on *every* run — whether the impact plan selected 78 test files or 2 — while a release check's actual test execution is 0.27s. The install was a fixed cost every pull request paid, and the largest single item in the required-check budget. `tests.yml` now restores `~/.bun/install/cache`, `cli/node_modules`, and `packages/session-tracker/node_modules` from an `actions/cache` keyed on **both** `cli/bun.lock` and `packages/session-tracker/bun.lock` — keying on the CLI lockfile alone would have left the session-tracker tree outside its own key. The key also carries a `node -v`/`bun -v` fingerprint, because `cli/package.json` trusts a native addon (`@homebridge/node-pty-prebuilt-multiarch`) whose prebuilt binary is Node-ABI-specific and nothing pins the runner's Node. `restore-keys` is deliberately omitted: a prefix fallback would let bun install on top of a *different* lockfile's modules, and `--frozen-lockfile` only guarantees the tree matches the lock from a clean or exact-match state — correctness beats a warmer cache when an attestation binds the tested tree to the published tarball. A dependency change misses the cache and reinstalls, which is exactly the previous behavior, so a miss can never be slower than not caching. Source: `.github/workflows/tests.yml`.
10
+
11
+ - **The CI dependency cache now actually hits.** The cache added a change earlier was dead weight in practice: GitHub scopes caches per ref — a run can restore only from its own branch or the default branch — and the required `test` job runs on `pull_request` only, so every entry it saved landed under `refs/pull/<N>/merge`, invisible to every other pull request. Measured after it shipped: `deps-cache-hit=0` with `packages installed [21.63s]` on unrelated PRs, i.e. a permanent cold miss for everyone except a re-run of the same PR. A push-to-`main` `warm-dep-cache` job now writes the identical key and paths, so the first PR after any lockfile or toolchain change pays the install once and every later PR restores it. The job is push-only and never joins the required check identity. `tests-gate.test.ts` pins the two keys equal, because drift between them would be silent — the warm job would populate an entry the required job never asks for, and the cache would look healthy while never hitting. Source: `.github/workflows/tests.yml`.
12
+
13
+ - **Managed session backup: harden the storage boundary (PHNX-3726).** Follow-up security fixes to the managed sessions Worker landed in the same ticket. The Worker is now **Phoenix-only** — the static `WRITE_TOKEN` principal is removed (and no longer provisioned): the zero-knowledge `--byo` path talks to your own R2 bucket directly and never reaches this Worker, so a token here was only a Phoenix-and-quota bypass. Mandatory encryption is now enforced at the storage boundary, not just client-side: a PUT whose body is not an encrypted bundle is rejected `422`, and a managed restore fails loud on any plaintext object instead of importing it. DELETE now refunds the per-user quota ledger via a delta CAS applied BEFORE the object is removed, and fails loud (503, object left intact and charged) when that CAS stays contended — instead of deleting first and silently dropping the refund on a contended ledger. Source: `cli/src/lib/session/sync/{worker-template,provision}.ts`, `cli/src/commands/sessions-import.ts`, `cli/docs/specifications.md`.
14
+
15
+ - **Signed-in users back sessions up off-box with NO `r2.backups` bucket to set up (PHNX-3726).** `agents sessions export --to-r2` / `import --from-r2` now default to a managed, Phoenix-gated store (`sessions.agents-cli.sh`) when you are signed in (`agents auth login`) — the same zero-setup pattern `agents artifacts share` and `agents traces sync` already use, resolved through the one shared `selectStorageBackendKind` policy. It is **managed-first**: a signed-in user backs up to managed even with a stale `r2.backups` bundle present; only `--byo` / `AGENTS_SESSIONS_BACKEND=byo` flips to your own bucket. Every managed backup is **mandatorily** AES-256-GCM encrypted under a per-account key that is minted once, cached at `~/.agents/.cache/state/sessions-backup-key.json` (mode 0600), and escrowed at the bearer-gated, immutable Worker key `<userId>/__key/backup-dek` so a fresh box signing in recovers it and decrypts prior backups with zero setup — the managed path never uploads plaintext. Both the command and Worker reject plaintext/malformed managed objects, and restore revalidates every at-rest bundle before decryption. Honest trust boundary: confidential vs a raw R2/Cloudflare bucket read (ciphertext at rest) but NOT zero-knowledge vs Phoenix (the DEK is escrowed on Phoenix infra); `--byo` keeps the zero-knowledge own-bucket path (the key stays only in your `r2.backups` bundle). The isolated Worker verifies the Phoenix bearer on every PUT/GET/LIST/DELETE, enforces `segments[0] == userId` (401/403, no public GET), and meters a per-user CAS quota (`__usage`, 413 over quota). Same-key managed PUT/DELETE operations share an expiring R2 lease; every object change is etag-conditioned, DELETE leaves a GET-404/LIST-hidden tombstone, and expired recovery fences the predecessor's etag before settling quota. The ledger retains an idempotent pending delta plus a terminal generation per path in the same CAS object, preventing late predecessor charges, replacement deletes, double refunds, stranded paths, and phantom quota. A CAS-reserved historical-path budget is consumed before persistent per-path state is created and is not refunded on DELETE, so authenticated create/delete churn cannot grow tombstones, leases, or ledger history past the hard cap. Reserved `__usage`/`__key` prefixes are never listed. The BYO object layout is byte-for-byte unchanged (the `<userId>/` prefix is added inside the client) and the Worker is integration-tested against real workerd. Source: `cli/src/lib/session/sync/{backend,managed-config,managed-key,net-client,provision,worker-template}.ts`, `cli/src/commands/sessions-export.ts`, `cli/src/commands/sessions-import.ts`, `cli/docs/{sessions,specifications}.md`.
16
+
17
+ - **Daemon: a host-placed run finishing on the heartbeat tick no longer freezes the event loop (PHNX-3727).** Fast-follow to PHNX-3695. `finalizeHostRunAsync` (the async, tick-reachable finalize path reached via `reapExitedRunningJobs`) called `applyHealedHostRun`, which ended by emitting routine-end through the **synchronous** `emitRoutineEnd` — whose event-log file lock (`withFileLock` → `lockSync` + `Atomics.wait`, up to 30s under contention) is exactly the loop-freeze PHNX-3695 removed elsewhere, on a path `guard-no-sync-io` couldn't see (it lives in `runner.ts`, not a `*-service.ts`). So a `host:`-placed routine run completing could still wedge the browser-IPC "accept but never reply" state under lock contention. `applyHealedHostRun` now takes an injected emitter: `finalizeHostRun` (sync CLI path) keeps the synchronous emit, while `finalizeHostRunAsync` (the tick) passes `emitRoutineEndAsync` (`withFileLockAsync`), so no daemon tick reaches the synchronous lock. `guard-no-sync-io` gains a call-site pin so reverting it fails loud. Source: `cli/src/lib/daemon/runner.ts`, `cli/src/lib/daemon/guard-no-sync-io.test.ts`.
18
+
19
+ - **`agents traces sync` now surfaces silent behavioral failures as ranked issues, not just a friction counter (RUSH-2988).** A session where the agent went idle after its last event and a human had to nudge it is a real failure with **no error code**, so it never clustered through `computeInsights` (which only groups `outcome === 'error'` tool calls) and was previously only a `needsAttention` severity input. The per-session silent-stall friction (`silent stall: 5-15m/15-60m/1h+`, already computed by `computeInsightFacets`) is now promoted into cross-session `FailurePattern`s with a new `behavioral` failure cause (`cli/src/lib/traces/insights.ts` `computeBehavioralPatterns`), ranked into the same top-K `failurePatterns` by wasted idle time (a bucket's midpoint, bounded by the same 30-min `MAX_GAP_ATTRIBUTION_MS` cap the tool-error path uses) and summed into `wastedMsTotal`. `TraceFailureCause` gains `behavioral` (`cli/src/lib/traces/classify.ts`); it is producer-derived and never returned by `classifyCause`, so the `failures.byCause` tool-error split reports it as `0`. The console renders the new cause in a follow-up web change.
20
+
3
21
  ## 1.22.70
4
22
 
5
23
  - **`agents browser stop --profile` is now gated by the remote-control consent gate (PHNX-3317).** `stop --profile <name>` (no `--task`) is handled entirely by `bindTask`'s early return in `ipc.ts`, so it never reached `resolveOrCreateTask` — the one chokepoint every other page/close/attach verb's `assertRemoteControlAllowedForRequest` gate runs through — and `case 'stop'` called `BrowserService.stopProfile()` directly with no consent check at all. That left one destructive fleet-remote path (kills the profile's browser process, clears its runtime dir) reachable from `browser --device <device> stop --profile` even with `remote-control off` on the target. `stopProfile()` now takes the same `{ fleetRemote, actor }` opts as `start`/`showUrl`/`resolveOrCreateTask` and asserts the gate first; a local (non-fleet-remote) call is unaffected. Source: `cli/src/lib/browser/service.ts`, `cli/src/lib/browser/ipc.ts`.
package/README.md CHANGED
@@ -402,6 +402,31 @@ Sharing a session uses `agents sessions render <id> -o session.md`, not the raw
402
402
 
403
403
  `agents sessions share <id>` goes one step further and publishes that document as a self-contained web page on your own share endpoint, printing the link. It is **unlisted** unless you pass `--public` — a transcript carries file paths, command output, and error text that a plan does not, so it stays out of your public gallery by default, and emails are masked on top of the render's own redaction. The slug is `session-<shortId>`, so re-sharing one session updates one URL.
404
404
 
405
+ ### Back up sessions off-box
406
+
407
+ Signed-in Phoenix users can back sessions up without creating a Cloudflare bucket or
408
+ an `r2.backups` secrets bundle:
409
+
410
+ ```bash
411
+ agents sessions export --since 30d --to-r2
412
+ agents sessions import --from-r2 --dry-run
413
+ agents sessions import --from-r2
414
+ ```
415
+
416
+ The managed path encrypts every transcript body locally with AES-256-GCM before
417
+ upload. Its per-account key is escrowed behind the same Phoenix bearer so another
418
+ signed-in device can restore with zero setup. This protects transcript contents from
419
+ a raw storage-bucket read, but it is not zero-knowledge against Phoenix because
420
+ Phoenix-operated infrastructure stores the recovery key. Use `--byo` with your own
421
+ `r2.backups` bundle and `R2_SYNC_ENC_KEY` when Phoenix must never possess the key:
422
+
423
+ ```bash
424
+ agents sessions export --since 30d --to-r2 --byo
425
+ agents sessions import --from-r2 --byo
426
+ ```
427
+
428
+ Both targets are on-demand backups only; neither enables background session sync.
429
+
405
430
  ### Resume anywhere — and stay resumed
406
431
 
407
432
  Pick up any past conversation and drop it back into a terminal:
@@ -1261,8 +1286,13 @@ agents daemon doctor # one-shot health check; non-zero ex
1261
1286
  ```
1262
1287
 
1263
1288
  Each hosted responsibility (secrets broker, browser IPC, scheduler, monitors,
1264
- watchdog, device probe, self-heal, keychain reap, account-state refresh,
1289
+ watchdog, device probe, self-heal, self-update, keychain reap, account-state refresh,
1265
1290
  state-dir checks) is an independent toggle in `~/.agents/daemon/services.yaml`.
1291
+ Self-update checks npm on its own schedule, installs + verifies a newer
1292
+ agents-cli with the same primitives `agents upgrade` uses, then exits so the OS
1293
+ supervisor relaunches the daemon onto the new code — the daemon used to run
1294
+ with auto-update forced off and only picked up new code on a manual
1295
+ `agents daemon restart`.
1266
1296
  `agents daemon services list` shows every service; `enable|disable <id>` flips
1267
1297
  one. Missing keys default to enabled, so upgrades are no-ops. Most services take
1268
1298
  effect on the next daemon start; browser IPC is registered even when boot-disabled
package/dist/bootstrap.js CHANGED
@@ -328,7 +328,7 @@ async function installResolvedPackage(metadata) {
328
328
  // every subsequent upgrade dead-ends there forever. bun does not use
329
329
  // npm's retire-path staging scheme, so this only needs to run once, ahead
330
330
  // of both package-manager branches below.
331
- sweepStaleInstallStaging(packageRoot);
331
+ await sweepStaleInstallStaging(packageRoot);
332
332
  // Upgrade with the package manager that owns this install. A bun global
333
333
  // install lives at <bunGlobalDir>/node_modules/... (no `lib` segment), so an
334
334
  // `npm install --prefix` would write to <bunGlobalDir>/lib/node_modules and
@@ -349,8 +349,8 @@ async function installResolvedPackage(metadata) {
349
349
  /* leave it for the OS temp sweep */
350
350
  }
351
351
  }
352
- verifyInstalledVersion(packageRoot, metadata.version);
353
- refreshAliasShims(packageRoot);
352
+ await verifyInstalledVersion(packageRoot, metadata.version);
353
+ await refreshAliasShims(packageRoot);
354
354
  // PHNX-2768: the npm install above can leave the package at the new version
355
355
  // but the global bin links GONE — the state that stranded zion (package at
356
356
  // 1.22.40, `/opt/homebrew/bin/{agents,ag,browser,computer}` missing, every
@@ -361,7 +361,7 @@ async function installResolvedPackage(metadata) {
361
361
  // own bin shims and are out of scope.
362
362
  if (detectPackageManager(packageRoot) !== 'bun' && process.platform !== 'win32') {
363
363
  const prefix = deriveGlobalPrefix(packageRoot);
364
- const repairs = ensureGlobalBinLinks(packageRoot, prefix);
364
+ const repairs = await ensureGlobalBinLinks(packageRoot, prefix);
365
365
  const repaired = repairs.filter((r) => r.action === 'repaired');
366
366
  const failed = repairs.filter((r) => r.action === 'failed');
367
367
  if (repaired.length > 0) {
@@ -60,7 +60,7 @@ function syncMarketplacesForDefaults() {
60
60
  /** Write this device's account metadata before the user repo is committed. */
61
61
  async function publishUserRepoAccountState(provisionAuth) {
62
62
  const { publishUsageSnapshotToSharedStore } = await import('../lib/accounting/usage-sync.js');
63
- const usage = publishUsageSnapshotToSharedStore();
63
+ const usage = await publishUsageSnapshotToSharedStore();
64
64
  if (usage.error)
65
65
  console.error(chalk.yellow(`Usage snapshot: ${usage.error}`));
66
66
  if (provisionAuth) {
@@ -73,7 +73,7 @@ async function publishUserRepoAccountState(provisionAuth) {
73
73
  return;
74
74
  }
75
75
  const { publishReservedAuthVerdict } = await import('../lib/secrets/reserved-sync.js');
76
- const auth = publishReservedAuthVerdict();
76
+ const auth = await publishReservedAuthVerdict();
77
77
  if (auth.error)
78
78
  console.error(chalk.yellow(`Auth verdict: ${auth.device}: ${auth.error}`));
79
79
  }
@@ -1,5 +1,6 @@
1
1
  import type { Command } from 'commander';
2
2
  import type { SessionMeta } from '../lib/session/types.js';
3
+ import { type SessionsBackupClient } from '../lib/session/sync/net-client.js';
3
4
  import { type BundleHeader, type BundleRecord } from '../lib/session/bundle.js';
4
5
  export declare function registerSessionsExportCommand(sessionsCmd: Command): void;
5
6
  interface GlobalSelection {
@@ -10,6 +11,7 @@ interface GlobalSelection {
10
11
  redact?: boolean;
11
12
  encrypt?: boolean;
12
13
  toR2?: boolean;
14
+ byo?: boolean;
13
15
  output?: string;
14
16
  stdout?: boolean;
15
17
  host?: string[];
@@ -36,7 +38,9 @@ interface GlobalSelection {
36
38
  * its test drive the exact same decision.
37
39
  */
38
40
  export declare function r2ExportGateError(g: Pick<GlobalSelection, 'toR2' | 'host'>, isConfigured: boolean): string | null;
39
- export declare function uploadToR2(header: BundleHeader, records: BundleRecord[]): Promise<void>;
41
+ export declare function uploadToR2(header: BundleHeader, records: BundleRecord[], resolvedClient?: SessionsBackupClient): Promise<void>;
42
+ /** Fail-closed guard at the managed transport boundary, not only at record construction. */
43
+ export declare function managedUploadEncryptionError(header: BundleHeader, records: BundleRecord[]): string | null;
40
44
  /** R2 object key for one record — dir-shaped agents key by relKey, file-shaped by session. */
41
45
  export declare function r2KeyForRecord(rec: BundleRecord): string;
42
46
  /**
@@ -27,8 +27,11 @@ import { listLocalTranscripts, objectKey, SYNC_AGENTS } from '../lib/session/syn
27
27
  import { machineId } from '../lib/machine-id.js';
28
28
  import { getHistoryDir } from '../lib/state.js';
29
29
  import { isSyncConfigured, loadR2Config } from '../lib/session/sync/config.js';
30
- import { resolveSyncEncKey, generateSyncEncKey } from '../lib/session/sync/transcript-crypto.js';
30
+ import { resolveSyncEncKey, generateSyncEncKey, isTranscriptEnvelope, } from '../lib/session/sync/transcript-crypto.js';
31
31
  import { R2Client } from '../lib/session/sync/r2.js';
32
+ import { resolveSessionsBackend } from '../lib/session/sync/backend.js';
33
+ import { SessionsHttpClient } from '../lib/session/sync/net-client.js';
34
+ import { resolveManagedBackupKey } from '../lib/session/sync/managed-key.js';
32
35
  import { buildRecord, makeHeader, mergeRecords, serializeBundle, writeBundleFile, specForAgent, } from '../lib/session/bundle.js';
33
36
  import { knownSecretValuesFromEnv } from '../lib/redact.js';
34
37
  import { pullBundlesFromHosts } from '../lib/session/remote-bundle.js';
@@ -42,7 +45,8 @@ export function registerSessionsExportCommand(sessionsCmd) {
42
45
  .option('-o, --output <path>', 'Write the bundle to this file')
43
46
  .option('--stdout', 'Write the bundle to stdout (for piping into `sessions import -`)')
44
47
  .option('--encrypt', 'Seal each transcript body with AES-256-GCM before writing')
45
- .option('--to-r2', 'Back the selected sessions up to Cloudflare R2 (requires the r2.backups bundle) instead of a local file');
48
+ .option('--to-r2', 'Back the selected sessions up off-box (managed Phoenix store when signed in; your own r2.backups bucket with --byo) instead of a local file')
49
+ .option('--byo', 'With --to-r2: force your own r2.backups bucket instead of the managed Phoenix store');
46
50
  setHelpSections(cmd, {
47
51
  examples: `# Bundle the last week of sessions to a file
48
52
  agents sessions export --since 7d -o week.bundle
@@ -53,17 +57,22 @@ agents sessions export 4f8a2b1c 9d3e7a55 -o pair.bundle
53
57
  # Encrypt + pipe straight into another machine over SSH
54
58
  agents sessions export --since 7d --stdout --encrypt | agents ssh boxB 'agents sessions import - --decrypt <key>'
55
59
 
56
- # Back the last month up off-box to Cloudflare R2 (encrypted with the shared key)
57
- agents sessions export --since 30d --to-r2`,
60
+ # Back the last month up off-box (managed Phoenix store — no bucket to set up)
61
+ agents sessions export --since 30d --to-r2
62
+
63
+ # Or to your own r2.backups bucket (R2_SYNC_ENC_KEY keeps it zero-knowledge)
64
+ agents sessions export --since 30d --to-r2 --byo`,
58
65
  notes: `Selection uses the same flags as 'agents sessions' (--since, -n/--limit, --all,
59
66
  -a/--agent, --no-redact). Bundles are self-describing NDJSON: a header line + one
60
67
  line per transcript file. Secrets are redacted by default. Dir-shaped sessions
61
68
  (Kimi) carry all their files. Restore with 'agents sessions import'.
62
69
 
63
- --to-r2 uploads each session to the r2.backups bucket instead of a local file,
64
- one encrypted object per transcript keyed by machine/agent/session. Bodies are
65
- sealed with the shared R2_SYNC_ENC_KEY when present. Restore on any box on the
66
- same bundle with 'agents sessions import --from-r2'.`,
70
+ --to-r2 backs each session up off-box, one encrypted object per transcript keyed
71
+ by machine/agent/session. When you are signed in ('agents auth login') it uploads
72
+ to the MANAGED Phoenix store — no Cloudflare or r2.backups bucket to set up — and
73
+ every body is sealed with a per-account key (mandatory; never plaintext). --byo
74
+ forces your own r2.backups bucket instead; with R2_SYNC_ENC_KEY, neither Phoenix
75
+ nor the storage provider can decrypt it. Restore with 'agents sessions import --from-r2'.`,
67
76
  });
68
77
  cmd.action(async (selectors, _options, command) => {
69
78
  await runExport(selectors, command);
@@ -71,13 +80,42 @@ same bundle with 'agents sessions import --from-r2'.`,
71
80
  }
72
81
  async function runExport(selectors, command) {
73
82
  const g = command.optsWithGlobals();
74
- // --to-r2 preflight: reject the impossible --host combo and fail loud when the
75
- // backup target is not configured, rather than silently producing a local file.
76
- // isSyncConfigured() is consulted only on the --to-r2 path (it reads the keychain).
83
+ // --to-r2 preflight. MANAGED-FIRST: a signed-in user backs up to the managed
84
+ // Phoenix store with no bucket to set up; --byo (or an unauthenticated user
85
+ // with an r2.backups bundle) uses their own bucket. isSyncConfigured() is
86
+ // consulted ONLY on the BYO path (it reads the keychain); the managed path
87
+ // authenticates with the Phoenix session and skips it entirely.
88
+ let sessionsClient;
89
+ let managedClient;
90
+ let managedBackupUserId;
91
+ if (g.byo && !g.toR2) {
92
+ process.stderr.write(chalk.red('--byo is only valid with --to-r2.\n'));
93
+ process.exit(1);
94
+ }
77
95
  if (g.toR2) {
78
- const gateErr = r2ExportGateError(g, isSyncConfigured());
79
- if (gateErr) {
80
- process.stderr.write(chalk.red(gateErr + '\n'));
96
+ if (g.host && g.host.length > 0) {
97
+ process.stderr.write(chalk.red("--to-r2 backs up THIS machine's sessions; it cannot be combined with --device.\n"));
98
+ process.exit(1);
99
+ }
100
+ try {
101
+ const backend = resolveSessionsBackend({ byo: g.byo });
102
+ if (backend.kind === 'managed') {
103
+ managedClient = new SessionsHttpClient({ baseUrl: backend.baseUrl, userId: backend.userId, token: backend.token });
104
+ sessionsClient = managedClient;
105
+ managedBackupUserId = backend.userId;
106
+ }
107
+ else {
108
+ // Preserve the explicit BYO gate used by the on-demand backup path and
109
+ // daemon sync cycle. resolveSessionsBackend already loaded the bundle,
110
+ // so this is a cached, non-prompting verification.
111
+ const gateErr = r2ExportGateError(g, isSyncConfigured());
112
+ if (gateErr)
113
+ throw new Error(gateErr);
114
+ sessionsClient = new R2Client(backend.r2);
115
+ }
116
+ }
117
+ catch (err) {
118
+ process.stderr.write(chalk.red(`Session backup: ${err.message}\n`));
81
119
  process.exit(1);
82
120
  }
83
121
  }
@@ -142,9 +180,23 @@ async function runExport(selectors, command) {
142
180
  process.exit(1);
143
181
  }
144
182
  // 4. Resolve encryption key (opt-in) + redaction (default on via parent --no-redact).
145
- // An R2 backup uses the fleet-shared R2_SYNC_ENC_KEY (an ephemeral key would be
146
- // unrecoverable on a fresh box), so it never takes the resolveExportKey path.
147
- const encryptKey = g.toR2 ? resolveR2BackupKey() : g.encrypt ? resolveExportKey() : null;
183
+ // A BYO R2 backup uses the fleet-shared R2_SYNC_ENC_KEY (an ephemeral key would
184
+ // be unrecoverable on a fresh box). A MANAGED backup resolves a per-account DEK
185
+ // that is MANDATORY and never null — the managed path never uploads plaintext —
186
+ // minting + escrowing one on first use so a fresh box recovers it with zero setup.
187
+ let encryptKey;
188
+ if (g.toR2 && managedBackupUserId) {
189
+ encryptKey = await resolveManagedBackupKey(managedClient, managedBackupUserId);
190
+ }
191
+ else if (g.toR2) {
192
+ encryptKey = resolveR2BackupKey();
193
+ }
194
+ else if (g.encrypt) {
195
+ encryptKey = resolveExportKey();
196
+ }
197
+ else {
198
+ encryptKey = null;
199
+ }
148
200
  const redact = g.redact !== false;
149
201
  // Value-aware redaction: mask live credential values already in the
150
202
  // environment (e.g. an injected secrets bundle) verbatim, whatever their
@@ -172,7 +224,7 @@ async function runExport(selectors, command) {
172
224
  records,
173
225
  });
174
226
  if (g.toR2) {
175
- await uploadToR2(header, records);
227
+ await uploadToR2(header, records, sessionsClient);
176
228
  return;
177
229
  }
178
230
  emitBundle(header, records, g);
@@ -205,13 +257,28 @@ export function r2ExportGateError(g, isConfigured) {
205
257
  }
206
258
  return null;
207
259
  }
208
- export async function uploadToR2(header, records) {
260
+ export async function uploadToR2(header, records, resolvedClient) {
261
+ // The resolved client (managed HTTP or BYO R2) is passed in by the command.
262
+ // The no-client overload preserves the original BYO-only signature for the
263
+ // direct unit test (loadR2Config() from the r2.backups bundle).
209
264
  let client;
210
- try {
211
- client = new R2Client(loadR2Config());
265
+ if (resolvedClient) {
266
+ client = resolvedClient;
267
+ }
268
+ else {
269
+ try {
270
+ client = new R2Client(loadR2Config());
271
+ }
272
+ catch (err) {
273
+ process.stderr.write(chalk.red(`R2 backup: ${err.message}\n`));
274
+ process.exit(1);
275
+ }
212
276
  }
213
- catch (err) {
214
- process.stderr.write(chalk.red(`R2 backup: ${err.message}\n`));
277
+ const encryptionError = client.kind === 'managed'
278
+ ? managedUploadEncryptionError(header, records)
279
+ : null;
280
+ if (encryptionError) {
281
+ process.stderr.write(chalk.red(`${encryptionError}\n`));
215
282
  process.exit(1);
216
283
  }
217
284
  let uploaded = 0;
@@ -234,7 +301,16 @@ export async function uploadToR2(header, records) {
234
301
  uploaded++;
235
302
  }
236
303
  process.stderr.write(chalk.green(`Backed up ${header.sessions} session${header.sessions === 1 ? '' : 's'} ` +
237
- `(${uploaded} object${uploaded === 1 ? '' : 's'}${header.encrypted ? ', encrypted' : ', UNENCRYPTED'}) → R2.\n`));
304
+ `(${uploaded} object${uploaded === 1 ? '' : 's'}${header.encrypted ? ', encrypted' : ', UNENCRYPTED'}) → ` +
305
+ `${client.kind === 'managed' ? 'managed Phoenix store' : 'R2'}.\n`));
306
+ }
307
+ /** Fail-closed guard at the managed transport boundary, not only at record construction. */
308
+ export function managedUploadEncryptionError(header, records) {
309
+ if (!header.encrypted ||
310
+ records.some(record => !record.encrypted || !isTranscriptEnvelope(record.body))) {
311
+ return 'Managed session backups require AES-256-GCM envelopes; refusing to upload plaintext.';
312
+ }
313
+ return null;
238
314
  }
239
315
  /** R2 object key for one record — dir-shaped agents key by relKey, file-shaped by session. */
240
316
  export function r2KeyForRecord(rec) {
@@ -1,4 +1,5 @@
1
1
  import type { Command } from 'commander';
2
+ import { type SessionsBackupClient } from '../lib/session/sync/net-client.js';
2
3
  import { type ParsedBundle } from '../lib/session/bundle.js';
3
4
  export declare function registerSessionsImportCommand(sessionsCmd: Command): void;
4
5
  /**
@@ -16,4 +17,4 @@ export declare function registerSessionsImportCommand(sessionsCmd: Command): voi
16
17
  * the command and its test drive the same decision.
17
18
  */
18
19
  export declare function r2ImportGateError(fromR2: boolean, isConfigured: boolean): string | null;
19
- export declare function pullFromR2(): Promise<ParsedBundle>;
20
+ export declare function pullFromR2(resolvedClient?: SessionsBackupClient): Promise<ParsedBundle>;
@@ -11,9 +11,12 @@
11
11
  import * as fs from 'fs';
12
12
  import chalk from 'chalk';
13
13
  import { isSyncConfigured, loadR2Config } from '../lib/session/sync/config.js';
14
- import { resolveSyncEncKey } from '../lib/session/sync/transcript-crypto.js';
14
+ import { isTranscriptEnvelope, resolveSyncEncKey } from '../lib/session/sync/transcript-crypto.js';
15
15
  import { R2Client } from '../lib/session/sync/r2.js';
16
16
  import { SESSIONS_PREFIX } from '../lib/session/sync/agents.js';
17
+ import { resolveSessionsBackend } from '../lib/session/sync/backend.js';
18
+ import { SessionsHttpClient } from '../lib/session/sync/net-client.js';
19
+ import { resolveManagedBackupKey } from '../lib/session/sync/managed-key.js';
17
20
  import { parseBundle, planImport, writeImport, mergeRecords, makeHeader, } from '../lib/session/bundle.js';
18
21
  import { pullBundlesFromHosts } from '../lib/session/remote-bundle.js';
19
22
  import { setHelpSections } from '../lib/help.js';
@@ -25,7 +28,8 @@ export function registerSessionsImportCommand(sessionsCmd) {
25
28
  .option('--overwrite', 'Replace local files that differ from the bundle (default: keep local)')
26
29
  .option('--decrypt [key]', 'Decrypt an encrypted bundle (key optional if the r2.backups sync key is configured)')
27
30
  .option('--from-host <target...>', 'Pull sessions live from remote peer(s) over SSH instead of a file (repeatable)')
28
- .option('--from-r2', 'Restore session backups from Cloudflare R2 (requires the r2.backups bundle)');
31
+ .option('--from-r2', 'Restore session backups from the off-box store (managed Phoenix store when signed in; your own r2.backups bucket with --byo)')
32
+ .option('--byo', 'With --from-r2: restore from your own r2.backups bucket instead of the managed Phoenix store');
29
33
  setHelpSections(cmd, {
30
34
  examples: `# Preview what a bundle would restore
31
35
  agents sessions import week.bundle --dry-run
@@ -46,9 +50,12 @@ they show up in 'agents sessions' tagged with that machine and never overwrite
46
50
  your own local sessions. Byte-exact duplicates are skipped. --from-host reuses
47
51
  the same SSH transport as the cross-machine listing (no R2, no daemon).
48
52
 
49
- --from-r2 downloads every session backup in the r2.backups bucket and restores
50
- it through the same placement, using the shared R2_SYNC_ENC_KEY to decrypt (or
51
- pass --decrypt <key>). It is the inverse of 'sessions export --to-r2'.`,
53
+ --from-r2 downloads every session backup from the off-box store and restores it
54
+ through the same placement. When signed in it restores from the MANAGED Phoenix
55
+ store and decrypts with your per-account key (recovered automatically, even on a
56
+ fresh box); --byo restores from your own r2.backups bucket with its shared
57
+ R2_SYNC_ENC_KEY (or pass --decrypt <key>). It is the inverse of
58
+ 'sessions export --to-r2'.`,
52
59
  });
53
60
  cmd.action(async (bundlePath, options, command) => {
54
61
  const g = command.optsWithGlobals();
@@ -58,13 +65,39 @@ pass --decrypt <key>). It is the inverse of 'sessions export --to-r2'.`,
58
65
  async function runImport(bundlePath, options, g, command) {
59
66
  // 1. Obtain the bundle — from R2, remote peer(s), stdin, or a file.
60
67
  let bundle;
68
+ // A managed restore recovers the per-account DEK (from the local cache or the
69
+ // Worker escrow) and decrypts with it; BYO falls back to the r2.backups key.
70
+ let managedDecryptKey;
71
+ if (options.byo && !options.fromR2) {
72
+ process.stderr.write(chalk.red('--byo is only valid with --from-r2.\n'));
73
+ process.exit(1);
74
+ }
61
75
  if (options.fromR2) {
62
- const gateErr = r2ImportGateError(true, isSyncConfigured());
63
- if (gateErr) {
64
- process.stderr.write(chalk.red(gateErr + '\n'));
76
+ let client;
77
+ let managedClient;
78
+ let managedUserId;
79
+ try {
80
+ const backend = resolveSessionsBackend({ byo: options.byo });
81
+ if (backend.kind === 'managed') {
82
+ managedClient = new SessionsHttpClient({ baseUrl: backend.baseUrl, userId: backend.userId, token: backend.token });
83
+ client = managedClient;
84
+ managedUserId = backend.userId;
85
+ }
86
+ else {
87
+ const gateErr = r2ImportGateError(true, isSyncConfigured());
88
+ if (gateErr)
89
+ throw new Error(gateErr);
90
+ client = new R2Client(backend.r2);
91
+ }
92
+ }
93
+ catch (err) {
94
+ process.stderr.write(chalk.red(`R2 restore: ${err.message}\n`));
65
95
  process.exit(1);
66
96
  }
67
- bundle = await pullFromR2();
97
+ bundle = await pullFromR2(client);
98
+ if (managedUserId) {
99
+ managedDecryptKey = await resolveManagedBackupKey(managedClient, managedUserId);
100
+ }
68
101
  }
69
102
  else if (options.fromHost && options.fromHost.length > 0) {
70
103
  bundle = await pullForImport(options.fromHost, bundlePath, g, command);
@@ -98,8 +131,12 @@ async function runImport(bundlePath, options, g, command) {
98
131
  process.exit(1);
99
132
  }
100
133
  }
101
- // 3. Resolve the decryption key if the bundle is encrypted.
102
- const decryptKey = bundle.header.encrypted ? resolveDecryptKey(options.decrypt) : null;
134
+ // 3. Resolve the decryption key if the bundle is encrypted. A managed restore
135
+ // already recovered the per-account DEK; otherwise fall back to an explicit
136
+ // --decrypt <key> or the shared r2.backups key.
137
+ const decryptKey = bundle.header.encrypted
138
+ ? (managedDecryptKey ?? resolveDecryptKey(options.decrypt))
139
+ : null;
103
140
  // 4. Plan.
104
141
  let plan;
105
142
  try {
@@ -193,21 +230,33 @@ export function r2ImportGateError(fromR2, isConfigured) {
193
230
  }
194
231
  return null;
195
232
  }
196
- export async function pullFromR2() {
233
+ export async function pullFromR2(resolvedClient) {
234
+ // The resolved client (managed HTTP or BYO R2) is passed in by the command; the
235
+ // no-client overload preserves the original BYO-only signature (loadR2Config)
236
+ // for the direct unit test. The managed client's list() ignores the prefix and
237
+ // enumerates the whole owner namespace, so one call site serves both.
197
238
  let client;
198
239
  let bucket;
199
- try {
200
- const cfg = loadR2Config();
201
- bucket = cfg.bucket;
202
- client = new R2Client(cfg);
240
+ if (resolvedClient) {
241
+ client = resolvedClient;
242
+ bucket = resolvedClient.kind === 'managed' ? 'managed' : 'byo';
203
243
  }
204
- catch (err) {
205
- process.stderr.write(chalk.red(`R2 restore: ${err.message}\n`));
206
- process.exit(1);
244
+ else {
245
+ try {
246
+ const cfg = loadR2Config();
247
+ bucket = cfg.bucket;
248
+ client = new R2Client(cfg);
249
+ }
250
+ catch (err) {
251
+ process.stderr.write(chalk.red(`R2 restore: ${err.message}\n`));
252
+ process.exit(1);
253
+ }
207
254
  }
208
255
  let keys;
209
256
  try {
210
- keys = await client.list(SESSIONS_PREFIX);
257
+ keys = client.kind === 'managed'
258
+ ? await client.list()
259
+ : await client.list(SESSIONS_PREFIX);
211
260
  }
212
261
  catch (err) {
213
262
  process.stderr.write(chalk.red(`R2 restore: listing failed: ${err.message}\n`));
@@ -239,6 +288,19 @@ export async function pullFromR2() {
239
288
  skipped++;
240
289
  continue;
241
290
  }
291
+ // Managed transcripts are mandatory AES-256-GCM (SES-51). A plaintext object
292
+ // in the managed store is a contract violation — an older/buggy writer or
293
+ // tampering — so fail loud rather than importing readable plaintext. Check
294
+ // the header AND every record body (matching the Worker's per-record
295
+ // isEncryptedManagedBundle boundary guard), never just the header flag.
296
+ if (client.kind === 'managed') {
297
+ const plaintext = !parsed.header.encrypted
298
+ || parsed.records.some(rec => !rec.encrypted || !isTranscriptEnvelope(rec.body));
299
+ if (plaintext) {
300
+ process.stderr.write(chalk.red(`R2 restore: managed backup ${key} is not encrypted — managed transcripts are always sealed; refusing to import plaintext.\n`));
301
+ process.exit(1);
302
+ }
303
+ }
242
304
  records.push(...parsed.records);
243
305
  if (parsed.header.encrypted)
244
306
  encryptedAny = true;
@@ -250,7 +312,9 @@ export async function pullFromR2() {
250
312
  }
251
313
  const deduped = mergeRecords([records]);
252
314
  if (deduped.length === 0) {
253
- process.stderr.write(chalk.red(`No session backups found in R2 bucket '${bucket}'.\n`));
315
+ process.stderr.write(chalk.red(bucket === 'managed'
316
+ ? 'No session backups found in the managed store.\n'
317
+ : `No session backups found in R2 bucket '${bucket}'.\n`));
254
318
  process.exit(1);
255
319
  }
256
320
  const header = makeHeader({
@@ -28,7 +28,7 @@ export interface PublishUsageSnapshotResult {
28
28
  path: string | null;
29
29
  }
30
30
  /** Publish this headed device's stable usage snapshot into its owned store file. */
31
- export declare function publishUsageSnapshotToSharedStore(options?: PublishUsageSnapshotOptions): PublishUsageSnapshotResult;
31
+ export declare function publishUsageSnapshotToSharedStore(options?: PublishUsageSnapshotOptions): Promise<PublishUsageSnapshotResult>;
32
32
  export interface ConsumeUsageSnapshotsOptions {
33
33
  userAgentsDir?: string;
34
34
  cachePath?: string;
@@ -8,12 +8,12 @@
8
8
  * There is deliberately no device-to-device SSH in this module or its tick.
9
9
  */
10
10
  import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole } from '../device-config.js';
11
- import { readFleetSharedDeviceStates, updateFleetSharedDeviceState, } from '../fleet-shared-state.js';
11
+ import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from '../fleet-shared-state.js';
12
12
  import { getUserAgentsDir } from '../state.js';
13
13
  import { machineId, normalizeHost } from '../session/sync/config.js';
14
14
  import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
15
15
  /** Publish this headed device's stable usage snapshot into its owned store file. */
16
- export function publishUsageSnapshotToSharedStore(options = {}) {
16
+ export async function publishUsageSnapshotToSharedStore(options = {}) {
17
17
  const result = {
18
18
  published: false,
19
19
  changed: false,
@@ -32,7 +32,7 @@ export function publishUsageSnapshotToSharedStore(options = {}) {
32
32
  return result;
33
33
  }
34
34
  try {
35
- const write = updateFleetSharedDeviceState(options.device ?? machineId(), { usage: { rows } }, options.userAgentsDir ?? getUserAgentsDir());
35
+ const write = await updateFleetSharedDeviceStateAsync(options.device ?? machineId(), { usage: { rows } }, options.userAgentsDir ?? getUserAgentsDir());
36
36
  result.published = true;
37
37
  result.changed = write.changed;
38
38
  result.path = write.path;
@@ -3,6 +3,16 @@ import { BrowserService } from './service.js';
3
3
  import type { IPCRequest, IPCResponse } from './types.js';
4
4
  export interface IPCRequestOptions {
5
5
  autoStartDaemon?: boolean;
6
+ /**
7
+ * Opt-in client-side deadline in ms — most IPC actions (browser automation,
8
+ * long-running recordings) have no fixed budget and must NOT get one by
9
+ * default. Set explicitly for a request that genuinely has one, e.g.
10
+ * `request-self-update`, whose handler answers immediately (it kicks the
11
+ * install off in the background — see `triggerSelfUpdateInBackground`), so its
12
+ * client deadline (`DAEMON_SELF_UPDATE_TRIGGER_TIMEOUT_MS`) is a short
13
+ * "did the daemon accept the trigger?" bound, not a wait on the install.
14
+ */
15
+ timeoutMs?: number;
6
16
  }
7
17
  export declare class BrowserServiceNotRunningError extends Error {
8
18
  constructor();
@@ -148,6 +158,23 @@ export declare class BrowserIPCServer {
148
158
  * owns the shared process.
149
159
  */
150
160
  export declare function shouldRecommendDaemonRefresh(daemonVersion: string | undefined, clientVersion: string): boolean;
161
+ /**
162
+ * Reconcile the running daemon's version with ours without evicting it. If the
163
+ * daemon is serving stale code, ask the daemon to run its OWN self-update path
164
+ * (`request-self-update`, same fail-closed check/install/verify/exit
165
+ * SelfUpdateService's periodic tick runs, self-update-service.ts) rather than
166
+ * telling a human to run `agents daemon restart` by hand. A browser client
167
+ * still owns neither the shared supervisor nor its sibling services, so
168
+ * version skew can never be permission for THIS client to stop or restart
169
+ * that process directly (PHNX-3605 holds) — it only asks the daemon to run a
170
+ * path the daemon already runs on its own schedule, which only exits once the
171
+ * daemon itself has verified the new version is good. If self-update declines
172
+ * (dev build, shadowed install, already current, or a real failure) this
173
+ * degrades to the same "nothing changed" outcome PHNX-3605 shipped.
174
+ */
175
+ export declare function reconcileDaemonVersion(): Promise<void>;
176
+ /** Test-only: `reconcileDaemonVersion` runs at most once per real process — this clears that latch so a test file can drive more than one branch. */
177
+ export declare function resetVersionReconciliationForTest(): void;
151
178
  export declare function sendIPCRequest(request: IPCRequest, opts?: IPCRequestOptions): Promise<IPCResponse>;
152
179
  /**
153
180
  * Fill actor / launchId / sessionId from the calling process when the request
@@ -160,3 +187,10 @@ export declare function stampCallerIdentity(request: IPCRequest, env?: NodeJS.Pr
160
187
  * only the client process and IPC socket lifetime differ.
161
188
  */
162
189
  export declare function connectBrowserIPC(opts?: IPCRequestOptions): Promise<BrowserIPCConnection>;
190
+ /**
191
+ * Exported (rather than kept module-private) so `ipc.test.ts` can drive the
192
+ * `opts.timeoutMs` client-side deadline directly against a raw `net.Server`
193
+ * test double that never responds — the hermetic way to prove the timeout
194
+ * actually fires without needing a slow real IPC action to provoke it.
195
+ */
196
+ export declare function sendRawIPCRequest(request: IPCRequest, opts?: IPCRequestOptions): Promise<IPCResponse>;