@phnx-labs/agents-cli 1.22.66 → 1.22.69

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 (161) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +21 -8
  3. package/dist/bootstrap.js +6 -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 +50 -0
  9. package/dist/commands/feed.js +20 -7
  10. package/dist/commands/monitors.js +5 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/repo.js +57 -19
  14. package/dist/commands/resume.d.ts +16 -0
  15. package/dist/commands/resume.js +41 -8
  16. package/dist/commands/routines.js +42 -21
  17. package/dist/commands/send.js +29 -2
  18. package/dist/commands/sessions-inject.d.ts +58 -0
  19. package/dist/commands/sessions-inject.js +143 -7
  20. package/dist/commands/sessions-optimize.js +1 -1
  21. package/dist/commands/sessions-picker.js +1 -0
  22. package/dist/commands/sessions.js +4 -11
  23. package/dist/commands/share.d.ts +5 -3
  24. package/dist/commands/share.js +73 -20
  25. package/dist/commands/ssh.js +205 -2
  26. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  27. package/dist/lib/accounting/account-pool-collect.js +6 -4
  28. package/dist/lib/accounting/usage-ingest.js +4 -2
  29. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  30. package/dist/lib/accounting/usage-sync.js +66 -210
  31. package/dist/lib/accounting/usage.d.ts +18 -5
  32. package/dist/lib/accounting/usage.js +165 -20
  33. package/dist/lib/auth-health.d.ts +8 -0
  34. package/dist/lib/auth-health.js +4 -4
  35. package/dist/lib/boot-profile.d.ts +14 -0
  36. package/dist/lib/boot-profile.js +66 -0
  37. package/dist/lib/browser/caller-identity.d.ts +12 -0
  38. package/dist/lib/browser/caller-identity.js +19 -0
  39. package/dist/lib/browser/ipc.d.ts +37 -32
  40. package/dist/lib/browser/ipc.js +146 -94
  41. package/dist/lib/browser/task-index.d.ts +10 -2
  42. package/dist/lib/browser/task-index.js +22 -3
  43. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  44. package/dist/lib/channels/providers/desktop.js +5 -4
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  47. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  48. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  49. package/dist/lib/daemon/auth-sync-service.js +17 -6
  50. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  51. package/dist/lib/daemon/catchup-service.js +51 -0
  52. package/dist/lib/daemon/daemon.d.ts +12 -22
  53. package/dist/lib/daemon/daemon.js +463 -176
  54. package/dist/lib/daemon/runner.js +2 -0
  55. package/dist/lib/daemon/service.d.ts +22 -4
  56. package/dist/lib/daemon/service.js +2 -2
  57. package/dist/lib/daemon/supervisor.d.ts +55 -15
  58. package/dist/lib/daemon/supervisor.js +119 -29
  59. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  60. package/dist/lib/daemon/usage-sync-service.js +22 -18
  61. package/dist/lib/daemon-health.js +36 -31
  62. package/dist/lib/daemon-services.d.ts +1 -1
  63. package/dist/lib/daemon-services.js +12 -2
  64. package/dist/lib/daemon-ticks.d.ts +9 -6
  65. package/dist/lib/daemon-ticks.js +14 -8
  66. package/dist/lib/devices/health.d.ts +38 -2
  67. package/dist/lib/devices/health.js +43 -5
  68. package/dist/lib/devices/registry.js +2 -0
  69. package/dist/lib/devices/worker-pick.d.ts +1 -1
  70. package/dist/lib/devices/worker-pick.js +4 -1
  71. package/dist/lib/exec.js +15 -0
  72. package/dist/lib/feed/watch.d.ts +3 -0
  73. package/dist/lib/feed/watch.js +13 -3
  74. package/dist/lib/feed-broadcast.d.ts +64 -5
  75. package/dist/lib/feed-broadcast.js +124 -22
  76. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  77. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  78. package/dist/lib/fleet-shared-state.d.ts +38 -0
  79. package/dist/lib/fleet-shared-state.js +105 -0
  80. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  81. package/dist/lib/hosts/remote-cmd.js +12 -3
  82. package/dist/lib/lock-compromise.d.ts +8 -0
  83. package/dist/lib/lock-compromise.js +12 -0
  84. package/dist/lib/monitors/engine.d.ts +2 -1
  85. package/dist/lib/monitors/engine.js +27 -2
  86. package/dist/lib/monitors/sources/command.js +13 -3
  87. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  88. package/dist/lib/monitors/sources/failure.js +52 -0
  89. package/dist/lib/monitors/sources/types.d.ts +9 -0
  90. package/dist/lib/owner-message.d.ts +12 -0
  91. package/dist/lib/owner-message.js +44 -0
  92. package/dist/lib/refresh-coordinator.js +2 -0
  93. package/dist/lib/run-trace-sync.d.ts +28 -0
  94. package/dist/lib/run-trace-sync.js +99 -0
  95. package/dist/lib/secrets/filestore.d.ts +4 -0
  96. package/dist/lib/secrets/filestore.js +164 -3
  97. package/dist/lib/secrets/push.d.ts +10 -0
  98. package/dist/lib/secrets/push.js +86 -7
  99. package/dist/lib/secrets/remote.d.ts +18 -6
  100. package/dist/lib/secrets/remote.js +29 -4
  101. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  102. package/dist/lib/secrets/reserved-sync.js +119 -101
  103. package/dist/lib/session/active.d.ts +13 -1
  104. package/dist/lib/session/active.js +5 -0
  105. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  106. package/dist/lib/session/actor-sidecar.js +2 -0
  107. package/dist/lib/session/db.d.ts +16 -2
  108. package/dist/lib/session/db.js +73 -22
  109. package/dist/lib/session/discover.d.ts +12 -3
  110. package/dist/lib/session/discover.js +187 -26
  111. package/dist/lib/session/linear.d.ts +13 -0
  112. package/dist/lib/session/linear.js +44 -0
  113. package/dist/lib/session/live-metadata.js +1 -0
  114. package/dist/lib/session/parse.js +2 -3
  115. package/dist/lib/session/prompt.d.ts +17 -0
  116. package/dist/lib/session/prompt.js +35 -0
  117. package/dist/lib/session/recovery.d.ts +43 -6
  118. package/dist/lib/session/recovery.js +80 -10
  119. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  120. package/dist/lib/session/remote/remote-list.js +29 -4
  121. package/dist/lib/session/remote/watch.d.ts +25 -2
  122. package/dist/lib/session/remote/watch.js +188 -11
  123. package/dist/lib/session/session-cache.d.ts +2 -1
  124. package/dist/lib/session/session-cache.js +1 -0
  125. package/dist/lib/session/state.js +11 -13
  126. package/dist/lib/session/types.d.ts +2 -0
  127. package/dist/lib/share/backend.d.ts +2 -2
  128. package/dist/lib/share/backend.js +20 -9
  129. package/dist/lib/share/delete.d.ts +5 -1
  130. package/dist/lib/share/delete.js +7 -2
  131. package/dist/lib/share/http-error.d.ts +52 -0
  132. package/dist/lib/share/http-error.js +65 -0
  133. package/dist/lib/share/publish.d.ts +67 -11
  134. package/dist/lib/share/publish.js +98 -16
  135. package/dist/lib/share/worker-template.js +105 -9
  136. package/dist/lib/smart-launch.js +27 -4
  137. package/dist/lib/ssh-exec.d.ts +2 -0
  138. package/dist/lib/ssh-exec.js +20 -4
  139. package/dist/lib/storage/index.d.ts +14 -0
  140. package/dist/lib/storage/index.js +14 -0
  141. package/dist/lib/storage/selection.d.ts +48 -0
  142. package/dist/lib/storage/selection.js +39 -0
  143. package/dist/lib/storage/visibility.d.ts +82 -0
  144. package/dist/lib/storage/visibility.js +99 -0
  145. package/dist/lib/teams/agents.js +3 -1
  146. package/dist/lib/teams/placement-probe.js +1 -0
  147. package/dist/lib/teams/registry.js +2 -0
  148. package/dist/lib/teams/scheduler.d.ts +8 -1
  149. package/dist/lib/teams/scheduler.js +4 -1
  150. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  151. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  152. package/dist/lib/traces/backend.js +13 -2
  153. package/dist/lib/traces/sync.d.ts +7 -0
  154. package/dist/lib/traces/sync.js +9 -0
  155. package/dist/lib/usage-refresh.d.ts +8 -2
  156. package/dist/lib/usage-refresh.js +3 -3
  157. package/dist/lib/worktree/held.d.ts +166 -0
  158. package/dist/lib/worktree/held.js +368 -0
  159. package/package.json +2 -2
  160. package/dist/lib/account-state-service.d.ts +0 -21
  161. package/dist/lib/account-state-service.js +0 -60
