@phnx-labs/agents-cli 1.22.107 → 1.22.110

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 (95) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/bootstrap.js +3 -2
  3. package/dist/commands/browser-sessions-picker.js +2 -1
  4. package/dist/commands/computer-sessions-picker.js +2 -1
  5. package/dist/commands/cost.js +2 -1
  6. package/dist/commands/daemon.js +45 -40
  7. package/dist/commands/doctor.js +7 -0
  8. package/dist/commands/exec.js +4 -2
  9. package/dist/commands/fork.js +6 -4
  10. package/dist/commands/logs.js +2 -1
  11. package/dist/commands/monitors.js +15 -2
  12. package/dist/commands/routines.js +46 -6
  13. package/dist/commands/sessions-backfill.d.ts +30 -0
  14. package/dist/commands/sessions-backfill.js +72 -0
  15. package/dist/commands/sessions-backup-setup.d.ts +12 -0
  16. package/dist/commands/sessions-backup-setup.js +65 -0
  17. package/dist/commands/sessions-inject.d.ts +3 -3
  18. package/dist/commands/sessions-inject.js +5 -11
  19. package/dist/commands/sessions-picker.js +10 -7
  20. package/dist/commands/sessions-resume.d.ts +17 -2
  21. package/dist/commands/sessions-resume.js +88 -12
  22. package/dist/commands/sessions.js +46 -24
  23. package/dist/commands/share.js +15 -2
  24. package/dist/commands/sync.js +30 -1
  25. package/dist/commands/utils.d.ts +9 -0
  26. package/dist/commands/utils.js +18 -0
  27. package/dist/commands/watchdog.js +4 -1
  28. package/dist/lib/accounting/rotate.d.ts +9 -0
  29. package/dist/lib/accounting/rotate.js +17 -1
  30. package/dist/lib/accounting/usage-sync.d.ts +22 -0
  31. package/dist/lib/accounting/usage-sync.js +70 -6
  32. package/dist/lib/accounting/usage.d.ts +32 -0
  33. package/dist/lib/accounting/usage.js +37 -2
  34. package/dist/lib/accounts/slots.js +7 -0
  35. package/dist/lib/agent-spec/agents.d.ts +60 -8
  36. package/dist/lib/agent-spec/agents.js +118 -45
  37. package/dist/lib/auth-health.js +19 -0
  38. package/dist/lib/claude-statusline.js +5 -0
  39. package/dist/lib/computer/sessions-list.js +2 -1
  40. package/dist/lib/daemon/auth-sync-service.d.ts +2 -0
  41. package/dist/lib/daemon/auth-sync-service.js +2 -2
  42. package/dist/lib/daemon/daemon.d.ts +34 -1
  43. package/dist/lib/daemon/daemon.js +70 -9
  44. package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
  45. package/dist/lib/daemon/leaked-daemons.js +180 -0
  46. package/dist/lib/daemon/session-title-service.d.ts +41 -0
  47. package/dist/lib/daemon/session-title-service.js +76 -0
  48. package/dist/lib/daemon/usage-sync-service.d.ts +9 -1
  49. package/dist/lib/daemon/usage-sync-service.js +10 -2
  50. package/dist/lib/daemon-services.d.ts +1 -1
  51. package/dist/lib/daemon-services.js +5 -0
  52. package/dist/lib/daemon-ticks.js +6 -0
  53. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  54. package/dist/lib/devices/doctor-findings.js +32 -1
  55. package/dist/lib/fleet-shared-state.d.ts +2 -0
  56. package/dist/lib/hooks/install.js +78 -64
  57. package/dist/lib/mailbox-target.js +2 -1
  58. package/dist/lib/models.js +76 -5
  59. package/dist/lib/session/active.d.ts +93 -16
  60. package/dist/lib/session/active.js +80 -14
  61. package/dist/lib/session/cloud.js +3 -1
  62. package/dist/lib/session/db.d.ts +44 -1
  63. package/dist/lib/session/db.js +122 -17
  64. package/dist/lib/session/fork.d.ts +10 -2
  65. package/dist/lib/session/fork.js +11 -2
  66. package/dist/lib/session/live-metadata.js +6 -0
  67. package/dist/lib/session/mirror.d.ts +3 -2
  68. package/dist/lib/session/mirror.js +5 -2
  69. package/dist/lib/session/parse.d.ts +1 -0
  70. package/dist/lib/session/parse.js +136 -2
  71. package/dist/lib/session/recovery.d.ts +9 -1
  72. package/dist/lib/session/recovery.js +14 -4
  73. package/dist/lib/session/remote/watch.js +18 -8
  74. package/dist/lib/session/title.d.ts +256 -0
  75. package/dist/lib/session/title.js +312 -0
  76. package/dist/lib/session/tool-calls.d.ts +1 -1
  77. package/dist/lib/session/tool-calls.js +40 -5
  78. package/dist/lib/session/tool-index.d.ts +5 -0
  79. package/dist/lib/session/tool-index.js +1 -0
  80. package/dist/lib/session/types.d.ts +11 -0
  81. package/dist/lib/share/backend.d.ts +23 -0
  82. package/dist/lib/share/backend.js +24 -0
  83. package/dist/lib/share/provision.d.ts +12 -0
  84. package/dist/lib/share/provision.js +30 -0
  85. package/dist/lib/share/worker-template.js +273 -0
  86. package/dist/lib/startup/root-command.d.ts +2 -0
  87. package/dist/lib/startup/root-command.js +22 -0
  88. package/dist/lib/terminal/engine.js +13 -1
  89. package/dist/lib/traces/sync.js +1 -0
  90. package/dist/lib/usage-refresh.d.ts +58 -7
  91. package/dist/lib/usage-refresh.js +145 -23
  92. package/dist/lib/watchdog/runner.d.ts +3 -0
  93. package/dist/lib/watchdog/runner.js +1 -0
  94. package/dist/session-tracker/dist/install-hook.js +4 -4
  95. package/package.json +1 -1
