@phnx-labs/agents-cli 1.22.66 → 1.22.68

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 (109) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +7 -2
  3. package/dist/bootstrap.js +3 -3
  4. package/dist/commands/browser.js +46 -21
  5. package/dist/commands/daemon-test-harness.d.ts +1 -1
  6. package/dist/commands/daemon-test-harness.js +2 -2
  7. package/dist/commands/daemon.js +21 -22
  8. package/dist/commands/exec.js +40 -0
  9. package/dist/commands/monitors.js +2 -2
  10. package/dist/commands/repo.js +57 -19
  11. package/dist/commands/resume.d.ts +16 -0
  12. package/dist/commands/resume.js +41 -8
  13. package/dist/commands/routines.js +42 -21
  14. package/dist/commands/sessions-optimize.js +1 -1
  15. package/dist/commands/sessions.js +4 -11
  16. package/dist/commands/share.d.ts +5 -3
  17. package/dist/commands/share.js +33 -9
  18. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  19. package/dist/lib/accounting/account-pool-collect.js +6 -4
  20. package/dist/lib/accounting/usage-ingest.js +4 -2
  21. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  22. package/dist/lib/accounting/usage-sync.js +66 -210
  23. package/dist/lib/accounting/usage.d.ts +11 -3
  24. package/dist/lib/accounting/usage.js +23 -10
  25. package/dist/lib/auth-health.d.ts +8 -0
  26. package/dist/lib/auth-health.js +4 -4
  27. package/dist/lib/browser/caller-identity.d.ts +12 -0
  28. package/dist/lib/browser/caller-identity.js +19 -0
  29. package/dist/lib/browser/ipc.d.ts +37 -32
  30. package/dist/lib/browser/ipc.js +146 -94
  31. package/dist/lib/browser/task-index.d.ts +10 -2
  32. package/dist/lib/browser/task-index.js +22 -3
  33. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  34. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  35. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  36. package/dist/lib/daemon/auth-sync-service.js +17 -6
  37. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  38. package/dist/lib/daemon/catchup-service.js +51 -0
  39. package/dist/lib/daemon/daemon.d.ts +12 -22
  40. package/dist/lib/daemon/daemon.js +463 -176
  41. package/dist/lib/daemon/runner.js +2 -0
  42. package/dist/lib/daemon/service.d.ts +22 -4
  43. package/dist/lib/daemon/service.js +2 -2
  44. package/dist/lib/daemon/supervisor.d.ts +55 -15
  45. package/dist/lib/daemon/supervisor.js +119 -29
  46. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  47. package/dist/lib/daemon/usage-sync-service.js +22 -18
  48. package/dist/lib/daemon-health.js +36 -31
  49. package/dist/lib/daemon-services.d.ts +1 -1
  50. package/dist/lib/daemon-services.js +12 -2
  51. package/dist/lib/daemon-ticks.d.ts +9 -6
  52. package/dist/lib/daemon-ticks.js +14 -8
  53. package/dist/lib/devices/registry.js +2 -0
  54. package/dist/lib/exec.js +11 -0
  55. package/dist/lib/feed/watch.d.ts +3 -0
  56. package/dist/lib/feed/watch.js +13 -3
  57. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  58. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  59. package/dist/lib/fleet-shared-state.d.ts +38 -0
  60. package/dist/lib/fleet-shared-state.js +105 -0
  61. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  62. package/dist/lib/hosts/remote-cmd.js +12 -3
  63. package/dist/lib/lock-compromise.d.ts +8 -0
  64. package/dist/lib/lock-compromise.js +12 -0
  65. package/dist/lib/monitors/engine.d.ts +2 -1
  66. package/dist/lib/monitors/engine.js +9 -2
  67. package/dist/lib/refresh-coordinator.js +2 -0
  68. package/dist/lib/run-trace-sync.d.ts +13 -0
  69. package/dist/lib/run-trace-sync.js +77 -0
  70. package/dist/lib/secrets/push.d.ts +10 -0
  71. package/dist/lib/secrets/push.js +86 -7
  72. package/dist/lib/secrets/remote.d.ts +18 -6
  73. package/dist/lib/secrets/remote.js +29 -4
  74. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  75. package/dist/lib/secrets/reserved-sync.js +119 -101
  76. package/dist/lib/session/active.d.ts +3 -1
  77. package/dist/lib/session/active.js +2 -0
  78. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  79. package/dist/lib/session/actor-sidecar.js +2 -0
  80. package/dist/lib/session/db.d.ts +5 -2
  81. package/dist/lib/session/db.js +54 -22
  82. package/dist/lib/session/discover.d.ts +12 -3
  83. package/dist/lib/session/discover.js +106 -25
  84. package/dist/lib/session/prompt.d.ts +11 -0
  85. package/dist/lib/session/prompt.js +25 -0
  86. package/dist/lib/session/recovery.d.ts +30 -2
  87. package/dist/lib/session/recovery.js +56 -4
  88. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  89. package/dist/lib/session/remote/remote-list.js +29 -4
  90. package/dist/lib/session/remote/watch.d.ts +25 -2
  91. package/dist/lib/session/remote/watch.js +185 -11
  92. package/dist/lib/session/session-cache.d.ts +2 -1
  93. package/dist/lib/session/session-cache.js +1 -0
  94. package/dist/lib/session/types.d.ts +2 -0
  95. package/dist/lib/share/publish.d.ts +55 -9
  96. package/dist/lib/share/publish.js +87 -9
  97. package/dist/lib/share/worker-template.js +100 -8
  98. package/dist/lib/ssh-exec.d.ts +2 -0
  99. package/dist/lib/ssh-exec.js +20 -4
  100. package/dist/lib/teams/registry.js +2 -0
  101. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  102. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  103. package/dist/lib/traces/sync.d.ts +7 -0
  104. package/dist/lib/traces/sync.js +9 -0
  105. package/dist/lib/usage-refresh.d.ts +8 -2
  106. package/dist/lib/usage-refresh.js +3 -3
  107. package/package.json +1 -1
  108. package/dist/lib/account-state-service.d.ts +0 -21
  109. package/dist/lib/account-state-service.js +0 -60