@@ -38,7 +38,8 @@ import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } fro
38
38
  import { lookupTransport } from './channels/resolve.js';
39
39
  import { registerBuiltinProviders } from './channels/providers/index.js';
40
40
  import { sendToOwner } from './notify.js';
41
- import { linearIssueUrl } from './session/linear.js';
41
+ import { linearIssueUrl, linearIssueKeys } from './session/linear.js';
42
+ import { isValidMailboxId } from './mailbox.js';
42
43
  import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
43
44
  const LEVEL_RANK = { milestone: 0, important: 1 };
44
45
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
@@ -145,6 +146,27 @@ export function shortSessionChunk(session) {
145
146
  const chunk = hex.replace(/[^a-f0-9]/g, '').slice(0, 8);
146
147
  return chunk || undefined;
147
148
  }
149
+ /**
150
+ * Tap-to-view link for the session behind a post: the addressable console page
151
+ * ({@link https://prix.dev/console/sessions/<id>}, prix/web). The footer already
152
+ * carries a short session crumb for disambiguation; this rides the link trail so
153
+ * the owner can open the full transcript straight from an iMessage broadcast
154
+ * instead of hunting for it in the console.
155
+ *
156
+ * Accepts any real, path-safe session id — a Claude/Codex UUID *and* a native
157
+ * `ses_…` id from OpenCode or another harness. The console shard uploader
158
+ * (`traces/sync.ts`) syncs sessions with no harness filter, so all of them are
159
+ * addressable; a UUID-only gate would silently drop the link for every non-Claude
160
+ * harness (the whole point of the link). Reject only an id that could not resolve:
161
+ * one with a path separator (URL-unsafe, via {@link isValidMailboxId}) or the bare
162
+ * 8-char footer crumb (a truncated id that would 404).
163
+ */
164
+ export function sessionConsoleUrl(session) {
165
+ const id = session?.trim();
166
+ if (!id || !isValidMailboxId(id) || /^[0-9a-f]{8}$/i.test(id))
167
+ return undefined;
168
+ return `https://prix.dev/console/sessions/${id}`;
169
+ }
148
170
  /**
149
171
  * Scrub em/en dashes from outbound phone copy (house rule + iMessage readability).
150
172
  * Collapses whitespace; does not invent meaning.
@@ -158,6 +180,56 @@ export function scrubOutboundDashes(text) {
158
180
  .replace(/[ \t]{2,}/g, ' ')
159
181
  .trim();
160
182
  }
183
+ /** Slack mrkdwn labeled link: `<url|label>` renders as blue `label` text. */
184
+ function slackLink(url, label) {
185
+ return `<${url}|${label}>`;
186
+ }
187
+ /**
188
+ * Only Slack renders `<url|label>`, so it is the one format that gets labeled
189
+ * links. iMessage / owner-scoped rush / command / desktop sinks stay `plain`
190
+ * (they can't turn `claude/6fc1db18` blue, and dumping the raw URL is worse than
191
+ * leaving the crumb unlinked — PHNX-3698).
192
+ *
193
+ * The argument is the **resolved provider name**, not the sink's declared
194
+ * channel: an operator can point an arbitrary channel name at the Slack provider
195
+ * through `notify.transports` (e.g. `eng-alerts -> slack`), and delivery keys off
196
+ * that resolved provider (`lookupTransport`), so the format decision must too —
197
+ * otherwise an aliased Slack sink would compose plain while delivering to Slack,
198
+ * or a name remapped AWAY from Slack would emit `<url|label>` markup a non-Slack
199
+ * transport shows literally. {@link resolveSinkProvider} does the mapping.
200
+ */
201
+ export function sinkMessageFormat(provider) {
202
+ return provider?.trim().toLowerCase() === 'slack' ? 'mrkdwn' : 'plain';
203
+ }
204
+ /**
205
+ * The provider a channel name actually delivers through — the same
206
+ * `notify.transports` remap `lookupTransport` applies at delivery — so the format
207
+ * decision and the delivery agree on what Slack is. Identity when no mapping
208
+ * exists (or no `meta`), matching the default name-identity transport rule.
209
+ */
210
+ function resolveSinkProvider(channel, meta) {
211
+ return meta?.notify?.transports?.[channel] ?? channel;
212
+ }
213
+ /**
214
+ * Replace each real Linear key the text NAMES with a Slack labeled link to its
215
+ * issue — `PHNX-3689` → `<https://linear.app/getrush/issue/PHNX-3689|PHNX-3689>`
216
+ * — so the key itself turns blue in place (no trailing URL line). Plain format,
217
+ * or a key the workspace can't resolve, or a denylisted unit string, is left as
218
+ * the bare key. `linearIssueKeys` is the same canonical detector the trail used,
219
+ * so mrkdwn linkifies exactly the keys plain leaves as text.
220
+ */
221
+ function linkifyKeys(text, format) {
222
+ if (format !== 'mrkdwn' || !text)
223
+ return text;
224
+ let out = text;
225
+ for (const key of linearIssueKeys(text)) {
226
+ const url = linearIssueUrl(key);
227
+ if (!url)
228
+ continue;
229
+ out = out.replace(new RegExp(`\\b${key}\\b`, 'g'), slackLink(url, key));
230
+ }
231
+ return out;
232
+ }
161
233
  /**
162
234
  * Footer like "Sent from my iPhone" — who posted, a session crumb, which box.
163
235
  *
@@ -165,8 +237,13 @@ export function scrubOutboundDashes(text) {
165
237
  *
166
238
  * Agent name first; session chunk for disambiguation when many groks run;
167
239
  * host last. Skip the uninformative default label `agent`.
240
+ *
241
+ * In `mrkdwn` the crumb (`agent/short`) becomes a Slack labeled link to the
242
+ * session's console page, so the human sentence reads identically while the
243
+ * crumb turns blue and taps through (PHNX-3698). `plain` keeps the bare sentence
244
+ * — it can't render a labeled link and must not dump the URL.
168
245
  */