@@ -85,6 +85,7 @@ import jetbrainsMono from '@fontsource/jetbrains-mono/files/jetbrains-mono-latin
85
85
  export const hooks = {
86
86
  verifyPhoenixToken: defaultVerifyPhoenixToken,
87
87
  renderOgCard: renderOgCard,
88
+ collabFetch: defaultCollabFetch,
88
89
  };
89
90
 
90
91
  export default {
@@ -99,6 +100,19 @@ export default {
99
100
  }
100
101
 
101
102
  const firstSeg = path.split('/').filter(Boolean)[0] || '';
103
+
104
+ // Same-origin human collaboration transport (PHNX-3835). Every /__collab/*
105
+ // verb loads the R2 share object FIRST, re-derives identity/visibility/
106
+ // provenance server-side, reuses the exact page read gate, then proxies to
107
+ // the Prix artifact-collaboration API with the service token (never surfaced
108
+ // to the browser). Routed BEFORE the __-prefix GET 404 guard below because
109
+ // GET /__collab/context|threads|events are first-class. Fails closed (404)
110
+ // when the managed backend is unconfigured — BYO has no Phoenix identity, so
111
+ // managed collaboration correctly does not exist there.
112
+ if (firstSeg === '__collab') {
113
+ return handleCollab(request, env, url, path, ctx);
114
+ }
115
+
102
116
  if ((request.method === 'GET' || request.method === 'HEAD') && firstSeg.startsWith('__')) {
103
117
  return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
104
118
  }
@@ -836,6 +850,26 @@ export default {
836
850
  freeCanonical: delSegs.length === 2,
837
851
  });
838
852
  }