@@ -7,7 +7,25 @@ import { spawn } from 'child_process';
7
7
  import chalk from 'chalk';
8
8
  import { resolveSessionMetadataValue } from './sessions.js';
9
9
  import { sessionOwnerDevice, consumeResumePinned, RESUME_PINNED_ENV } from '../lib/session/resume-owner.js';
10
+ import { machineId } from '../lib/machine-id.js';
10
11
  export const RESUME_SOURCE_ENV = 'AGENTS_RESUME_SOURCE_JSON';
12
+ /**
13
+ * The source a dead-remote local fallback resumes from: the same session, but
14
+ * with `machine` rewritten to THIS box so the delegated `agents run --resume`
15
+ * resolves recovery locally (`sessionRecoveryPeer` returns undefined) instead of
16
+ * bouncing back to the unreachable owner. Because no local version home owns the
17
+ * peer's transcript, that local recovery lands on a labelled `/continue` replay
18
+ * from the synced mirror — the only way to continue a session whose owning device
19
+ * is gone. Owner-approved prefer-device, fall-back-local (PHNX-3626).
20
+ *
21
+ * Safe against the RUSH-2022 "silent local resume forks live state" hazard by
22
+ * PRECONDITION: this is reached only after `runOnPeer` proved the owner
23
+ * unreachable, so there is no live process on the peer to fork, and the fallback
24
+ * is announced with a log line — never silent.
25
+ */
26
+ export function resumeLocalFallbackSource(session, self = machineId()) {
27
+ return { ...session, machine: self };
28
+ }
11
29
  /**
12
30
  * The argv to re-run this resume on the machine that owns the session.
13
31
  *
@@ -165,14 +183,30 @@ export async function runStrictResume(sessionId, prompt, options) {
165
183
  },
166
184
  sessionId: outcome.session.id,
167
185
  });
168
- if (rc === 'no-target') {
169
- console.error(chalk.red(`Session ${outcome.session.shortId} lives on ${owner}, which isn't a reachable device right now.`));
170
- console.error(chalk.gray(`Register/wake it (agents devices), or run there: agents ssh ${owner}`));
171
- process.exitCode = 1;
186
+ if (rc === 'no-target' || rc === 'unreachable') {
187
+ // Prefer-device, fall back to local (PHNX-3626): the owning device is
188
+ // unreachable — either not a dialable registered device ('no-target') or
189
+ // registered but offline/asleep so the SSH connection itself failed
190
+ // ('unreachable'). Either way there is no live harness to reach OR to fork,
191
+ // so continue the session HERE from its synced mirror rather than
192
+ // dead-ending. The local recovery resolves this to a labelled `/continue`
193
+ // replay (no local home owns the peer's transcript), the honest degradation.
194
+ if (!options.quiet) {
195
+ process.stderr.write(chalk.yellow(`[agents] session ${outcome.session.shortId} belongs to ${owner}, which is unreachable → resuming locally (/continue replay from the synced transcript)\n`));
196
+ }
197
+ process.exitCode = await delegateLocalResume(resumeLocalFallbackSource(outcome.session), prompt, options);
172
198
  }
173
199
  return;
174
200
  }
175
- const args = buildResumeRunArgs(outcome.session, prompt, options);
201
+ process.exitCode = await delegateLocalResume(outcome.session, prompt, options);
202
+ }
203
+ /**
204
+ * Spawn the delegated local `agents run --resume` for a session this box owns
205
+ * (or is falling back to). The run command remains the sole executor; recovery
206
+ * (native vs `/continue`) is resolved there. Returns the child's exit code.
207
+ */
208
+ async function delegateLocalResume(session, prompt, options) {
209
+ const args = buildResumeRunArgs(session, prompt, options);
176
210
  const child = spawn(process.execPath, [process.argv[1], ...args], {
177
211
  stdio: 'inherit',
178
212
  env: {
@@ -180,12 +214,11 @@ export async function runStrictResume(sessionId, prompt, options) {
180
214
  // Avoid repeating the fleet lookup in the delegated local `run`
181
215
  // process. The value is metadata-only and is not forwarded over SSH;
182
216
  // the owner performs its own local SQLite lookup.
183
- [RESUME_SOURCE_ENV]: JSON.stringify(outcome.session),
217
+ [RESUME_SOURCE_ENV]: JSON.stringify(session),
184
218
  },
185
219
  });
186
- const exitCode = await new Promise((resolve) => {
220
+ return new Promise((resolve) => {
187
221
  child.once('error', () => resolve(127));
188
222
  child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
189
223
  });
190
- process.exitCode = exitCode;
191
224
  }
@@ -10,8 +10,9 @@ import ora from 'ora';
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as yaml from 'yaml';
13
- import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemonLog, getDaemonStatus, getDaemonLogPath, } from '../lib/daemon/daemon.js';
14
- import { assertSchedulerEnabled, assertDaemonEnabled, isDaemonEnabled } from '../lib/device-config.js';
13
+ import { isDaemonRunning, signalDaemonReload, startDaemon, readDaemonLog, getDaemonStatus, getDaemonLogPath, } from '../lib/daemon/daemon.js';
14
+ import { isDaemonServiceEnabled, setDaemonServiceEnabled } from '../lib/daemon-services.js';
15
+ import { assertSchedulerEnabled, assertDaemonEnabled, isDaemonEnabled, isSchedulerEnabled, } from '../lib/device-config.js';
15
16
  import { parseAgentVersionSpec, isAgentHardDeprecated, hardDeprecationError, ROUTINE_AGENT_IDS } from '../lib/agents.js';
16
17
  import { isCustomHarnessName } from '../lib/profiles.js';
17
18
  import { RUN_STRATEGIES, normalizeRunStrategy } from '../lib/accounting/rotate.js';
@@ -313,10 +314,17 @@ function ensureSchedulerRunning(opts = {}) {
313
314
  console.error(chalk.yellow(err.message));
314
315
  return;
315
316
  }
317
+ const wasServiceEnabled = isDaemonServiceEnabled('scheduler');
318
+ if (!wasServiceEnabled)
319
+ setDaemonServiceEnabled('scheduler', true);
316
320
  if (isDaemonRunning()) {
317
- signalDaemonReload();
318
- if (!opts.quiet)
319
- log(chalk.gray('Scheduler reloaded'));
321
+ const reloaded = signalDaemonReload();
322
+ if (!reloaded) {
323
+ console.error(chalk.yellow('Scheduler service enabled, but live reload is unavailable. Restart deliberately with: agents daemon restart'));
324
+ }
325
+ else if (!opts.quiet) {
326
+ log(chalk.gray(wasServiceEnabled ? 'Scheduler reloaded' : 'Scheduler service enabled and reloaded'));
327
+ }
320
328
  return;
321
329
  }
322
330
  const result = startDaemon();
@@ -2162,7 +2170,7 @@ export function registerRoutinesCommands(program) {
2162
2170
  // Scheduler lifecycle — usually auto-managed by `routines add`, exposed here for manual control.
2163
2171
  routinesCmd
2164
2172
  .command('start')
2165
- .description('Start the background scheduler. Usually unnecessary — it auto-starts when you add your first routine.')
2173
+ .description('Enable and reload the scheduler service. Usually unnecessary — it auto-starts when you add your first routine.')
2166
2174
  .action(() => {
2167
2175
  try {
2168
2176
  // A manual start on a disabled device refuses with the same message the
@@ -2176,13 +2184,16 @@ export function registerRoutinesCommands(program) {
2176
2184
  console.error(chalk.red(err.message));
2177
2185
  process.exit(1);
2178
2186
  }
2187
+ setDaemonServiceEnabled('scheduler', true);
2179
2188
  const result = startDaemon();
2180
2189
  if (result.method === 'already-running') {
2181
2190
  // Signal a reload even here: if the daemon booted while this device had
2182
2191
  // scheduler.enabled=false, the reload re-evaluates the gate and boots
2183
2192
  // the scheduler — a manual start heals a scheduler-less daemon.
2184
- signalDaemonReload();
2185
- console.log(chalk.yellow(`Scheduler already running (PID: ${result.pid}) — reloaded`));
2193
+ const reloaded = signalDaemonReload();
2194
+ console.log(reloaded
2195
+ ? chalk.yellow(`Scheduler service enabled (shared daemon PID: ${result.pid}) — reloaded`)
2196
+ : chalk.yellow(`Scheduler service enabled, but live reload is unavailable. Restart deliberately with: agents daemon restart`));
2186
2197
  }
2187
2198
  else if (result.pid) {
2188
2199
  console.log(chalk.green(`Scheduler started (PID: ${result.pid})`));
@@ -2193,25 +2204,31 @@ export function registerRoutinesCommands(program) {
2193
2204
  });
2194
2205
  routinesCmd
2195
2206
  .command('stop')
2196
- .description('Stop the background scheduler. Routines will not fire until you start it again.')
2207
+ .description('Stop only the scheduler service. The shared daemon and its browser, secrets, usage, and monitoring services stay running.')
2197
2208
  .action(() => {
2198
- if (!isDaemonRunning()) {
2199
- console.log(chalk.yellow('Scheduler is not running'));
2209
+ const daemonRunning = isDaemonRunning();
2210
+ setDaemonServiceEnabled('scheduler', false);
2211
+ if (daemonRunning && !signalDaemonReload()) {
2212
+ console.log(chalk.yellow('Scheduler service disabled, but live reload is unavailable. It will stay off after the next daemon start.'));
2200
2213
  return;
2201
2214
  }
2202
- stopDaemon();
2203
- console.log(chalk.green('Scheduler stopped'));
2215
+ console.log(daemonRunning
2216
+ ? chalk.green('Scheduler service stopped; shared daemon is still running')
2217
+ : chalk.yellow('Scheduler service disabled; shared daemon was already stopped'));
2204
2218
  });
2205
2219
  routinesCmd
2206
2220
  .command('status')
2207
- .description('Show scheduler status, enabled routines, and when each one fires next.')
2208
- .option('--json', 'Emit machine-readable scheduler + per-routine status (owner device, last fire, last error, in-flight run)')
2221
+ .description('Show scheduler service state, shared-daemon state, enabled routines, and upcoming runs.')
2222
+ .option('--json', 'Emit machine-readable scheduler service + device gate + daemon + per-routine status (owner device, last fire, last error, in-flight run)')
2209
2223
  .action((options) => {
2210
2224
  try {
2211
2225
  monitorRunningJobs();
2212
2226
  }
2213
2227
  catch { /* best-effort orphan reap */ }
2214
2228
  const status = getDaemonStatus();
2229
+ const schedulerServiceEnabled = isDaemonServiceEnabled('scheduler');
2230
+ const schedulerDeviceEnabled = isSchedulerEnabled();
2231
+ const schedulerState = schedulerServiceEnabled && schedulerDeviceEnabled ? status.state : 'stopped';
2215
2232
  // The daemon-owned status surface (PHNX-3215): scheduler truth plus, per
2216
2233
  // routine, its single owner device, last fire outcome + error, and any
2217
2234
  // in-flight spawn — the fields `list --json` (definition-shaped) does not
@@ -2221,7 +2238,10 @@ export function registerRoutinesCommands(program) {
2221
2238
  writeJson({
2222
2239
  device: machineId(),
2223
2240
  scheduler: {
2224
- state: status.state,
2241
+ state: schedulerState,
2242
+ serviceEnabled: schedulerServiceEnabled,
2243
+ deviceEnabled: schedulerDeviceEnabled,
2244
+ daemonState: status.state,
2225
2245
  pid: status.pid,
2226
2246
  binaryPath: status.binaryPath,
2227
2247
  heartbeat: status.heartbeat
@@ -2238,12 +2258,13 @@ export function registerRoutinesCommands(program) {
2238
2258
  return;
2239
2259
  }
2240
2260
  console.log(chalk.bold('Scheduler\n'));
2241
- const stateLabel = status.state === 'running'
2261
+ const stateLabel = schedulerState === 'running'
2242
2262
  ? chalk.green('running')
2243
- : status.state === 'wedged'
2263
+ : schedulerState === 'wedged'
2244
2264
  ? chalk.red('wedged')
2245
2265
  : chalk.gray('stopped');
2246
2266
  console.log(` Status: ${stateLabel}`);
2267
+ console.log(` Daemon: ${status.running ? chalk.green('running') : chalk.gray('stopped')}`);
2247
2268
  if (status.pid)
2248
2269
  console.log(` PID: ${status.pid}`);
2249
2270
  if (status.binaryPath)
@@ -2256,9 +2277,9 @@ export function registerRoutinesCommands(program) {
2256
2277
  const enabled = jobs.filter((j) => j.enabled);
2257
2278
  console.log(` Routines: ${enabled.length} enabled / ${jobs.length} total`);
2258
2279
  if (status.state === 'wedged') {
2259
- console.log(chalk.red('\n The daemon is wedged (heartbeat stale). Restart with: agents routines stop && agents routines start'));
2280
+ console.log(chalk.red('\n The shared daemon is wedged (heartbeat stale). Restart deliberately with: agents daemon restart'));
2260
2281
  }
2261
- if (status.running && enabled.length > 0) {
2282
+ if (schedulerState === 'running' && enabled.length > 0) {
2262
2283
  const scheduler = new JobScheduler(async () => { });
2263
2284
  scheduler.loadAll();
2264
2285
  const scheduled = scheduler.listScheduled();
@@ -2269,7 +2290,7 @@ export function registerRoutinesCommands(program) {
2269
2290
  }
2270
2291
  scheduler.stopAll();
2271
2292
  }
2272
- else if (!status.running && jobs.length > 0) {
2293
+ else if (schedulerState !== 'running' && jobs.length > 0) {
2273
2294
  console.log(chalk.gray('\n Start the scheduler to begin firing routines: agents routines start'));
2274
2295
  }
2275
2296
  });
@@ -35,7 +35,7 @@ export function registerSessionsOptimizeCommand(sessionsCmd) {
35
35
  notes: `
36
36
  - FTS5 appends a segment on every insert and tombstones every delete; the scanner delete+inserts a session's docs on each rescan and never self-merges, so \`tool_call_text_data\` / \`session_text_data\` bloat with unmerged segments — GBs of index for tens of MB of content, and queries slow down.
37
37
  - This runs FTS5 \`'optimize'\`: it merges every segment into one and purges tombstones. Non-destructive — no searchable content is lost.
38
- - Reclaimed space becomes reusable free pages inside the DB file. To return it to the OS, stop the daemon (\`agents routines stop\`) and run \`VACUUM\` against \`~/.agents/.history/sessions/sessions.db\`.
38
+ - Reclaimed space becomes reusable free pages inside the DB file. To return it to the OS, use the operator lifecycle command \`agents daemon stop\`, run \`VACUUM\` against \`~/.agents/.history/sessions/sessions.db\`, then \`agents daemon start\`. \`agents routines stop\` disables only the scheduler service.
39
39
  `,
40
40
  });
41
41
  }
@@ -53,7 +53,7 @@ import { itemPicker } from '../lib/picker.js';
53
53
  import { resolveSessionAlias } from '../lib/session/actor-sidecar.js';
54
54
  import { listInstalledVersions, resolveVersionAliasLoose } from '../lib/installations/versions.js';
55
55
  import { getAgentsInvocation } from '../lib/daemon/daemon.js';
56
- import { sessionRecoveryRunArgs } from '../lib/session/recovery.js';
56
+ import { sessionAgentSupportsResume, sessionRecoveryRunArgs } from '../lib/session/recovery.js';
57
57
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
58
58
  import { sessionPicker, buildPreview, loadSessionPreviewDigest, transcriptOnPeerOf, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
59
59
  import { setHelpSections } from '../lib/help.js';
@@ -4062,6 +4062,8 @@ function versionedAliasIfPresent(agent, version) {
4062
4062
  return null;
4063
4063
  }
4064
4064
  export function buildResumeCommand(session) {
4065
+ if (!sessionAgentSupportsResume(session.agent))
4066
+ return null;
4065
4067
  switch (session.agent) {
4066
4068
  // opencode sessions are shared across versions, so resume is deliberately NOT
4067
4069
  // version-pinned — it always goes through the plain launcher.
@@ -4079,16 +4081,7 @@ export function buildResumeCommand(session) {
4079
4081
  }
4080
4082
  return resumeArgv(session.agent, session.id, cli);
4081
4083
  }
4082
- case 'gemini':
4083
- case 'antigravity':
4084
- case 'openclaw':
4085
- case 'rush':
4086
- case 'hermes':
4087
- case 'grok':
4088
- case 'kimi':
4089
- case 'droid':
4090
- case 'cursor':
4091
- // Grok (and some others) sessions are captured artifacts, not resumable the same way.
4084
+ default:
4092
4085
  return null;
4093
4086
  }
4094
4087
  }
@@ -80,7 +80,9 @@ export declare function runShareEdit(target: string, opts: {
80
80
  removeMeta?: string[];
81
81
  /** Change the page's visibility in place (public | unlisted | me | org).
82
82
  * A metadata-only rewrite like label/meta — the body is untouched, so no
83
- * revision is created. me/org require a Phoenix session. */
83
+ * revision is created. me/org require a Phoenix session. `private` is
84
+ * rejected here — token-gating needs a fresh viewer key only the publish
85
+ * path mints (PHNX-3654). */
84
86
  visibility?: ShareVisibility;
85
87
  force?: boolean;
86
88
  fetchEdit?: typeof fetch;
@@ -116,7 +118,7 @@ export declare function runShareList(opts?: {
116
118
  /** Visibility filter. `public` (default) lists only public gallery pages;
117
119
  * `unlisted`/`me`/`org` include that owner's hidden pages; `all` includes
118
120
  * every active page. Hidden scopes send the owner's bearer and `scope=mine`. */
119
- scope?: 'public' | 'unlisted' | 'me' | 'org' | 'all';
121
+ scope?: 'public' | 'unlisted' | 'private' | 'me' | 'org' | 'all';
120
122
  }): Promise<ShareListResult>;
121
123
  /** Client-side filtering over an already-fetched listing (RUSH-2683) — the
122
124
  * Worker has no query surface for this, so it narrows the fetched set instead
@@ -127,7 +129,7 @@ export declare function applyShareListFilters(result: ShareListResult, filters:
127
129
  session?: string;
128
130
  label?: string;
129
131
  meta?: Record<string, string>;
130
- scope?: 'public' | 'unlisted' | 'me' | 'org' | 'all';
132
+ scope?: 'public' | 'unlisted' | 'private' | 'me' | 'org' | 'all';
131
133
  }): ShareListResult;
132
134
  export declare function formatShareList(result: ShareListResult, json?: boolean): string;
133
135
  /** One retained prior version of a slug, as reported by the Worker's
@@ -11,7 +11,7 @@ import { Argument, Option } from 'commander';
11
11
  import chalk from 'chalk';
12
12
  import { DEFAULT_BUCKET_NAME, DEFAULT_CF_BUNDLE, DEFAULT_SHARE_DOMAIN, DEFAULT_WORKER_NAME, generateWriteToken, readCloudflareCreds, readShareConfig, readWriteToken, readWriteTokenEnv, readWriteTokenFromBundle, storeWriteToken, writeShareConfig, } from '../lib/share/config.js';
13
13
  import { addCustomDomain, configureBucketLifecycle, createBucket, deployWorker, enableWorkersDev, findZoneId, hashWorkerScript, putWorkerSecret, updateWorker, WORKER_PHOENIX_ID_BASE_SECRET, setWorkerSecret, } from '../lib/share/provision.js';
14
- import { publishFile, resolveShareUsername, parseMetaEntries, sanitizeLabel, resolveShareVisibility, scanShareContent, formatSensitiveContentError, SHARE_VISIBILITY_LEVELS, } from '../lib/share/publish.js';
14
+ import { publishFile, resolveShareUsername, parseMetaEntries, sanitizeLabel, resolveShareVisibility, scanShareContent, formatSensitiveContentError, unlistedNotPrivateWarning, SHARE_VISIBILITY_LEVELS, PUBLISH_VISIBILITY_LEVELS, } from '../lib/share/publish.js';
15
15
  import { deleteShare, resolveDeleteTarget } from '../lib/share/delete.js';
16
16
  import { renderWorkerBundle } from '../lib/share/worker-template.js';
17
17
  import { analyticsEnabled } from '../lib/share/analytics.js';
@@ -37,7 +37,11 @@ export function formatSharePublishResult(result, json = false) {
37
37
  lines.push(chalk.dim(' expires never'));
38
38
  const visibility = result.visibility ?? (result.unlisted ? 'unlisted' : 'public');
39
39
  if (visibility === 'unlisted') {
40
- lines.push(chalk.dim(' visibility: unlisted (noindex, hidden from gallery)'));
40
+ lines.push(chalk.dim(' visibility: unlisted (noindex, hidden from gallery — NOT authenticated)'));
41
+ }
42
+ else if (visibility === 'private') {
43
+ lines.push(chalk.dim(' visibility: private (token-gated — 404 without the key; hidden from gallery)'));
44
+ lines.push(chalk.yellow(' the key is in the URL above (?k=…) — treat the whole link as a secret'));
41
45
  }
42
46
  else if (visibility === 'me' || visibility === 'org') {
43
47
  lines.push(chalk.dim(` visibility: ${visibility} (login required, hidden from gallery)`));
@@ -57,6 +61,14 @@ export function shareTemplateStatus(cfg) {
57
61
  return cfg.templateHash === hashWorkerScript(renderWorkerBundle().script) ? 'current' : 'outdated';
58
62
  }
59
63
  export async function runShareEdit(target, opts) {
64
+ // Token-gated `private` can't be set in place: the metadata-edit route carries
65
+ // no viewer token, so re-stamping visibility=private alone would leave the page
66
+ // gated by a hash that doesn't exist — inaccessible to everyone (PHNX-3654).
67
+ // Fail loud pointing at the publish path, which mints the key.
68
+ if (opts.visibility === 'private') {
69
+ throw new Error("Can't change a page to 'private' in place — a token-gated link needs a fresh viewer key. " +
70
+ "Re-publish it with 'agents artifacts share <file> --protected' instead.");
71
+ }
60
72
  // Same public-listing gate as publish: label + every metadata value are
61
73
  // world-readable in the gallery and `share list --list-json`.
62
74
  if (opts.force !== true) {
@@ -158,7 +170,10 @@ export function parseShareListing(user, body) {
158
170
  host: item.host == null ? null : String(item.host),
159
171
  repo: item.repo == null ? null : String(item.repo),
160
172
  revisionCount: typeof item.revisionCount === 'number' ? item.revisionCount : 0,
161
- visibility: item.visibility === 'unlisted' || item.visibility === 'me' || item.visibility === 'org'
173
+ visibility: item.visibility === 'unlisted' ||
174
+ item.visibility === 'private' ||
175
+ item.visibility === 'me' ||
176
+ item.visibility === 'org'
162
177
  ? item.visibility
163
178
  : 'public',
164
179
  meta: parseMetaField(item.meta),
@@ -609,11 +624,12 @@ export function registerShareCommands(artifactsCmd) {
609
624
  .option('--slug <slug>', 'URL slug override (default: stable slug of the artifact title, then filename)')
610
625
  .option('--github-user <user>', 'GitHub username for the share namespace (default: resolved from gh/git config; ignored on the managed endpoint)')
611
626
  .option('--expire <spec>', "auto-expire (default 30d). e.g. 12h, 30d, 2026-08-01, or 'never'")
612
- .addOption(new Option('--visibility <level>', 'public | unlisted | me | org (default public). unlisted is a capability URL; me/org require a Phoenix session and are hidden from the gallery')
613
- .choices(['public', 'unlisted', 'me', 'org'])
627
+ .addOption(new Option('--visibility <level>', 'public | unlisted | private | me | org (default public). unlisted is an UNAUTHENTICATED capability URL; private is token-gated (404 without the key); me/org require a Phoenix session. All but public are hidden from the gallery')
628
+ .choices([...PUBLISH_VISIBILITY_LEVELS])
614
629
  .default('public'))
615
- .addOption(new Option('--unlisted', 'hidden alias of --visibility unlisted').hideHelp())
616
- .addOption(new Option('--private', 'hidden alias of --visibility unlisted').hideHelp())
630
+ .addOption(new Option('--protected', 'token-gated link (= --visibility private): the URL carries a secret key and returns 404 without it — the authenticated alternative to --unlisted'))
631
+ .addOption(new Option('--unlisted', 'hidden alias of --visibility unlisted (obscurity, NOT authentication)').hideHelp())
632
+ .addOption(new Option('--private', 'hidden alias of --visibility unlisted (obscurity, NOT authentication — for real read-auth use --protected)').hideHelp())
617
633
  .option('--force', 'publish even when the file contains emails or credential-shaped strings')
618
634
  .option('--no-cover', 'skip the OG preview image (HTML pages get one by default)')
619
635
  .option('--no-analytics', 'skip injecting the Cloudflare Web Analytics beacon')
@@ -637,13 +653,21 @@ export function registerShareCommands(artifactsCmd) {
637
653
  const visibility = resolveShareVisibility({
638
654
  visibility: opts.visibility,
639
655
  unlisted: opts.unlisted === true || opts.private === true,
656
+ protected: opts.protected === true,
640
657
  });
658
+ // unlisted (incl. its --private alias) is obscurity, not read-auth — warn
659
+ // loudly before the publish so a "private" link is never mistaken for a
660
+ // gated one (PHNX-3654). Skipped for --json so machine output stays clean.
661
+ if (visibility === 'unlisted' && !opts.json) {
662
+ console.error(chalk.yellow(`⚠ ${unlistedNotPrivateWarning()}`));
663
+ }
641
664
  const result = await publishFile(file, {
642
665
  slug: opts.slug,
643
666
  githubUser: opts.githubUser,
644
667
  expire: opts.expire,
645
668
  visibility,
646
669
  unlisted: visibility === 'unlisted',
670
+ protected: visibility === 'private',
647
671
  force: opts.force,
648
672
  cover: opts.cover,
649
673
  analytics: opts.analytics,
@@ -929,8 +953,8 @@ Prefer to change visibility without a browser? Use 'agents artifacts share visib
929
953
  // so the listing filter is named `--scope` here; `--all` is the convenience
930
954
  // alias for `--scope all`.
931
955
  .option('--for-user <user>', 'GitHub username whose namespace to list (default: resolved from gh/git config)')
932
- .addOption(new Option('--scope <level>', 'visibility filter: public (default), unlisted, me, org, or all')
933
- .choices(['public', 'unlisted', 'me', 'org', 'all'])
956
+ .addOption(new Option('--scope <level>', 'visibility filter: public (default), unlisted, private, me, org, or all')
957
+ .choices(['public', 'unlisted', 'private', 'me', 'org', 'all'])
934
958
  .default('public'))
935
959
  .option('--all', "list every page including hidden unlisted/me/org shares (alias for --scope all)")
936
960
  .option('--agent <name>', 'filter to shares published by this agent/harness (case-insensitive)')
@@ -25,10 +25,12 @@ export declare function foldRegistryCandidates(agent: AgentId, inputs: RunCandid
25
25
  * provider can authenticate `agent` and whose credential is present locally
26
26
  * (RUSH-3182). This is what makes a setup-token / API-key account balance-eligible.
27
27
  *
28
- * Passed as the `collect` argument to {@link resolveRunVersion} on the RUN path
29
- * ONLY — the other consumers of {@link collectRunCandidates} (watchdog, session
30
- * recovery, teams placement) keep calling it directly and are unaffected. The
31
- * existing {@link pickBalancedCandidate} selects over the combined list, so the
28
+ * Passed as the `collect` argument to {@link resolveRunVersion} on the RUN path,
29
+ * and used by session recovery (`resolveSessionRecovery`, PHNX-3626) so an
30
+ * origin-account limit can rotate to a healthy provider account and stay NATIVE.
31
+ * The other consumers of {@link collectRunCandidates} (watchdog, teams
32
+ * placement) keep calling it directly and are unaffected. The existing
33
+ * {@link pickBalancedCandidate} selects over the combined list, so the
32
34
  * revoked / rate-limit / exhausted logic is unchanged.
33
35
  */
34
36
  export declare function collectRunCandidatesForRun(agent: AgentId): Promise<RotateCandidate[]>;
@@ -63,10 +63,12 @@ export function foldRegistryCandidates(agent, inputs) {
63
63
  * provider can authenticate `agent` and whose credential is present locally
64
64
  * (RUSH-3182). This is what makes a setup-token / API-key account balance-eligible.
65
65
  *
66
- * Passed as the `collect` argument to {@link resolveRunVersion} on the RUN path
67
- * ONLY — the other consumers of {@link collectRunCandidates} (watchdog, session
68
- * recovery, teams placement) keep calling it directly and are unaffected. The
69
- * existing {@link pickBalancedCandidate} selects over the combined list, so the
66
+ * Passed as the `collect` argument to {@link resolveRunVersion} on the RUN path,
67
+ * and used by session recovery (`resolveSessionRecovery`, PHNX-3626) so an
68
+ * origin-account limit can rotate to a healthy provider account and stay NATIVE.
69
+ * The other consumers of {@link collectRunCandidates} (watchdog, teams
70
+ * placement) keep calling it directly and are unaffected. The existing
71
+ * {@link pickBalancedCandidate} selects over the combined list, so the
70
72
  * revoked / rate-limit / exhausted logic is unchanged.
71
73
  */
72
74
  export async function collectRunCandidatesForRun(agent) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The `agents __usage-ingest` receiver (PHNX-3392 usage-sync).
3
3
  *
4
- * A headed peer's daemon pipes a {@link UsageSyncPayload} JSON envelope to our
4
+ * A legacy headed peer may pipe a {@link UsageSyncPayload} JSON envelope to our
5
5
  * stdin; we merge its identity-keyed rows into the local usage cache newest-wins
6
6
  * ({@link ingestPeerClaudeUsageRows}). Hidden internal verb — intercepted in
7
7
  * index.ts before bootstrap, so it never triggers an update check or a detached
@@ -12,7 +12,9 @@
12
12
  * accepting a wrong shape, but a busy cache lock degrades to best-effort inside
13
13
  * `ingestPeerClaudeUsageRows` like every other cache writer.
14
14
  *
15
- * The payload arrives on stdin, EXCEPT on a Windows receiver: the `agents.ps1`
15
+ * New daemon ticks use the fleet-shared store instead. This compatibility
16
+ * receiver remains for older installed versions. The payload arrives on stdin,
17
+ * EXCEPT on a Windows receiver: the `agents.ps1`
16
18
  * shim does not forward ssh-piped stdin to the node process, so the pusher writes
17
19
  * the payload to a temp file and passes `agents __usage-ingest --from <path>`
18
20
  * (the same workaround the secrets push uses — `buildWindowsStdinImportCommand`).
@@ -1,107 +1,52 @@
1
- import { type DeviceProfile } from '../devices/registry.js';
2
- import { type ConfiguredDeviceRole } from '../device-config.js';
3
- import { type CachedUsageSnapshot } from './usage.js';
4
- /** How long a single peer push may take before it is abandoned for this tick. */
5
- export declare const USAGE_PUSH_DEADLINE_MS = 20000;
6
- export declare const USAGE_PULL_DEADLINE_MS = 20000;
7
1
  /**
8
- * A worker's newest local usage row older than this triggers a pull from the
9
- * primary. A worker cannot self-read usage (setup-token lacks `user:profile`,
10
- * RUSH-2392), so its only source of a fresh row is the daemon push every
11
- * `USAGE_SYNC_TICK_MS` (15m); this cutoff is 2× that tick, so one missed push
12
- * cycle is tolerated before the worker actively pulls. Bounds worker staleness
13
- * to ~30m instead of the days-old caches this replaces.
2
+ * Fleet distribution of identity-keyed Claude usage through the user repo.
3
+ *
4
+ * A headed device can read authoritative usage; a worker's setup-token cannot.
5
+ * Headed daemons therefore publish one snapshot into their conflict-free
6
+ * `~/.agents/devices/<device>/daemon-state.json`. The daemon's bounded Git
7
+ * transport delivers those files automatically; workers merge newest-wins.
8
+ * There is deliberately no device-to-device SSH in this module or its tick.
14
9
  */
15
- export declare const USAGE_SYNC_MAX_AGE_MS: number;
16
- /** The stdin envelope the `__usage-ingest` verb reads. `v` guards the shape. */
10
+ import { type ConfiguredDeviceRole } from '../device-config.js';
11
+ import { type CachedUsageSnapshot } from './usage.js';
12
+ /** Legacy hidden ingest/export envelope kept for older fleet CLI compatibility. */
17
13
  export interface UsageSyncPayload {
18
14
  v: 1;
19
15
  rows: Record<string, CachedUsageSnapshot>;
20
16
  }
21
- /** One peer the local publisher is considering, reduced to the plan inputs. */
22
- export interface UsagePushTarget {
23
- name: string;
24
- /** The peer's configured role — headed peers are skipped (own reader). */
25
- role: ConfiguredDeviceRole | undefined;
26
- /** Live-ish reachability from the tailscale snapshot; offline peers are skipped. */
27
- online: boolean;
28
- /** Managed known-hosts pin — an unpinned host is skipped, never TOFU-accepted. */
29
- pinned: boolean;
17
+ export interface PublishUsageSnapshotOptions {
18
+ userAgentsDir?: string;
19
+ cachePath?: string;
20
+ role?: ConfiguredDeviceRole;
21
+ device?: string;
30
22
  }
31
- export type UsageSyncPlanItem = {
32
- action: 'push';
33
- device: string;
34
- } | {
35
- action: 'skip';
36
- device: string;
37
- reason: string;
38
- };
39
- /**
40
- * Decide, per peer, whether to push the local usage snapshot. Pure.
41
- *
42
- * - `selfIsPublisher` is false on a `worker`/unmarked box: it has no authoritative
43
- * usage to publish, so every peer is skipped.
44
- * - `hasLocalRows` false means the local cache is empty (nothing to teach yet).
45
- * - A HEADED peer is skipped — it reads its own usage; pushing risks nothing
46
- * (the merge is newest-wins) but is wasted work, and keeping the fan-out to
47
- * consumers only makes the intent legible.
48
- */
49
- export declare function planUsagePush(selfIsPublisher: boolean, hasLocalRows: boolean, targets: UsagePushTarget[]): UsageSyncPlanItem[];
50
- export interface UsageSyncResult {
51
- pushed: string[];
52
- skipped: Array<{
53
- device: string;
54
- reason: string;
55
- }>;
56
- errors: Array<{
57
- device: string;
58
- message: string;
59
- }>;
23
+ export interface PublishUsageSnapshotResult {
24
+ published: boolean;
25
+ changed: boolean;
26
+ skipped: string | null;
27
+ error: string | null;
28
+ path: string | null;
60
29
  }
61
- export interface UsageSyncDeps {
62
- selfRole?: () => ConfiguredDeviceRole | undefined;
63
- listDevices?: () => DeviceProfile[];
64
- /** Configured roles by device name; default reads the fleet config. */
65
- listRoles?: () => Record<string, ConfiguredDeviceRole>;
66
- localName?: () => string;
67
- isPinned?: (name: string) => boolean;
68
- exportRows?: () => Record<string, CachedUsageSnapshot>;
69
- /** Deliver the serialized payload to one peer. Default: ssh `__usage-ingest`. */
70
- push?: (device: DeviceProfile, payload: string) => {
71
- ok: boolean;
72
- message?: string;
73
- };
30
+ /** Publish this headed device's stable usage snapshot into its owned store file. */
31
+ export declare function publishUsageSnapshotToSharedStore(options?: PublishUsageSnapshotOptions): PublishUsageSnapshotResult;
32
+ export interface ConsumeUsageSnapshotsOptions {
33
+ userAgentsDir?: string;
34
+ cachePath?: string;
35
+ role?: ConfiguredDeviceRole;
36
+ device?: string;
37
+ roles?: Record<string, ConfiguredDeviceRole>;
74
38
  }
75
- export interface UsagePullResult {
76
- pulledFrom: string | null;
39
+ export interface ConsumeUsageSnapshotsResult {
40
+ sources: string[];
77
41
  merged: number;
78
42
  skipped: string | null;
79
- error: string | null;
80
- }
81
- export interface UsagePullDeps {
82
- selfRole?: () => ConfiguredDeviceRole | undefined;
83
- listDevices?: () => DeviceProfile[];
84
- listRoles?: () => Record<string, ConfiguredDeviceRole>;
85
- isPinned?: (name: string) => boolean;
86
- exportRows?: () => Record<string, CachedUsageSnapshot>;
87
- ingestRows?: (rows: Record<string, CachedUsageSnapshot>) => number;
88
- /** Injectable clock for the staleness gate; defaults to `Date.now`. */
89
- now?: () => number;
90
- /** Read the versioned payload from the primary. Default: ssh `__usage-export`. */
91
- pull?: (device: DeviceProfile) => {
92
- ok: boolean;
93
- stdout?: string;
94
- message?: string;
95
- };
43
+ errors: Array<{
44
+ device: string;
45
+ message: string;
46
+ }>;
96
47
  }
97
48
  /**
98
- * Pull usage from the fleet's primary headed device when this worker's local
99
- * cache is empty or contains an expired row. `personal` is the primary role;
100
- * a `desktop` is used only when the fleet has no personal device.
101
- */
102
- export declare function pullUsageFromPrimary(deps?: UsagePullDeps): UsagePullResult;
103
- /**
104
- * Push the local identity-keyed usage snapshot to every reachable, pinned,
105
- * non-headed peer. A no-op on a non-headed box or when the local cache is empty.
49
+ * Read headed publishers from the local user-repo checkout and merge one
50
+ * newest-wins batch into the worker cache. No network or subprocess is touched.
106
51
  */
107
- export declare function syncFleetUsageSnapshots(deps?: UsageSyncDeps): UsageSyncResult;
52
+ export declare function consumeUsageSnapshotsFromSharedStore(options?: ConsumeUsageSnapshotsOptions): ConsumeUsageSnapshotsResult;