@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
@@ -23,6 +23,7 @@
23
23
  import * as fs from 'fs';
24
24
  import * as path from 'path';
25
25
  import { getDaemonDir } from './state.js';
26
+ import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
26
27
  const HEALTH_FILE = 'health.json';
27
28
  /** Stable subsystem identifiers shared by the daemon (writer) and `agents daemon` (reader). */
28
29
  export const SUBSYSTEM_SECRETS_BROKER = 'secrets-broker';
@@ -61,15 +62,19 @@ function readAll() {
61
62
  * dropped health update, never escape as an unhandled rejection that would
62
63
  * hit the process-wide handler and take down every OTHER service too.
63
64
  */
64
- function writeAll(records) {
65
+ function updateAll(update) {
65
66
  try {
66
67
  const healthPath = getHealthPath();
67
- fs.mkdirSync(path.dirname(healthPath), { recursive: true });
68
- fs.writeFileSync(healthPath, JSON.stringify(records), 'utf-8');
69
- try {
70
- fs.chmodSync(healthPath, 0o600);
71
- }
72
- catch { /* best effort */ }
68
+ ensureLockTarget(healthPath, '{}');
69
+ withFileLock(healthPath, () => {
70
+ const records = readAll();
71
+ update(records);
72
+ atomicWriteFileSync(healthPath, JSON.stringify(records), { encoding: 'utf-8', mode: 0o600 });
73
+ try {
74
+ fs.chmodSync(healthPath, 0o600);
75
+ }
76
+ catch { /* best effort */ }
77
+ });
73
78
  }
74
79
  catch { /* see docblock above — health recording must never crash a caller */ }
75
80
  }
@@ -78,23 +83,23 @@ function blankRecord(subsystem) {
78
83
  }
79
84
  /** Record a successful subsystem check-in — clears the failure streak. */
80
85
  export function recordSubsystemOk(subsystem, at = new Date().toISOString()) {
81
- const all = readAll();
82
- const existing = all[subsystem] ?? blankRecord(subsystem);
83
- all[subsystem] = { ...existing, subsystem, consecutiveFailures: 0, lastOkAt: at };
84
- writeAll(all);
86
+ updateAll((all) => {
87
+ const existing = all[subsystem] ?? blankRecord(subsystem);
88
+ all[subsystem] = { ...existing, subsystem, consecutiveFailures: 0, lastOkAt: at };
89
+ });
85
90
  }
86
91
  /** Record a subsystem failure — bumps the consecutive-failure streak. */
87
92
  export function recordSubsystemError(subsystem, error, at = new Date().toISOString()) {
88
- const all = readAll();
89
- const existing = all[subsystem] ?? blankRecord(subsystem);
90
- all[subsystem] = {
91
- ...existing,
92
- subsystem,
93
- lastError: error,
94
- lastErrorAt: at,
95
- consecutiveFailures: existing.consecutiveFailures + 1,
96
- };
97
- writeAll(all);
93
+ updateAll((all) => {
94
+ const existing = all[subsystem] ?? blankRecord(subsystem);
95
+ all[subsystem] = {
96
+ ...existing,
97
+ subsystem,
98
+ lastError: error,
99
+ lastErrorAt: at,
100
+ consecutiveFailures: existing.consecutiveFailures + 1,
101
+ };
102
+ });
98
103
  }
99
104
  /**
100
105
  * Refine the reason on an already-counted failure, without bumping the streak.
@@ -109,12 +114,12 @@ export function recordSubsystemError(subsystem, error, at = new Date().toISOStri
109
114
  * subsystem is left alone rather than given a blank record to decorate.
110
115
  */
111
116
  export function recordSubsystemErrorReason(subsystem, error, at = new Date().toISOString()) {
112
- const all = readAll();
113
- const existing = all[subsystem];
114
- if (!existing)
115
- return;
116
- all[subsystem] = { ...existing, lastError: error, lastErrorAt: at };
117
- writeAll(all);
117
+ updateAll((all) => {
118
+ const existing = all[subsystem];
119
+ if (!existing)
120
+ return;
121
+ all[subsystem] = { ...existing, lastError: error, lastErrorAt: at };
122
+ });
118
123
  }
119
124
  /**
120
125
  * Record a `ServiceSupervisor` lifecycle-state transition, without touching
@@ -123,10 +128,10 @@ export function recordSubsystemErrorReason(subsystem, error, at = new Date().toI
123
128
  * status` runs as a separate process from the daemon (see module docblock).
124
129
  */
125
130
  export function recordSubsystemState(subsystem, state) {
126
- const all = readAll();
127
- const existing = all[subsystem] ?? blankRecord(subsystem);
128
- all[subsystem] = { ...existing, subsystem, state };
129
- writeAll(all);
131
+ updateAll((all) => {
132
+ const existing = all[subsystem] ?? blankRecord(subsystem);
133
+ all[subsystem] = { ...existing, subsystem, state };
134
+ });
130
135
  }
131
136
  /** Read one subsystem's health record, or null if it has never reported in. */
132
137
  export function readSubsystemHealth(subsystem) {
@@ -7,7 +7,7 @@
7
7
  * enabled without pulling in the whole daemon lifecycle.
8
8
  */
9
9
  /** Every service the daemon can host. IDs are kebab-case and stable. */
10
- export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'keychain-reap' | 'account-state' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state';
10
+ export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'keychain-reap' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state';
11
11
  /** Human-readable metadata for each service. */
12
12
  export interface DaemonServiceDef {
13
13
  id: DaemonServiceId;
@@ -22,6 +22,11 @@ export const DAEMON_SERVICES = [
22
22
  title: 'Routine scheduler',
23
23
  description: 'Fires cron-scheduled routines and catches up missed fires.',
24
24
  },
25
+ {
26
+ id: 'catchup',
27
+ title: 'Catch-up recovery',
28
+ description: 'Supervised pass that re-runs routines whose scheduled fire this device missed (sleep, wedge, or suspend). No-ops while the scheduler gate is off.',
29
+ },
25
30
  {
26
31
  id: 'monitors',
27
32
  title: 'Monitor engine',
@@ -49,8 +54,13 @@ export const DAEMON_SERVICES = [
49
54
  },
50
55
  {
51
56
  id: 'account-state',
52
- title: 'Account state',
53
- description: 'Refreshes account quota/usage and publishes the local fleet-status row.',
57
+ title: 'Account usage refresh',
58
+ description: 'Refreshes account quota/usage on its own tick (PHNX-3608: independent circuit breaker from account-auth).',
59
+ },
60
+ {
61
+ id: 'account-auth',
62
+ title: 'Account auth refresh',
63
+ description: 'Publishes this host\'s fleet-status row and refreshes auth health on its own slower tick (PHNX-3608: independent circuit breaker from account-state).',
54
64
  },
55
65
  {
56
66
  id: 'watchdog',
@@ -2,10 +2,12 @@
2
2
  * Daemon account-state tick bodies.
3
3
  *
4
4
  * These two bodies are the `refreshUsage` / `refreshAuth` implementations the
5
- * daemon's `account-state-service.ts` timers call directly, in-process, on a
6
- * plain `setInterval` (usage every 60s, auth every ~3 min). They are NOT
7
- * routines and are never fired through the scheduler — the daemon owns usage and
8
- * authentication health as first-party device state (RUSH-2451).
5
+ * supervised `AccountStateDaemonService` (daemon/account-state-daemon-service.ts)
6
+ * runs on its tick, in-process (usage every tick, auth on the slower ~3 min
7
+ * cadence). They are NOT routines and are never fired through the scheduler — the
8
+ * daemon owns usage and authentication health as first-party device state
9
+ * (RUSH-2451); the supervisor bounds each tick with a deadline + AbortSignal so a
10
+ * hung refresh is abandoned and restarted instead of latching (PHNX-3608).
9
11
  *
10
12
  * `refreshLocalFleetAuthState` is also called by `agents fleet`/`ssh` surfaces
11
13
  * that need a fresh local auth snapshot on demand; provider-level work is guarded
@@ -59,11 +61,12 @@ export declare function shouldReuseCachedAuthProbe(force: boolean, cached: AuthP
59
61
  */
60
62
  export declare function refreshLocalFleetAuthState(opts?: {
61
63
  force?: boolean;
64
+ signal?: AbortSignal;
62
65
  }): Promise<{
63
66
  row: FleetStatusRow;
64
67
  authRows: import('./auth-health.js').AuthProbeRow[];
65
68
  }>;
66
- export declare function runFleetCacheWarmTick(): Promise<void>;
69
+ export declare function runFleetCacheWarmTick(signal?: AbortSignal): Promise<void>;
67
70
  /**
68
71
  * Usage refresh: keep the usage cache the `agents run` router reads
69
72
  * (RUSH-2061, readOnly hot path) fresh, WITHOUT the hot path ever fetching.
@@ -71,7 +74,7 @@ export declare function runFleetCacheWarmTick(): Promise<void>;
71
74
  * accounts it holds credentials for, straight from the provider APIs
72
75
  * (RUSH-3193 #15; no cross-host broadcast).
73
76
  */
74
- export declare function runUsageRefreshTick(): Promise<void>;
77
+ export declare function runUsageRefreshTick(signal?: AbortSignal): Promise<void>;
75
78
  /**
76
79
  * Active-sessions warm (RUSH-2062 / RUSH-2484): publish THIS host's live session
77
80
  * rows so `agents sessions watch` (and the extension that tails it) receive
@@ -2,10 +2,12 @@
2
2
  * Daemon account-state tick bodies.
3
3
  *
4
4
  * These two bodies are the `refreshUsage` / `refreshAuth` implementations the
5
- * daemon's `account-state-service.ts` timers call directly, in-process, on a
6
- * plain `setInterval` (usage every 60s, auth every ~3 min). They are NOT
7
- * routines and are never fired through the scheduler — the daemon owns usage and
8
- * authentication health as first-party device state (RUSH-2451).
5
+ * supervised `AccountStateDaemonService` (daemon/account-state-daemon-service.ts)
6
+ * runs on its tick, in-process (usage every tick, auth on the slower ~3 min
7
+ * cadence). They are NOT routines and are never fired through the scheduler — the
8
+ * daemon owns usage and authentication health as first-party device state
9
+ * (RUSH-2451); the supervisor bounds each tick with a deadline + AbortSignal so a
10
+ * hung refresh is abandoned and restarted instead of latching (PHNX-3608).
9
11
  *
10
12
  * `refreshLocalFleetAuthState` is also called by `agents fleet`/`ssh` surfaces
11
13
  * that need a fresh local auth snapshot on demand; provider-level work is guarded
@@ -62,6 +64,7 @@ export function shouldReuseCachedAuthProbe(force, cached, now, maxAgeMs = AUTH_P
62
64
  */
63
65
  export async function refreshLocalFleetAuthState(opts) {
64
66
  const force = opts?.force === true;
67
+ const signal = opts?.signal;
65
68
  const { machineId } = await import('./machine-id.js');
66
69
  const { probeLocalFleetAuth, readFleetAuthRows, writeFleetAuthRows } = await import('./auth-health.js');
67
70
  const { getCliVersion } = await import('./version.js');
@@ -89,7 +92,7 @@ export async function refreshLocalFleetAuthState(opts) {
89
92
  // Fleet status publishes every tick regardless — it does not ride that endpoint.
90
93
  const cached = readFleetAuthRows(self);
91
94
  const reuse = shouldReuseCachedAuthProbe(force, cached, requestedAt);
92
- const authRows = reuse ? cached : await probeLocalFleetAuth({ cliVersion: getCliVersion(), forceLive: force });
95
+ const authRows = reuse ? cached : await probeLocalFleetAuth({ cliVersion: getCliVersion(), forceLive: force, signal });
93
96
  if (!reuse)
94
97
  writeFleetAuthRows(self, authRows);
95
98
  const row = await publishLocalFleetStatus(self);
@@ -97,8 +100,8 @@ export async function refreshLocalFleetAuthState(opts) {
97
100
  },
98
101
  });
99
102
  }
100
- export async function runFleetCacheWarmTick() {
101
- const result = await refreshLocalFleetAuthState();
103
+ export async function runFleetCacheWarmTick(signal) {
104
+ const result = await refreshLocalFleetAuthState({ signal });
102
105
  // A waiter receives the already-published fleet row. The auth-row count is
103
106
  // available only to the process that performed the provider probes.
104
107
  const row = result.row;
@@ -112,7 +115,7 @@ export async function runFleetCacheWarmTick() {
112
115
  * accounts it holds credentials for, straight from the provider APIs
113
116
  * (RUSH-3193 #15; no cross-host broadcast).
114
117
  */
115
- export async function runUsageRefreshTick() {
118
+ export async function runUsageRefreshTick(signal) {
116
119
  const { runUsageRefresh, buildLocalUsageAccounts } = await import('./usage-refresh.js');
117
120
  const { writeClaudeUsageCache, readClaudeUsageCache } = await import('./accounting/usage.js');
118
121
  const { usageRateLimitedUntil } = await import('./usage-backoff.js');
@@ -124,6 +127,9 @@ export async function runUsageRefreshTick() {
124
127
  // so a recent capture means the account is already fresh at zero API cost —
125
128
  // the refresher re-derives headroom from it and skips the API fetch.
126
129
  readCachedSnapshot: (usageKey) => readClaudeUsageCache(usageKey),
130
+ // Thread the supervisor deadline into each provider fetch so the tick's I/O
131
+ // is bounded by deadlineMs, not just each fetch's own 5s timeout (PHNX-3608).
132
+ signal,
127
133
  });
128
134
  const { listProfiles } = await import('./profiles.js');
129
135
  const { refreshDueByokUsage } = await import('./byok-usage.js');
@@ -12,9 +12,37 @@
12
12
  * separate copy on purpose: the CLI does not import across packages.
13
13
  */
14
14
  import type { DeviceProfile } from './registry.js';
15
- /** Default per-device probe budget. Short enough that the list never hangs on a
16
- * wedged box, long enough for a cold relayed SSH handshake. */
15
+ /** Default per-device probe budget, for a device reachable over a DIRECT
16
+ * Tailscale path. Short enough that the list never hangs on a wedged box. */
17
17
  export declare const PROBE_TIMEOUT_MS = 2500;
18
+ /**
19
+ * Probe budget for a device whose last handshake was DERP-relayed
20
+ * ({@link DeviceTailscale.direct} === false).
21
+ *
22
+ * This constant used to not exist: 2.5s was applied to every device and its
23
+ * docstring claimed to be "long enough for a cold relayed SSH handshake". That
24
+ * was false, and on a fleet with no direct paths it broke `--device auto`
25
+ * outright (PHNX-3682). Measured on a 9-box relayed fleet, probed in parallel
26
+ * with cold paths: 1686/1805/1914/2688/2706/2749/3082/5586/6588 ms — six of nine
27
+ * over budget, every one of them healthy (rc=0 within 10s). Warm, the same
28
+ * probes take 512-872ms, which is what made the failure intermittent.
29
+ *
30
+ * A relayed hop pays DERP path setup on top of the TCP+SSH handshake, so it
31
+ * gets the same budget the readiness probe already allows
32
+ * (`READY_PROBE_TIMEOUT_MS`) rather than the direct-path one.
33
+ */
34
+ export declare const RELAYED_PROBE_TIMEOUT_MS = 8000;
35
+ /**
36
+ * The probe budget for one device. A relayed peer gets
37
+ * {@link RELAYED_PROBE_TIMEOUT_MS}; a direct (or unknown-path) peer keeps the
38
+ * tight {@link PROBE_TIMEOUT_MS}. Windows keeps its own larger budget, which
39
+ * already exceeds both.
40
+ *
41
+ * `direct` is only meaningful when a tailscale snapshot exists — a
42
+ * `via:"manual"` device never gets a peer entry, so absence is "unknown path",
43
+ * not "relayed", and must not silently widen every manual device's budget.
44
+ */
45
+ export declare function probeBudgetMs(device: DeviceProfile): number;
18
46
  /** Windows probe budget. The first CIM query of a PowerShell session pays a
19
47
  * "Preparing modules for first use" cost on top of PowerShell startup, which
20
48
  * routinely blows the 2.5s POSIX budget on a relayed connection. */
@@ -35,6 +63,14 @@ export declare function localProbeInvocation(platform: NodeJS.Platform): {
35
63
  export interface DeviceStats {
36
64
  host: string;
37
65
  reachable: boolean;
66
+ /**
67
+ * The probe exceeded its budget rather than being refused or unresolvable.
68
+ * Only meaningful when `reachable` is false — it separates "this box did not
69
+ * answer in time" from "this box actively could not be reached", so callers
70
+ * report a slow link honestly instead of calling a healthy device offline
71
+ * (PHNX-3682).
72
+ */
73
+ timedOut?: boolean;
38
74
  loadAvg1?: number;
39
75
  ncpu?: number;
40
76
  /** Load normalized to core count (the "has room" number): loadAvg1 / ncpu *
@@ -13,9 +13,43 @@
13
13
  */
14
14
  import { execFile } from 'child_process';
15
15
  import { buildSshInvocation, writeAskpassShim } from './connect.js';
16
- /** Default per-device probe budget. Short enough that the list never hangs on a
17
- * wedged box, long enough for a cold relayed SSH handshake. */
16
+ /** Default per-device probe budget, for a device reachable over a DIRECT
17
+ * Tailscale path. Short enough that the list never hangs on a wedged box. */
18
18
  export const PROBE_TIMEOUT_MS = 2_500;
19
+ /**
20
+ * Probe budget for a device whose last handshake was DERP-relayed
21
+ * ({@link DeviceTailscale.direct} === false).
22
+ *
23
+ * This constant used to not exist: 2.5s was applied to every device and its
24
+ * docstring claimed to be "long enough for a cold relayed SSH handshake". That
25
+ * was false, and on a fleet with no direct paths it broke `--device auto`
26
+ * outright (PHNX-3682). Measured on a 9-box relayed fleet, probed in parallel
27
+ * with cold paths: 1686/1805/1914/2688/2706/2749/3082/5586/6588 ms — six of nine
28
+ * over budget, every one of them healthy (rc=0 within 10s). Warm, the same
29
+ * probes take 512-872ms, which is what made the failure intermittent.
30
+ *
31
+ * A relayed hop pays DERP path setup on top of the TCP+SSH handshake, so it
32
+ * gets the same budget the readiness probe already allows
33
+ * (`READY_PROBE_TIMEOUT_MS`) rather than the direct-path one.
34
+ */
35
+ export const RELAYED_PROBE_TIMEOUT_MS = 8_000;
36
+ /**
37
+ * The probe budget for one device. A relayed peer gets
38
+ * {@link RELAYED_PROBE_TIMEOUT_MS}; a direct (or unknown-path) peer keeps the
39
+ * tight {@link PROBE_TIMEOUT_MS}. Windows keeps its own larger budget, which
40
+ * already exceeds both.
41
+ *
42
+ * `direct` is only meaningful when a tailscale snapshot exists — a
43
+ * `via:"manual"` device never gets a peer entry, so absence is "unknown path",
44
+ * not "relayed", and must not silently widen every manual device's budget.
45
+ */
46
+ export function probeBudgetMs(device) {
47
+ if (device.shell === 'powershell')
48
+ return WIN_PROBE_TIMEOUT_MS;
49
+ return device.tailscale && device.tailscale.direct === false
50
+ ? RELAYED_PROBE_TIMEOUT_MS
51
+ : PROBE_TIMEOUT_MS;
52
+ }
19
53
  /** Windows probe budget. The first CIM query of a PowerShell session pays a
20
54
  * "Preparing modules for first use" cost on top of PowerShell startup, which
21
55
  * routinely blows the 2.5s POSIX budget on a relayed connection. */
@@ -227,10 +261,14 @@ export function probeDeviceStats(device, opts = {}) {
227
261
  execFile('ssh', args, {
228
262
  encoding: 'utf-8',
229
263
  env: { ...process.env, ...env },
230
- timeout: opts.timeoutMs ?? (isWin ? WIN_PROBE_TIMEOUT_MS : PROBE_TIMEOUT_MS),
264
+ timeout: opts.timeoutMs ?? probeBudgetMs(device),
231
265
  }, (err, stdout) => {
232
- if (err || !stdout)
233
- return resolve({ host, reachable: false, fetchedAt });
266
+ // execFile kills an over-budget child with a signal; that is a slow link,
267
+ // not an unreachable box, and the two must not read the same downstream.
268
+ if (err || !stdout) {
269
+ const timedOut = Boolean(err && err.killed);
270
+ return resolve(timedOut ? { host, reachable: false, timedOut, fetchedAt } : { host, reachable: false, fetchedAt });
271
+ }
234
272
  resolve(isWin ? parseWinProbeOutput(host, stdout, fetchedAt) : parseProbeOutput(host, stdout, fetchedAt));
235
273
  });
236
274
  });
@@ -21,6 +21,7 @@ import { getDevicesRegistryPath, readMeta, updateMeta } from '../state.js';
21
21
  import { atomicWriteJsonSync } from '../fs-atomic.js';
22
22
  import { machineId } from '../machine-id.js';
23
23
  import { addIgnoredEntry, unionDeviceIgnored } from './device-docs.js';
24
+ import { logAndContinueOnLockCompromised } from '../lock-compromise.js';
24
25
  /**
25
26
  * Whether a fan-out should dial this device, honouring the preference stated on
26
27
  * {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
@@ -141,6 +142,7 @@ async function withRegistryLock(p, fn) {
141
142
  const release = await lockfile.lock(p, {
142
143
  retries: { retries: 60, minTimeout: 25, maxTimeout: 250, factor: 1.5 },
143
144
  stale: 10_000,
145
+ onCompromised: logAndContinueOnLockCompromised('devices registry'),
144
146
  });
145
147
  try {
146
148
  return await fn();
@@ -2,7 +2,7 @@ import { type DevicePlacementSignal } from '../teams/scheduler.js';
2
2
  /** Why a candidate was dropped, for the fail-loud error message. */
3
3
  export interface WorkerExclusion {
4
4
  device: string;
5
- reason: 'unreachable' | 'overloaded' | 'wrong-platform' | 'interactive';
5
+ reason: 'unreachable' | 'probe timed out' | 'overloaded' | 'wrong-platform' | 'interactive';
6
6
  }
7
7
  export interface WorkerPickPlan {
8
8
  /** The chosen device name. Never the local box unless it is an auto-pool member. */
@@ -79,7 +79,10 @@ export async function resolveWorkerDevice(opts = {}) {
79
79
  const eligible = onPlatform.filter((device) => {
80
80
  const signal = signals.get(device);
81
81
  if (signal?.reachable !== true) {
82
- excluded.push({ device, reason: 'unreachable' });
82
+ // A probe killed for exceeding its budget says nothing about whether the
83
+ // box is up — on a relayed fleet it usually is. Report the two apart so
84
+ // the message points at the link, not at a fleet outage (PHNX-3682).
85
+ excluded.push({ device, reason: signal?.timedOut ? 'probe timed out' : 'unreachable' });
83
86
  return false;
84
87
  }
85
88
  if (signal.headroom === 'loaded') {
package/dist/lib/exec.js CHANGED
@@ -40,6 +40,7 @@ import { resolveHarnessAdapter, stripForeignConfigDir } from './harness/index.js
40
40
  import { resolveConfigVersion } from './harness/exec-config-version.js';
41
41
  import { getAccountInfo } from './agents.js';
42
42
  import { getUsageLookupKey, noteClaudeSessionLimit, noteClaudeOutOfCredits, clearClaudeAccountRefusal, parseClaudeSessionLimitReset } from './accounting/usage.js';
43
+ import { bootMark, flushBootProfile } from './boot-profile.js';
43
44
  /**
44
45
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
45
46
  *
@@ -409,6 +410,17 @@ export function buildExecEnv(options) {
409
410
  // re-parsing harness-specific transcripts.
410
411
  result.AGENTS_RUN_MODE = resolveHeadlessMode(options.agent, normalizeMode(options.mode), resolveInteractive(options), options.modeWarningContext, options.modeWarningState);
411
412
  result.AGENTS_HISTORY_DIR = getHistoryDir();
413
+ // Durable origin version for a later native resume. The SessionStart hook joins
414
+ // this to the harness's real session id (like AGENTS_RUN_MODE), so recovery can
415
+ // pin the exact origin version even when the transcript carries no derivable one
416
+ // (codex's `.codex-homes/<version>/` home) — the "version not recorded" fallback
417
+ // to `/continue` this closes (PHNX-3626). Resolve the concrete version-home id
418
+ // the same way the spawn path does; only stamp a real one.
419
+ if (options.agent) {
420
+ const runVersion = options.version ?? resolveVersion(options.agent, options.cwd || process.cwd());
421
+ if (runVersion)
422
+ result.AGENTS_RUN_VERSION = runVersion;
423
+ }
412
424
  // So activity / feed posts stamp the right harness without re-detecting.
413
425
  // A custom-harness run (`agents run deepseek`) must stamp the PROFILE name,
414
426
  // not the host CLI (`claude`) — otherwise sessions and feed posts cannot
@@ -1722,6 +1734,7 @@ async function emitRunLaunch(ctx) {
1722
1734
  }
1723
1735
  }
1724
1736
  async function spawnAgent(options) {
1737
+ bootMark('spawn-agent:enter');
1725
1738
  // Assign a known session id up front for agents that accept one, so the
1726
1739
  // launcher can record an EXACT pid -> session mapping (see pid-registry) —
1727
1740
  // otherwise the headless `ag sessions --active` path can only guess
@@ -1856,6 +1869,7 @@ async function spawnAgent(options) {
1856
1869
  });
1857
1870
  if (tmuxWrap.kind === 'wrap') {
1858
1871
  timer.mark('startup');
1872
+ flushBootProfile('spawn');
1859
1873
  try {
1860
1874
  const result = await runInTmux(options, executable, args);
1861
1875
  timer.end({ exitCode: result.exitCode, status: result.exitCode === 0 ? 'success' : 'failed' });
@@ -1887,6 +1901,7 @@ async function spawnAgent(options) {
1887
1901
  const useShell = process.platform === 'win32' && (!path.isAbsolute(executable) || executable.endsWith('.cmd'));
1888
1902
  const spawnCommand = useShell ? composeWin32CommandLine(executable, args) : executable;
1889
1903
  const spawnArgs = useShell ? [] : args;
1904
+ flushBootProfile('spawn');
1890
1905
  const child = spawn(spawnCommand, spawnArgs, {
1891
1906
  cwd: options.cwd || process.cwd(),
1892
1907
  stdio,
@@ -18,6 +18,9 @@ export type FeedWatchEnvelope = Base & {
18
18
  type: 'agent.upsert';
19
19
  rowKey: string;
20
20
  agent: SessionWatchRow;
21
+ } | Base & {
22
+ type: 'agent.remove';
23
+ rowKey: string;
21
24
  } | Base & {
22
25
  type: 'attention.upsert';
23
26
  rowKey: string;
@@ -21,13 +21,20 @@ export class FeedWatchState {
21
21
  }
22
22
  }
23
23
  async function attentionFor(agent) {
24
- if (!agent.sessionId)
24
+ // Durable Previous rows share the operator stream for Sessions history, but
25
+ // they are not live work and must never synthesize Needs-you attention.
26
+ if (!agent.sessionId || agent.previous || agent.context === 'recent')
25
27
  return undefined;
26
28
  const blockId = blockIdForSession(agent.sessionId);
27
29
  // ActiveSession.host names the terminal app; the feed contract's host is the
28
30
  // device scope. Normalize only the reconciler input so lifecycle/PR keys are
29
31
  // routable across the fleet while the projected agent row stays compatible.
30
- const session = { ...agent, host: agent.sourceDevice, viewingIn: undefined };
32
+ const session = {
33
+ ...agent,
34
+ context: agent.context,
35
+ host: agent.sourceDevice,
36
+ viewingIn: undefined,
37
+ };
31
38
  return reconcileAttention({
32
39
  block: readBlock(blockId), session,
33
40
  resolution: readResolution(blockId),
@@ -49,7 +56,10 @@ export async function projectSessionEnvelope(event, state) {
49
56
  ];
50
57
  }
51
58
  if (event.type === 'remove')
52
- return [state.emit({ type: 'attention.remove', scope: event.scope, rowKey: event.rowKey })];
59
+ return [
60
+ state.emit({ type: 'agent.remove', scope: event.scope, rowKey: event.rowKey }),
61
+ state.emit({ type: 'attention.remove', scope: event.scope, rowKey: event.rowKey }),
62
+ ];
53
63
  if (event.type === 'scope')
54
64
  return [state.emit({ type: 'scope', capturedAt: event.capturedAt, scope: event.scope, status: event.status, ...(event.reason ? { reason: event.reason } : {}) })];
55
65
  return [state.emit({ type: 'heartbeat', capturedAt: event.capturedAt, scope: event.scope })];
@@ -136,11 +136,56 @@ export interface SinkOutcome {
136
136
  export declare function shortHost(host: string | undefined): string | undefined;
137
137
  /** First 8 hex chars of a session id for the footer (readable, not a full uuid). */
138
138
  export declare function shortSessionChunk(session: string | undefined): string | undefined;
139
+ /**
140
+ * Tap-to-view link for the session behind a post: the addressable console page
141
+ * ({@link https://prix.dev/console/sessions/<id>}, prix/web). The footer already
142
+ * carries a short session crumb for disambiguation; this rides the link trail so
143
+ * the owner can open the full transcript straight from an iMessage broadcast
144
+ * instead of hunting for it in the console.
145
+ *
146
+ * Accepts any real, path-safe session id — a Claude/Codex UUID *and* a native
147
+ * `ses_…` id from OpenCode or another harness. The console shard uploader
148
+ * (`traces/sync.ts`) syncs sessions with no harness filter, so all of them are
149
+ * addressable; a UUID-only gate would silently drop the link for every non-Claude
150
+ * harness (the whole point of the link). Reject only an id that could not resolve:
151
+ * one with a path separator (URL-unsafe, via {@link isValidMailboxId}) or the bare
152
+ * 8-char footer crumb (a truncated id that would 404).
153
+ */
154
+ export declare function sessionConsoleUrl(session: string | undefined): string | undefined;
139
155
  /**
140
156
  * Scrub em/en dashes from outbound phone copy (house rule + iMessage readability).
141
157
  * Collapses whitespace; does not invent meaning.
142
158
  */
143
159
  export declare function scrubOutboundDashes(text: string): string;
160
+ /**
161
+ * The rendering vocabulary a sink can display, which decides how the shared
162
+ * `{message}` surfaces its links (PHNX-3698):
163
+ *
164
+ * - `mrkdwn` — Slack, which renders `<url|label>` as blue tappable text. The
165
+ * session crumb and every ticket key the prose NAMES become inline labeled
166
+ * links, so nothing rides a trailing naked-URL line.
167
+ * - `plain` — iMessage, the owner-scoped rush message, a spawned `command:`
168
+ * sink, desktop banners: none can render a labeled link and a dumped naked
169
+ * URL reads as noise, so the message stays the human sentence with no URLs.
170
+ *
171
+ * The default is `plain`; only a Slack `channel:` sink opts into `mrkdwn`.
172
+ */
173
+ export type SinkMessageFormat = 'plain' | 'mrkdwn';
174
+ /**
175
+ * Only Slack renders `<url|label>`, so it is the one format that gets labeled
176
+ * links. iMessage / owner-scoped rush / command / desktop sinks stay `plain`
177
+ * (they can't turn `claude/6fc1db18` blue, and dumping the raw URL is worse than
178
+ * leaving the crumb unlinked — PHNX-3698).
179
+ *
180
+ * The argument is the **resolved provider name**, not the sink's declared
181
+ * channel: an operator can point an arbitrary channel name at the Slack provider
182
+ * through `notify.transports` (e.g. `eng-alerts -> slack`), and delivery keys off
183
+ * that resolved provider (`lookupTransport`), so the format decision must too —
184
+ * otherwise an aliased Slack sink would compose plain while delivering to Slack,
185
+ * or a name remapped AWAY from Slack would emit `<url|label>` markup a non-Slack
186
+ * transport shows literally. {@link resolveSinkProvider} does the mapping.
187
+ */
188
+ export declare function sinkMessageFormat(provider: string | undefined): SinkMessageFormat;
144
189
  /**
145
190
  * Footer like "Sent from my iPhone" — who posted, a session crumb, which box.
146
191
  *
@@ -148,10 +193,15 @@ export declare function scrubOutboundDashes(text: string): string;
148
193
  *
149
194
  * Agent name first; session chunk for disambiguation when many groks run;
150
195
  * host last. Skip the uninformative default label `agent`.
196
+ *
197
+ * In `mrkdwn` the crumb (`agent/short`) becomes a Slack labeled link to the
198
+ * session's console page, so the human sentence reads identically while the
199
+ * crumb turns blue and taps through (PHNX-3698). `plain` keeps the bare sentence
200
+ * — it can't render a labeled link and must not dump the URL.
151
201
  */
152
- export declare function composeBroadcastFooter(ctx: FeedBroadcastContext): string | undefined;
202
+ export declare function composeBroadcastFooter(ctx: FeedBroadcastContext, format?: SinkMessageFormat): string | undefined;
153
203
  export declare function truncateBroadcastBody(body: string): string;
154
- export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): string;
204
+ export declare function composeBroadcastMessage(ctx: FeedBroadcastContext, format?: SinkMessageFormat): string;
155
205
  /**
156
206
  * Substitute `{placeholder}` tokens in an argv template. Returns undefined when
157
207
  * the template needs a value this post does not have — the sink is then skipped
@@ -159,8 +209,13 @@ export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): stri
159
209
  * would otherwise comment on nothing.
160
210
  */
161
211
  export declare function renderSinkArgv(template: string[], ctx: FeedBroadcastContext): string[] | undefined;
162
- /** Render one channel-message template with the same fail-closed placeholder contract as argv. */
163
- export declare function renderSinkMessage(template: string, ctx: FeedBroadcastContext): string | undefined;
212
+ /**
213
+ * Render one channel-message template with the same fail-closed placeholder
214
+ * contract as argv. `format` (Slack `mrkdwn` vs `plain`) flows into the shared
215
+ * `{message}` var so a Slack sink gets labeled links and an iMessage/owner sink
216
+ * gets the plain sentence.
217
+ */
218
+ export declare function renderSinkMessage(template: string, ctx: FeedBroadcastContext, format?: SinkMessageFormat): string | undefined;
164
219
  /**
165
220
  * Which sinks this post reaches, in config order. Pure — the dry-run listing and
166
221
  * the real fan-out plan through here, so what `--dry-run` shows is what runs.
@@ -168,8 +223,12 @@ export declare function renderSinkMessage(template: string, ctx: FeedBroadcastCo
168
223
  * A `channel:` sink is gated by the same `minLevel` rule as a `command:` sink —
169
224
  * one level check for both shapes, so a dry-run plan is truthful regardless of
170
225
  * which shape an operator's sink uses.
226
+ *
227
+ * `meta` is used only to resolve a channel name to its real provider for the
228
+ * mrkdwn/plain format decision (`notify.transports`), the same map delivery uses;
229
+ * it is optional so a test can plan without a config snapshot (identity mapping).
171
230
  */
172
- export declare function planFeedBroadcast(config: FeedBroadcastConfig | undefined, ctx: FeedBroadcastContext): PlannedSink[];
231
+ export declare function planFeedBroadcast(config: FeedBroadcastConfig | undefined, ctx: FeedBroadcastContext, meta?: Meta): PlannedSink[];
173
232
  /**
174
233
  * The effective sink config for a post: the operator's `feed.broadcast`, or —
175
234
  * when that is unset or empty — an implicit fallback straight to