853
+ // PHNX-3835: best-effort purge of the deleted share's collaboration
854
+ // threads. Object-first gating already makes comments inaccessible the
855
+ // instant the R2 object is gone (every /__collab request 404s on the
856
+ // missing object), so this is durable cleanup on the Prix side, never the
857
+ // security boundary. Fire-and-forget so a purge failure never fails the
858
+ // delete. Only a managed (Phoenix) canonical page (2 segments, not a
859
+ // cover, not an internal __-key) can carry collaboration.
860
+ const collabCfgDel = collabConfig(env);
861
+ if (
862
+ collabCfgDel &&
863
+ auth.kind === 'phoenix' &&
864
+ !isCover &&
865
+ !firstSeg.startsWith('__') &&
866
+ path.split('/').filter(Boolean).length === 2
867
+ ) {
868
+ const purgeOwner = (doomed && doomed.customMetadata && doomed.customMetadata.owner) || auth.owner;
869
+ const purge = purgeCollab(env, collabCfgDel, purgeOwner, path);
870
+ if (ctx && typeof ctx.waitUntil === 'function') ctx.waitUntil(purge);
871
+ else await purge;
872
+ }
839
873
  return json({ ok: true, deleted: path }, 200);
840
874
  }
841
875
 
@@ -2147,5 +2181,244 @@ async function authorizeWrite(request, env) {
2147
2181
  }
2148
2182
  return { error: json({ error: 'unauthorized' }, 401) };
2149
2183
  }