169
- export function composeBroadcastFooter(ctx) {
246
+ export function composeBroadcastFooter(ctx, format = 'plain') {
170
247
  const agent = ctx.agent?.trim();
171
248
  const agentLabel = agent && agent !== 'agent' ? agent : undefined;
172
249
  const session = shortSessionChunk(ctx.session);
@@ -178,6 +255,10 @@ export function composeBroadcastFooter(ctx) {
178
255
  who = agentLabel;
179
256
  else if (session)
180
257
  who = session;
258
+ // The crumb is only a link when the session resolves to a real console page.
259
+ const consoleUrl = who ? sessionConsoleUrl(ctx.session) : undefined;
260
+ if (who && format === 'mrkdwn' && consoleUrl)
261
+ who = slackLink(consoleUrl, who);
181
262
  if (who && host)
182
263
  return `Sent from ${who} on ${host}`;
183
264
  if (who)
@@ -236,16 +317,17 @@ export function truncateBroadcastBody(body) {
236
317
  return body;
237
318
  return `${out.trimEnd()}\n… (full in feed)`;
238
319
  }
239
- export function composeBroadcastMessage(ctx) {
320
+ export function composeBroadcastMessage(ctx, format = 'plain') {
240
321
  const title = scrubOutboundDashes(ctx.title ?? '');
241
322
  const body = truncateBroadcastBody(scrubOutboundDashes(ctx.text ?? ''));
242
323
  // Title preferred; if an older post has no title, body alone still sends.
243
- const head = title || body;
244
- const mid = title && body && title !== body ? body : undefined;
245
- const footer = composeBroadcastFooter(ctx);
246
- const links = [ctx.ticketUrl, ...(ctx.links ?? [])]
247
- .filter((l) => !!l && /^https?:\/\//i.test(l))
248
- .filter((l, i, all) => all.indexOf(l) === i);
324
+ // A `TEAM-N` key the human typed is dead text on a phone — in `mrkdwn` the key
325
+ // itself becomes a Slack labeled link in place, so nothing rides a trailing
326
+ // naked URL line; `plain` leaves the bare key (iMessage can't render a label,
327
+ // and dumping the URL is worse than leaving it — PHNX-3698).
328
+ const head = linkifyKeys(title || body, format);
329
+ const mid = title && body && title !== body ? linkifyKeys(body, format) : undefined;
330
+ const footer = composeBroadcastFooter(ctx, format);
249
331
  // The action block: the one thing the operator can act on from a phone. Show the
250
332
  // choices, then what happens if they do not answer. Deliberately NOT a CLI command
251
333
  // (`agents focus <id>` is unusable from a phone) -- the safe default is the real
@@ -259,8 +341,6 @@ export function composeBroadcastMessage(ctx) {
259
341
  : `Default: ${scrubOutboundDashes(ctx.safeDefault)}`)
260
342
  : undefined;
261
343
  const action = [choices, fallback].filter(Boolean).join('\n') || undefined;
262
- // Link trail after the "Sent from" footer so the human sentence stays at the top.
263
- const trail = [footer, ...links].filter(Boolean);
264
344
  const parts = [];
265
345
  if (head)
266
346
  parts.push(head);
@@ -275,16 +355,22 @@ export function composeBroadcastMessage(ctx) {
275
355
  parts.push('');
276
356
  parts.push(action);
277
357
  }
278
- if (trail.length) {
279
- // Blank line before the footer block (iPhone "Sent from my iPhone" spacing).
358
+ if (footer) {
359
+ // Blank line before the "Sent from" footer (iPhone "Sent from my iPhone" spacing).
360
+ // The crumb/ticket links are inline (footer + prose) — never a trailing URL line.
280
361
  if (parts.length)
281
362
  parts.push('');
282
- parts.push(trail.join('\n'));
363
+ parts.push(footer);
283
364
  }
284
365
  return parts.join('\n').trim();
285
366
  }
286
- /** The values a template may reference, resolved once per post. */
287
- function templateVars(ctx) {
367
+ /**
368
+ * The values a template may reference, resolved once per post. `format` decides
369
+ * how `{message}` surfaces its links — Slack `mrkdwn` (labeled links) vs `plain`
370
+ * (the human sentence, no URLs). The scalar `{ticket_url}`/`{links}` vars are the
371
+ * raw URLs a custom `message:` template can place itself, so they are unaffected.
372
+ */
373
+ function templateVars(ctx, format = 'plain') {
288
374
  return {
289
375
  title: ctx.title,
290
376
  text: ctx.text,
@@ -296,7 +382,7 @@ function templateVars(ctx) {
296
382
  session: ctx.session,
297
383
  level: ctx.level,
298
384
  links: ctx.links?.length ? ctx.links.join(' ') : undefined,
299
- message: composeBroadcastMessage(ctx),
385
+ message: composeBroadcastMessage(ctx, format),
300
386
  block: ctx.blockId,
301
387
  class: ctx.class,
302
388
  cost: ctx.cost,
@@ -330,9 +416,14 @@ export function renderSinkArgv(template, ctx) {
330
416
  }
331
417
  return argv.length > 0 ? argv : undefined;
332
418
  }
333
- /** Render one channel-message template with the same fail-closed placeholder contract as argv. */
334
- export function renderSinkMessage(template, ctx) {
335
- const vars = templateVars(ctx);
419
+ /**
420
+ * Render one channel-message template with the same fail-closed placeholder
421
+ * contract as argv. `format` (Slack `mrkdwn` vs `plain`) flows into the shared
422
+ * `{message}` var so a Slack sink gets labeled links and an iMessage/owner sink
423
+ * gets the plain sentence.
424
+ */
425
+ export function renderSinkMessage(template, ctx, format = 'plain') {
426
+ const vars = templateVars(ctx, format);
336
427
  let missing = false;
337
428
  const rendered = template.replace(PLACEHOLDER, (whole, key) => {
338
429
  const value = vars[key];
@@ -367,8 +458,12 @@ export function renderSinkMessage(template, ctx) {
367
458
  * A `channel:` sink is gated by the same `minLevel` rule as a `command:` sink —
368
459
  * one level check for both shapes, so a dry-run plan is truthful regardless of
369
460
  * which shape an operator's sink uses.
461
+ *
462
+ * `meta` is used only to resolve a channel name to its real provider for the
463
+ * mrkdwn/plain format decision (`notify.transports`), the same map delivery uses;
464
+ * it is optional so a test can plan without a config snapshot (identity mapping).
370
465
  */
371
- export function planFeedBroadcast(config, ctx) {
466
+ export function planFeedBroadcast(config, ctx, meta) {
372
467
  if (!config)
373
468
  return [];
374
469
  const planned = [];
@@ -386,7 +481,14 @@ export function planFeedBroadcast(config, ctx) {
386
481
  // placeholder below).
387
482
  if (!isOwnerAlias(channel) && !sink.to?.trim())
388
483
  continue;
389
- const text = renderSinkMessage(sink.message ?? '{message}', ctx);
484
+ // Slack renders labeled links; every other channel (owner alias, iMessage,
485
+ // telegram, discord, mailbox, desktop) stays plain (PHNX-3698). The owner
486
+ // alias is deliberately plain even when it fans out to a Slack destination:
487
+ // it delivers ONE shared string to every channel in owner.policy.normal, so
488
+ // mrkdwn markup would corrupt a sibling iMessage copy. Keying on the
489
+ // resolved provider (not the raw name) matches what delivery does.
490
+ const provider = isOwnerAlias(channel) ? channel : resolveSinkProvider(channel, meta);
491
+ const text = renderSinkMessage(sink.message ?? '{message}', ctx, sinkMessageFormat(provider));
390
492
  if (!text)
391
493
  continue;
392
494
  planned.push({
@@ -0,0 +1,36 @@
1
+ export declare const FLEET_SHARED_REPO_SYNC_DEADLINE_MS = 45000;
2
+ export declare const FLEET_SHARED_REPO_KILL_GRACE_MS = 250;
3
+ export interface BoundedProcessResult {
4
+ code: number | null;
5
+ stdout: string;
6
+ stderr: string;
7
+ timedOut: boolean;
8
+ }
9
+ export interface FleetSharedRepoSyncOptions {
10
+ userAgentsDir?: string;
11
+ device?: string;
12
+ timeoutMs?: number;
13
+ /** Test-only redirection for the untracked cross-process lock target. */
14
+ lockPath?: string;
15
+ }
16
+ export interface FleetSharedRepoSyncResult {
17
+ success: boolean;
18
+ committed: boolean;
19
+ pushed: boolean;
20
+ commit: string | null;
21
+ timedOut: boolean;
22
+ skipped: string | null;
23
+ error: string | null;
24
+ }
25
+ /** Run one real process asynchronously with a hard wall-clock/process-tree bound. */
26
+ export declare function runBoundedProcess(command: string, args: string[], options: {
27
+ cwd: string;
28
+ timeoutMs: number;
29
+ env?: NodeJS.ProcessEnv;
30
+ }): Promise<BoundedProcessResult>;
31
+ /**
32
+ * Commit/pull/push this device's shared state under one cross-process lock.
33
+ * Callers publish their local fields first and consume peer fields only after
34
+ * this resolves successfully.
35
+ */
36
+ export declare function syncFleetSharedStateRepo(options?: FleetSharedRepoSyncOptions): Promise<FleetSharedRepoSyncResult>;
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Automatic transport for the non-secret fleet state stored in the user repo.
3
+ *
4
+ * Usage/auth services publish only their own conflict-free
5
+ * `devices/<device>/daemon-state.json` file. This module commits that one path,
6
+ * rebases the user repo, and pushes it so peer daemons can read the snapshot
7
+ * after their own fetch/rebase. Git runs asynchronously under one cross-process
8
+ * lock; every invocation has a wall-clock deadline and a timed-out process tree
9
+ * is terminated, including git's ssh child and its remote channel.
10
+ */
11
+ import { spawn } from 'node:child_process';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import lockfile from 'proper-lockfile';
15
+ import { fleetSharedStatePath } from './fleet-shared-state.js';
16
+ import { ensureLockTarget } from './fs-atomic.js';
17
+ import { logAndContinueOnLockCompromised } from './lock-compromise.js';
18
+ import { machineId } from './session/sync/config.js';
19
+ import { getDaemonDir, getUserAgentsDir } from './state.js';
20
+ export const FLEET_SHARED_REPO_SYNC_DEADLINE_MS = 45_000;
21
+ export const FLEET_SHARED_REPO_KILL_GRACE_MS = 250;
22
+ const FLEET_SHARED_REPO_OUTPUT_MAX_BYTES = 1024 * 1024;
23
+ const FLEET_SHARED_REPO_PUSH_ATTEMPTS = 3;
24
+ const FLEET_SHARED_REPO_REBASE_CLEANUP_RESERVE_MS = 5_000;
25
+ function signalProcessTree(child, signal) {
26
+ const pid = child.pid;
27
+ if (!pid)
28
+ return;
29
+ if (process.platform === 'win32') {
30
+ // Windows has no process-group signal. taskkill is asynchronous here so a
31
+ // timeout path cannot replace one event-loop stall with another.
32
+ const killer = spawn('taskkill', ['/F', '/T', '/PID', String(pid)], {
33
+ stdio: 'ignore',
34
+ windowsHide: true,
35
+ });
36
+ killer.unref();
37
+ return;
38
+ }
39
+ try {
40
+ // The child is spawned detached on POSIX, so -pid reaches git plus ssh.
41
+ process.kill(-pid, signal);
42
+ }
43
+ catch {
44
+ try {
45
+ child.kill(signal);
46
+ }
47
+ catch { /* already gone */ }
48
+ }
49
+ }
50
+ /** Run one real process asynchronously with a hard wall-clock/process-tree bound. */
51
+ export function runBoundedProcess(command, args, options) {
52
+ return new Promise((resolve) => {
53
+ const child = spawn(command, args, {
54
+ cwd: options.cwd,
55
+ env: options.env ?? process.env,
56
+ detached: process.platform !== 'win32',
57
+ stdio: ['ignore', 'pipe', 'pipe'],
58
+ windowsHide: true,
59
+ });
60
+ let stdout = '';
61
+ let stderr = '';
62
+ let settled = false;
63
+ let timedOut = false;
64
+ let overflow = false;
65
+ let killTimer = null;
66
+ const finish = (code) => {
67
+ if (settled)
68
+ return;
69
+ settled = true;
70
+ clearTimeout(timer);
71
+ if (killTimer)
72
+ clearTimeout(killTimer);
73
+ resolve({ code, stdout, stderr, timedOut });
74
+ };
75
+ const hardKill = () => {
76
+ signalProcessTree(child, 'SIGKILL');
77
+ if (process.platform === 'win32' && !killTimer) {
78
+ // taskkill owns the descendant cleanup; this fallback only guarantees
79
+ // the direct child's Promise settles if taskkill itself cannot run.
80
+ killTimer = setTimeout(() => {
81
+ if (!settled) {
82
+ try {
83
+ child.kill('SIGKILL');
84
+ }
85
+ catch { /* already gone */ }
86
+ }
87
+ }, FLEET_SHARED_REPO_KILL_GRACE_MS);
88
+ }
89
+ };
90
+ const timer = setTimeout(() => {
91
+ timedOut = true;
92
+ if (process.platform === 'win32') {
93
+ hardKill();
94
+ return;
95
+ }
96
+ signalProcessTree(child, 'SIGTERM');
97
+ killTimer = setTimeout(() => {
98
+ if (!settled)
99
+ hardKill();
100
+ }, FLEET_SHARED_REPO_KILL_GRACE_MS);
101
+ }, Math.max(1, options.timeoutMs));
102
+ const append = (stream, chunk) => {
103
+ if (overflow)
104
+ return;
105
+ const value = String(chunk);
106
+ if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) + Buffer.byteLength(value) > FLEET_SHARED_REPO_OUTPUT_MAX_BYTES) {
107
+ overflow = true;
108
+ stderr += '\ngit output exceeded 1 MiB';
109
+ hardKill();
110
+ return;
111
+ }
112
+ if (stream === 'stdout')
113
+ stdout += value;
114
+ else
115
+ stderr += value;
116
+ };
117
+ child.stdout?.on('data', (chunk) => append('stdout', chunk));
118
+ child.stderr?.on('data', (chunk) => append('stderr', chunk));
119
+ child.on('error', (err) => {
120
+ stderr += err.message;
121
+ finish(null);
122
+ });
123
+ child.on('close', (code) => finish(code));
124
+ });
125
+ }
126
+ function failure(command, result) {
127
+ const detail = result.timedOut
128
+ ? `${command} timed out`
129
+ : result.stderr.trim() || result.stdout.trim() || `${command} exited ${result.code ?? 'without a status'}`;
130
+ return {
131
+ success: false,
132
+ committed: false,
133
+ pushed: false,
134
+ commit: null,
135
+ timedOut: result.timedOut,
136
+ skipped: null,
137
+ error: detail,
138
+ };
139
+ }
140
+ function isPushRace(result) {
141
+ const text = `${result.stdout}\n${result.stderr}`.toLowerCase();
142
+ return text.includes('non-fast-forward') || text.includes('fetch first') || text.includes('[rejected]');
143
+ }
144
+ function createdAutostashObject(result) {
145
+ return `${result.stdout}\n${result.stderr}`.match(/created autostash:\s*([0-9a-f]+)/i)?.[1] ?? null;
146
+ }
147
+ function retainedAutostash(stashList, objectId) {
148
+ if (stashList.code === 0) {
149
+ const entries = stashList.stdout.trim().split('\n').filter(Boolean).map((line) => {
150
+ const [ref = '', hash = '', ...subject] = line.split('\t');
151
+ return { ref, hash, subject: subject.join('\t') };
152
+ });
153
+ const matched = objectId
154
+ ? entries.find((entry) => entry.hash.startsWith(objectId) || objectId.startsWith(entry.hash))
155
+ : entries.find((entry) => /autostash/i.test(entry.subject));
156
+ if (matched?.ref)
157
+ return { label: `${matched.ref} (${matched.hash.slice(0, 8)})`, ref: matched.ref };
158
+ }
159
+ return {
160
+ label: objectId ? `autostash object ${objectId}` : 'the retained autostash',
161
+ ref: null,
162
+ };
163
+ }
164
+ async function performFleetSharedRepoSync(root, device, timeoutMs) {
165
+ if (!fs.existsSync(path.join(root, '.git'))) {
166
+ return {
167
+ success: false,
168
+ committed: false,
169
+ pushed: false,
170
+ commit: null,
171
+ timedOut: false,
172
+ skipped: `user store is not git-backed: ${root}`,
173
+ error: null,
174
+ };
175
+ }
176
+ const deadline = Date.now() + timeoutMs;
177
+ const git = async (args, reserveMs = 0) => {
178
+ const remaining = deadline - Date.now() - reserveMs;
179
+ if (remaining <= 0)
180
+ return { code: null, stdout: '', stderr: '', timedOut: true };
181
+ return runBoundedProcess('git', args, {
182
+ cwd: root,
183
+ timeoutMs: remaining,
184
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
185
+ });
186
+ };
187
+ const branchResult = await git(['branch', '--show-current']);
188
+ if (branchResult.code !== 0)
189
+ return failure('git branch --show-current', branchResult);
190
+ const branch = branchResult.stdout.trim();
191
+ if (!branch) {
192
+ return {
193
+ success: false,
194
+ committed: false,
195
+ pushed: false,
196
+ commit: null,
197
+ timedOut: false,
198
+ skipped: null,
199
+ error: 'user store is on a detached HEAD',
200
+ };
201
+ }
202
+ const ownedFile = fleetSharedStatePath(device, root);
203
+ const relativeOwnedFile = path.relative(root, ownedFile).split(path.sep).join('/');
204
+ let committed = false;
205
+ if (fs.existsSync(ownedFile)) {
206
+ const status = await git(['status', '--porcelain=v1', '--', relativeOwnedFile]);
207
+ if (status.code !== 0)
208
+ return failure('git status', status);
209
+ if (status.stdout.trim()) {
210
+ const add = await git(['add', '--', relativeOwnedFile]);
211
+ if (add.code !== 0)
212
+ return failure('git add', add);
213
+ const commit = await git([
214
+ '-c', 'commit.gpgsign=false',
215
+ 'commit', '--no-verify', '-m', `chore(devices): publish ${device} daemon state`,
216
+ '--', relativeOwnedFile,
217
+ ]);
218
+ if (commit.code !== 0)
219
+ return failure('git commit', commit);
220
+ committed = true;
221
+ }
222
+ }
223
+ for (let attempt = 1; attempt <= FLEET_SHARED_REPO_PUSH_ATTEMPTS; attempt++) {
224
+ const fetch = await git(['fetch', 'origin']);
225
+ if (fetch.code !== 0)
226
+ return { ...failure('git fetch', fetch), committed };
227
+ // Keep enough of the same wall-clock bound available to remove a botched
228
+ // autostash pop from the operator's live checkout before returning.
229
+ const rebase = await git(['rebase', '--autostash', `origin/${branch}`], FLEET_SHARED_REPO_REBASE_CLEANUP_RESERVE_MS);
230
+ if (rebase.code !== 0) {
231
+ // Rebase can leave control files behind even when the child was killed.
232
+ // Abort is bounded by the same overall deadline and never hides the cause.
233
+ await git(['rebase', '--abort']);
234
+ return { ...failure('git rebase', rebase), committed };
235
+ }
236
+ // `git rebase --autostash` exits zero when the rebase succeeds but applying
237
+ // the autostash conflicts. Never push or leave those conflict markers in
238
+ // the operator's real ~/.agents checkout. Git retains the original changes
239
+ // in the stash on this path, so reset only the failed pop to rebased HEAD.
240
+ const unmerged = await git(['ls-files', '--unmerged']);
241
+ if (unmerged.code !== 0)
242
+ return { ...failure('git ls-files --unmerged', unmerged), committed };
243
+ if (unmerged.stdout.trim()) {
244
+ const autostashObject = createdAutostashObject(rebase);
245
+ const reset = await git(['reset', '--hard', 'HEAD']);
246
+ const cleaned = await git(['ls-files', '--unmerged']);
247
+ const stashList = await git(['stash', 'list', '--format=%gd%x09%H%x09%gs']);
248
+ const stash = retainedAutostash(stashList, autostashObject);
249
+ if (reset.code !== 0 || cleaned.code !== 0 || cleaned.stdout.trim()) {
250
+ const cleanup = reset.code !== 0
251
+ ? failure('git reset --hard HEAD', reset).error
252
+ : cleaned.code !== 0
253
+ ? failure('git ls-files --unmerged', cleaned).error
254
+ : 'unmerged index entries remain after git reset --hard HEAD';
255
+ return {
256
+ success: false,
257
+ committed,
258
+ pushed: false,
259
+ commit: null,
260
+ timedOut: reset.timedOut || cleaned.timedOut,
261
+ skipped: null,
262
+ error: `autostash pop conflicted; push refused, but cleanup failed: ${cleanup}. Original tracked changes remain safe in ${stash.label}`,
263
+ };
264
+ }
265
+ return {
266
+ success: false,
267
+ committed,
268
+ pushed: false,
269
+ commit: null,
270
+ timedOut: false,
271
+ skipped: null,
272
+ error: `autostash pop conflicted; push refused and the worktree was reset to rebased HEAD without conflict markers. Original tracked changes remain safe in ${stash.label}; inspect with ${stash.ref ? `git stash show -p ${stash.ref}` : 'git stash list'}`,
273
+ };
274
+ }
275
+ const push = await git(['push', '--', 'origin', branch]);
276
+ if (push.code === 0) {
277
+ const head = await git(['rev-parse', '--short=8', 'HEAD']);
278
+ return {
279
+ success: true,
280
+ committed,
281
+ pushed: committed || push.stdout.trim().length > 0 || push.stderr.includes('->'),
282
+ commit: head.code === 0 ? head.stdout.trim() : null,
283
+ timedOut: false,
284
+ skipped: null,
285
+ error: null,
286
+ };
287
+ }
288
+ if (attempt === FLEET_SHARED_REPO_PUSH_ATTEMPTS || !isPushRace(push)) {
289
+ return { ...failure('git push', push), committed };
290
+ }
291
+ }
292
+ throw new Error('unreachable fleet shared repo push loop');
293
+ }
294
+ /**
295
+ * Commit/pull/push this device's shared state under one cross-process lock.
296
+ * Callers publish their local fields first and consume peer fields only after
297
+ * this resolves successfully.
298
+ */
299
+ export async function syncFleetSharedStateRepo(options = {}) {
300
+ const root = options.userAgentsDir ?? getUserAgentsDir();
301
+ const device = options.device ?? machineId();
302
+ const timeoutMs = options.timeoutMs ?? FLEET_SHARED_REPO_SYNC_DEADLINE_MS;
303
+ const lockPath = options.lockPath ?? path.join(getDaemonDir(), 'fleet-shared-repo-sync');
304
+ ensureLockTarget(lockPath, '');
305
+ let release = null;
306
+ try {
307
+ release = await lockfile.lock(lockPath, {
308
+ stale: Math.max(10_000, timeoutMs + 5_000),
309
+ retries: { retries: 20, factor: 1, minTimeout: 100, maxTimeout: 100 },
310
+ onCompromised: logAndContinueOnLockCompromised('fleet shared repo sync'),
311
+ });
312
+ return await performFleetSharedRepoSync(root, device, timeoutMs);
313
+ }
314
+ catch (err) {
315
+ return {
316
+ success: false,
317
+ committed: false,
318
+ pushed: false,
319
+ commit: null,
320
+ timedOut: false,
321
+ skipped: null,
322
+ error: err.message,
323
+ };
324
+ }
325
+ finally {
326
+ if (release) {
327
+ try {
328
+ await release();
329
+ }
330
+ catch { /* compromised/already released */ }
331
+ }
332
+ }
333
+ }
@@ -0,0 +1,38 @@
1
+ import type { CachedUsageSnapshot } from './accounting/usage.js';
2
+ export declare const FLEET_SHARED_STATE_VERSION = 1;
3
+ export declare const FLEET_SHARED_STATE_FILE = "daemon-state.json";
4
+ export type SharedAuthStatus = 'ready' | 'missing' | 'invalid';
5
+ export interface FleetSharedDeviceState {
6
+ version: typeof FLEET_SHARED_STATE_VERSION;
7
+ device: string;
8
+ usage?: {
9
+ rows: Record<string, CachedUsageSnapshot>;
10
+ };
11
+ auth?: {
12
+ status: SharedAuthStatus;
13
+ };
14
+ }
15
+ export interface FleetSharedStatePatch {
16
+ usage?: FleetSharedDeviceState['usage'];
17
+ auth?: FleetSharedDeviceState['auth'];
18
+ }
19
+ export interface FleetSharedStateReadResult {
20
+ states: FleetSharedDeviceState[];
21
+ errors: Array<{
22
+ device: string;
23
+ message: string;
24
+ }>;
25
+ }
26
+ /** Path owned by one device in the conflict-free tracked device-doc tree. */
27
+ export declare function fleetSharedStatePath(device: string, userAgentsDir?: string): string;
28
+ /**
29
+ * Merge one daemon-owned field into this device's shared file under a real
30
+ * inter-process lock. Stable serialization avoids dirtying the user repo when
31
+ * neither usage nor auth state changed.
32
+ */
33
+ export declare function updateFleetSharedDeviceState(device: string, patch: FleetSharedStatePatch, userAgentsDir?: string): {
34
+ changed: boolean;
35
+ path: string;
36
+ };
37
+ /** Read every valid peer-owned state file; malformed peers fail separately. */
38
+ export declare function readFleetSharedDeviceStates(userAgentsDir?: string): FleetSharedStateReadResult;