2184
+
2185
+ // ---- Same-origin human collaboration transport (PHNX-3835) ----
2186
+ //
2187
+ // The Worker is the trust boundary for collaboration exactly as it is for the
2188
+ // page GET. For every /__collab/* request it:
2189
+ // 1. loads the R2 share object FIRST (object-first) and 404s if absent — share
2190
+ // existence is never enumerable, and a deleted share drops its comments the
2191
+ // instant the object is gone (no separate revocation step);
2192
+ // 2. re-derives share identity, current revision (etag), owner, visibility,
2193
+ // org domain, and producer provenance SERVER-SIDE from R2 metadata, ignoring
2194
+ // anything the browser supplied (the 'share' field is a LOOKUP key only,
2195
+ // never trusted as identity);
2196
+ // 3. reuses the EXACT page read gate (me/org identity gate + private token
2197
+ // gate), mapping any denial to 404 so an out-of-scope viewer cannot tell the
2198
+ // share exists; every WRITE additionally requires a verified signed-in
2199
+ // Phoenix human;
2200
+ // 4. proxies to the Prix artifact-collaboration API with the service token in
2201
+ // Authorization (never surfaced to the browser) plus trusted X-Artifact-* /
2202
+ // X-Phoenix-* headers, streaming SSE through without buffering.
2203
+ //
2204
+ // Missing PRIX_ARTIFACT_COLLAB_BASE or ARTIFACT_COLLAB_SERVICE_TOKEN disables the
2205
+ // whole surface (404) without touching artifact page GET.
2206
+
2207
+ var COLLAB_VISIBILITIES = ['public', 'unlisted', 'private', 'me', 'org'];
2208
+
2209
+ // The managed collaboration backend, or null when unconfigured. Both the base
2210
+ // URL and the service token must be present; either missing fails the whole
2211
+ // surface closed.
2212
+ function collabConfig(env) {
2213
+ const base = typeof env.PRIX_ARTIFACT_COLLAB_BASE === 'string' ? env.PRIX_ARTIFACT_COLLAB_BASE.replace(/\\/+$/, '').trim() : '';
2214
+ const token = typeof env.ARTIFACT_COLLAB_SERVICE_TOKEN === 'string' ? env.ARTIFACT_COLLAB_SERVICE_TOKEN : '';
2215
+ if (!base || !token) return null;
2216
+ return { base: base, token: token };
2217
+ }
2218
+
2219
+ // No collaboration response may be cached (visibility can downgrade on any
2220
+ // request), and every collab body is JSON unless overridden (SSE).
2221
+ function collabHeaders(extra) {
2222
+ const h = new Headers(extra || {});
2223
+ if (!h.has('content-type')) h.set('content-type', 'application/json');
2224
+ h.set('cache-control', 'no-store');
2225
+ return h;
2226
+ }
2227
+
2228
+ function collabError(message, status) {
2229
+ return new Response(JSON.stringify({ error: message }), { status: status, headers: collabHeaders() });
2230
+ }
2231
+
2232
+ // A collab denial is ALWAYS a 404 — never a 302 login bounce or a 401 for a read
2233
+ // — so an out-of-scope viewer cannot enumerate share existence.
2234
+ function collabNotFound() {
2235
+ return collabError('not found', 404);
2236
+ }
2237
+
2238
+ // The page READ gate, reused verbatim for collaboration reads/subscriptions but
2239
+ // with every denial normalized to 404. Returns null when the read is allowed.
2240
+ async function collabReadGate(request, url, obj, identity) {
2241
+ const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
2242
+ // Unknown visibility fails closed.
2243
+ if (COLLAB_VISIBILITIES.indexOf(visibility) === -1) return collabNotFound();
2244
+ if (isIdentityGated(visibility)) {
2245
+ // me/org: require a matching identity; a missing/wrong viewer 404s (never the
2246
+ // page GET's 302 login bounce, which is meaningless to a fetch()).
2247
+ if (!identity || !viewerMayRead(visibility, obj.customMetadata, identity)) return collabNotFound();
2248
+ return null;
2249
+ }
2250
+ if (visibility === 'private') {
2251
+ // private: require the viewer token, exactly as the page GET does.
2252
+ const denied = await gateTokenRead(request, url, obj, identity);
2253
+ if (denied) return collabNotFound();
2254
+ return null;
2255
+ }
2256
+ return null; // public | unlisted — readable when the object exists
2257
+ }
2258
+
2259
+ // Map a browser /__collab sub-route to the Prix artifact-collaboration route +
2260
+ // method. Returns null for any unsupported shape (fail loud, never a wrong path).
2261
+ // Only the cursor rides through as a query param; the browser's own \`share\`
2262
+ // param is dropped (Prix derives identity from the trusted header, not the path).
2263
+ function collabTarget(rest, method, url) {
2264
+ if (rest.length === 1 && rest[0] === 'context' && method === 'GET') {
2265
+ return { path: '/context', search: '', sse: false };
2266
+ }
2267
+ if (rest.length === 1 && rest[0] === 'threads') {
2268
+ if (method === 'GET') {
2269
+ const after = url.searchParams.get('after');
2270
+ return { path: '/threads', search: after ? '?after=' + encodeURIComponent(after) : '', sse: false };
2271
+ }
2272
+ if (method === 'POST') return { path: '/threads', search: '', sse: false };
2273
+ }
2274
+ if (rest.length === 2 && rest[0] === 'threads' && method === 'PATCH') {
2275
+ return { path: '/threads/' + encodeURIComponent(rest[1]), search: '', sse: false };
2276
+ }
2277
+ if (rest.length === 3 && rest[0] === 'threads' && rest[2] === 'replies' && method === 'POST') {
2278
+ return { path: '/threads/' + encodeURIComponent(rest[1]) + '/replies', search: '', sse: false };
2279
+ }
2280
+ if (rest.length === 2 && rest[0] === 'comments' && method === 'PATCH') {
2281
+ return { path: '/comments/' + encodeURIComponent(rest[1]), search: '', sse: false };
2282
+ }
2283
+ if (rest.length === 1 && rest[0] === 'events' && method === 'GET') {
2284
+ return { path: '/events', search: '', sse: true };
2285
+ }
2286
+ return null;
2287
+ }
2288
+
2289
+ async function handleCollab(request, env, url, path, ctx) {
2290
+ const cfg = collabConfig(env);
2291
+ // Fail closed: no managed backend → the surface does not exist.
2292
+ if (!cfg) return collabNotFound();
2293
+
2294
+ const segs = path.split('/').filter(Boolean); // ['__collab', ...]
2295
+ const rest = segs.slice(1);
2296
+ const method = request.method;
2297
+
2298
+ // Resolve the share path. GET carries it in ?share=, mutations in the JSON
2299
+ // body — read that body ONCE here so \`share\` extraction and verbatim upstream
2300
+ // forwarding share a single read.
2301
+ const isMutation = method === 'POST' || method === 'PATCH';
2302
+ let sharePath = '';
2303
+ let bodyText = '';
2304
+ if (isMutation) {
2305
+ bodyText = await request.text();
2306
+ let parsed;
2307
+ try { parsed = JSON.parse(bodyText || '{}'); } catch { return collabError('body must be JSON', 400); }
2308
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return collabError('body must be a JSON object', 400);
2309
+ sharePath = typeof parsed.share === 'string' ? parsed.share : '';
2310
+ } else {
2311
+ sharePath = url.searchParams.get('share') || '';
2312
+ }
2313
+ sharePath = String(sharePath).replace(/^\\/+/, '');
2314
+ const shareFirst = sharePath.split('/').filter(Boolean)[0] || '';
2315
+ // Never let a collab lookup reach an internal __-key or an empty share.
2316
+ if (!sharePath || shareFirst.startsWith('__')) return collabNotFound();
2317
+
2318
+ // OBJECT-FIRST: load the share before deriving anything. A missing share
2319
+ // (never provisioned, or just deleted) 404s.
2320
+ const obj = await env.BUCKET.get(sharePath);
2321
+ if (!obj) return collabNotFound();
2322
+
2323
+ // Re-derive identity SERVER-SIDE and apply the page read gate. Collab clients
2324
+ // authenticate with the Bearer or the __share cookie, never a ?phoenix_ticket=
2325
+ // (that is a page-navigation param the CLI appends when OPENING a page). So we
2326
+ // use only the resolved identity and ignore any redirect outcome — a 302 login
2327
+ // bounce is meaningless to a fetch(). If a ticket were present it would be
2328
+ // redeemed (and, being single-use, consumed) here but its cookie discarded, so
2329
+ // the request falls through as anonymous — the fail-safe (more restrictive)
2330
+ // direction, never an escalation.
2331
+ const viewer = await resolveViewer(request, env, url);
2332
+ const identity = viewer.identity || null;
2333
+ const readDenied = await collabReadGate(request, url, obj, identity);
2334
+ if (readDenied) return readDenied;
2335
+
2336
+ // Every write requires a verified signed-in Phoenix human, ON TOP of the read
2337
+ // gate (public/unlisted readers may read anonymously but never write).
2338
+ if (isMutation && (!identity || !identity.userId)) {
2339
+ return collabError('sign in with Phoenix to comment', 401);
2340
+ }
2341
+
2342
+ const target = collabTarget(rest, method, url);
2343
+ if (!target) return collabError('unsupported collaboration route', 404);
2344
+
2345
+ // Trusted, server-derived values. owner + NUL + normalized path → a stable
2346
+ // share id; the raw private access token is NEVER used as identity.
2347
+ const meta = obj.customMetadata || {};
2348
+ const visibility = meta.visibility || 'public';
2349
+ const owner = meta.owner || '';
2350
+ const shareId = await sha256Hex(owner + '\\u0000' + sharePath);
2351
+ const revision = obj.etag || '';
2352
+
2353
+ const headers = new Headers();
2354
+ headers.set('authorization', 'Bearer ' + cfg.token);
2355
+ headers.set('x-artifact-share-id', shareId);
2356
+ if (revision) headers.set('x-artifact-revision', revision);
2357
+ headers.set('x-artifact-visibility', visibility);
2358
+ if (owner) headers.set('x-artifact-owner-id', owner);
2359
+ if (meta.org_domain) headers.set('x-artifact-org-domain', meta.org_domain);
2360
+ if (identity && identity.userId) headers.set('x-phoenix-actor-id', identity.userId);
2361
+ if (identity && identity.email) headers.set('x-phoenix-actor-email', identity.email);
2362
+ // Preserve existing R2 producer provenance (RUSH-2683) so Prix can attribute a
2363
+ // comment thread to the run that made the artifact.
2364
+ if (meta.agent) headers.set('x-artifact-agent', meta.agent);
2365
+ if (meta.session) headers.set('x-artifact-session', meta.session);
2366
+ if (meta.host) headers.set('x-artifact-host', meta.host);
2367
+ if (meta.repo) headers.set('x-artifact-repo', meta.repo);
2368
+ // Idempotency + resumable-SSE headers ride through unchanged.
2369
+ const idem = request.headers.get('idempotency-key');
2370
+ if (idem) headers.set('idempotency-key', idem);
2371
+ const lastEvent = request.headers.get('last-event-id');
2372
+ if (lastEvent) headers.set('last-event-id', lastEvent);
2373
+ if (target.sse) headers.set('accept', 'text/event-stream');
2374
+ else if (isMutation) headers.set('content-type', 'application/json');
2375
+
2376
+ const init = { method: method, headers: headers };
2377
+ if (isMutation) init.body = bodyText;
2378
+ // Propagate a client disconnect to the upstream fetch (SSE cancel).
2379
+ if (request.signal) init.signal = request.signal;
2380
+
2381
+ let upstream;
2382
+ try {
2383
+ upstream = await hooks.collabFetch(new Request(cfg.base + '/v1/artifact-collaboration' + target.path + target.search, init));
2384
+ } catch (e) {
2385
+ return collabError('collaboration backend unavailable', 502);
2386
+ }
2387
+
2388
+ // Forward Prix status + body. Never echo the service token; every collab
2389
+ // response is no-store. SSE streams through unbuffered — event IDs and content
2390
+ // type ride the bytes, and disabling proxy buffering keeps the stream live.
2391
+ const ct = upstream.headers.get('content-type');
2392
+ const outHeaders = collabHeaders();
2393
+ if (target.sse) {
2394
+ outHeaders.set('content-type', ct || 'text/event-stream');
2395
+ outHeaders.set('x-accel-buffering', 'no');
2396
+ } else if (ct) {
2397
+ outHeaders.set('content-type', ct);
2398
+ }
2399
+ return new Response(upstream.body, { status: upstream.status, headers: outHeaders });
2400
+ }
2401
+
2402
+ // Best-effort authenticated purge of a deleted share's collaboration threads.
2403
+ // Swallows every error — a purge failure must never fail the share delete, and
2404
+ // object-first gating already made the comments inaccessible.
2405
+ async function purgeCollab(env, cfg, owner, sharePath) {
2406
+ try {
2407
+ const shareId = await sha256Hex((owner || '') + '\\u0000' + sharePath);
2408
+ const headers = new Headers();
2409
+ headers.set('authorization', 'Bearer ' + cfg.token);
2410
+ headers.set('x-artifact-share-id', shareId);
2411
+ await hooks.collabFetch(new Request(cfg.base + '/v1/artifact-collaboration/purge', { method: 'POST', headers: headers }));
2412
+ } catch (e) {
2413
+ // best-effort: a purge failure must never fail the share delete
2414
+ }
2415
+ }
2416
+
2417
+ // The real upstream hop to Prix. Overridable in tests (like verifyPhoenixToken)
2418
+ // so the generated Worker's derivation/gating is exercised against a simulated
2419
+ // backend without a live network.
2420
+ async function defaultCollabFetch(request) {
2421
+ return fetch(request);
2422
+ }
2150
2423
  `;
2151
2424
  }
@@ -18,3 +18,5 @@ import type { Command } from 'commander';
18
18
  * instead (renaming the colliding options), not with this global flag.
19
19
  */
20
20
  export declare function configureRootCommand(program: Command, name: string, version: string): Command;
21
+ /** Resume takes one device; its parent listing command takes a variadic list. */
22
+ export declare function normalizeResumeDeviceArgs(args: string[]): string[];
@@ -26,3 +26,25 @@ export function configureRootCommand(program, name, version) {
26
26
  .helpOption('-h, --help', 'Show help')
27
27
  .addHelpCommand(false);
28
28
  }
29
+ /** Resume takes one device; its parent listing command takes a variadic list. */
30
+ export function normalizeResumeDeviceArgs(args) {
31
+ if (args[0] !== 'sessions' || args[1] !== 'resume')
32
+ return args;
33
+ let options = true;
34
+ return args.map((arg, index) => {
35
+ if (index < 2 || !options)
36
+ return arg;
37
+ if (arg === '--') {
38
+ options = false;
39
+ return arg;
40
+ }
41
+ if (arg === '--device' || arg === '--devices' || arg === '-D')
42
+ return '--resume-device';
43
+ if (arg.startsWith('--device=') || arg.startsWith('--devices=')) {
44
+ return `--resume-device=${arg.slice(arg.indexOf('=') + 1)}`;
45
+ }
46
+ if (arg.startsWith('-D'))
47
+ return `--resume-device=${arg.slice(arg[2] === '=' ? 3 : 2)}`;
48
+ return arg;
49
+ });
50
+ }
@@ -1,6 +1,8 @@
1
1
  import { BACKENDS } from './backends/index.js';
2
2
  import { planLayouts } from './policy.js';
3
3
  import { runSpec } from './transport.js';
4
+ import { homeRemainder, remoteCdPrefix } from '../project-root.js';
5
+ import { sshExec } from '../ssh-exec.js';
4
6
  const DEFAULT_STAGGER_MS = 400;
5
7
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
8
  /** The concrete launch command for a request (pure — no side effects). */
@@ -21,7 +23,17 @@ export async function openSurface(req, opts = {}) {
21
23
  // Both can throw: specForRequest on an unknown backend, runSpec when the
22
24
  // SSH transport rejects an invalid --device target. Keep them inside the
23
25
  // catch so a bad request degrades to a per-surface failure, never a throw.
24
- const spec = specForRequest(req);
26
+ let resolved = req;
27
+ if (req.host && req.host !== 'local' && homeRemainder(req.cwd) !== null) {
28
+ const target = opts.resolveHost ? opts.resolveHost(req.host) : req.host;
29
+ const result = sshExec(target, remoteCdPrefix(req.cwd) + 'pwd -P', { multiplex: true });
30
+ const cwd = result.stdout.replace(/\r?\n$/, '');
31
+ if (result.code !== 0 || !cwd.startsWith('/') || /[\r\n]/.test(cwd)) {
32
+ throw new Error(`Cannot resolve directory ${req.cwd} on ${req.host}: ${(result.stderr || '').trim() || 'no absolute working directory returned'}`);
33
+ }
34
+ resolved = { ...req, cwd };
35
+ }
36
+ const spec = specForRequest(resolved);
25
37
  const res = await runSpec(spec, req.host, opts.resolveHost);
26
38
  return { ok: res.ok, request: req, error: res.error };
27
39
  }
@@ -237,6 +237,7 @@ export async function syncTraces(opts = {}) {
237
237
  */
238
238
  const UTILITY_PROMPT_SIGNATURES = [
239
239
  /generate a 3-4 word title/i, // title generation
240
+ /generate a concise session headline/i, // session-title daemon service (PHNX-3797)
240
241
  /you are a watchdog|watchdog monitoring/i, // watchdog tick
241
242
  /conventional[- ]commit/i, // commit-message writer
242
243
  /factory worker/i, // dispatched factory worker
@@ -1,5 +1,6 @@
1
1
  import { type UsageHeadroom, type UsageSnapshot, type UsageInfo } from './accounting/usage.js';
2
2
  import type { AgentId } from './types.js';
3
+ import { type ConfiguredDeviceRole } from './device-config.js';
3
4
  /**
4
5
  * Default schedule between successful (or attempted) live usage fetches for one
5
6
  * account. Floor and ceiling of the delay helper are pinned to this so the
@@ -146,6 +147,48 @@ export declare function providerLastCall(accounts: LocalUsageAccount[], cache: R
146
147
  * gate that binds it.
147
148
  */
148
149
  export declare function providerSpacingTokens(lastCallMs: number, now: number): number;
150
+ /** One account considered for the headed poller. */
151
+ export interface UsagePollCandidate {
152
+ usageKey: string;
153
+ agentId: AgentId;
154
+ home: string;
155
+ holdsNativeLogin: boolean;
156
+ }
157
+ /**
158
+ /** Daemon auth-health may hit `/oauth/usage` only on a headed box, unless forceLive. */
159
+ export declare function mayIssueUsageEndpointProbe(opts: {
160
+ role: ConfiguredDeviceRole | undefined;
161
+ forceLive?: boolean;
162
+ }): boolean;
163
+ /**
164
+ * Sticky one-poller election: lex-least among this box (if it holds a native
165
+ * login) and peer devices that published a `freshnessSource=poll` row. Statusline
166
+ * ingest does not claim. Two headed boxes therefore converge on one poller
167
+ * instead of each deferring to the other.
168
+ */
169
+ export declare function electUsagePoller(opts: {
170
+ selfDevice: string;
171
+ selfHoldsNativeLogin: boolean;
172
+ peerPollers?: string[];
173
+ }): string | null;
174
+ export declare function shouldPollUsageAccount(candidate: Pick<UsagePollCandidate, 'usageKey' | 'holdsNativeLogin'>, opts: {
175
+ role: ConfiguredDeviceRole | undefined;
176
+ selfDevice: string;
177
+ peerPollers?: string[];
178
+ /** @deprecated use peerPollers; kept so older tests that pass claimedBy still compile */
179
+ claimedBy?: Record<string, string>;
180
+ }): boolean;
181
+ /** Native rotating login on this home — Claude's `.credentials.json` blob, else a credential file. */
182
+ export declare function homeHoldsNativeLogin(agentId: AgentId, home: string): boolean;
183
+ /** usageKey → devices that published a `poll` row (statusline/sync do not claim). */
184
+ export declare function pollerClaimsFromSharedStore(selfDevice: string, userAgentsDir?: string): Record<string, string[]>;
185
+ /**
186
+ * Spend one live usage-endpoint call from the shared per-account / per-provider
187
+ * budget. Auth-health and the usage poller both go through here so they cannot
188
+ * together exceed {@link PROVIDER_HOURLY_BUDGET} (~30/hr, well under ~100/hr).
189
+ * Returns false when the call must not fire.
190
+ */
191
+ export declare function trySpendUsageApiCall(usageKey: string, agentId: AgentId, now: number): boolean;
149
192
  /** An account whose credentials live on the publisher host. */
150
193
  export interface LocalUsageAccount {
151
194
  usageKey: string;
@@ -178,15 +221,19 @@ export declare function orderUsageAccounts(accounts: LocalUsageAccount[], cache:
178
221
  * have no rate-limited endpoint to budget.
179
222
  */
180
223
  export declare function providerRecentCalls(accounts: LocalUsageAccount[], cache: Record<string, HeadroomEntry>, now: number): Map<AgentId, number>;
224
+ export interface BuildLocalUsageAccountsOpts {
225
+ role?: ConfiguredDeviceRole;
226
+ device?: string;
227
+ userAgentsDir?: string;
228
+ holdsNativeLogin?: (agentId: AgentId, home: string) => boolean;
229
+ claimedBy?: Record<string, string[]>;
230
+ }
181
231
  /**
182
- * Enumerate the usage accounts whose credentials live on THIS host — one
183
- * per unique usage key, deduped to the most-recently-active version (the same
184
- * canonicalization `getUsageInfoByIdentity` uses). Each carries a closure that
185
- * live-fetches its usage. This is the daemon's `listAccounts`; because it only
186
- * ever lists local, signed-in accounts, each host is the sole writer for its own
187
- * accounts' caches. `runUsageRefreshTick` calls this on every host.
232
+ * Enumerate the usage accounts THIS headed box should poll — native logins it
233
+ * holds, minus accounts another headed poller already claims. A worker or
234
+ * setup-token-only box returns [].
188
235
  */
189
- export declare function buildLocalUsageAccounts(): Promise<LocalUsageAccount[]>;
236
+ export declare function buildLocalUsageAccounts(opts?: BuildLocalUsageAccountsOpts): Promise<LocalUsageAccount[]>;
190
237
  /** Injectable side effects, so `runUsageRefresh` is drivable without the daemon. */
191
238
  export interface UsageRefreshDeps {
192
239
  now?: number;
@@ -212,6 +259,10 @@ export interface UsageRefreshDeps {
212
259
  readCachedSnapshot?: (usageKey: string) => UsageSnapshot | null;
213
260
  /** Daemon tick deadline signal, forwarded to each account's provider fetch (PHNX-3608). */
214
261
  signal?: AbortSignal;
262
+ /** Stamp D8 provenance on a successful poll write. */
263
+ pollerDevice?: string;
264
+ /** Fire after at least one snapshot was written (push-on-change). */
265
+ onSnapshotsChanged?: (usageKeys: string[]) => Promise<void> | void;
215
266
  }
216
267
  export interface UsageRefreshResult {
217
268
  refreshed: number;