@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
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Non-secret daemon state exchanged through the already fleet-synced user repo.
3
+ *
4
+ * Each device owns exactly one file under `~/.agents/devices/<device>/`, so
5
+ * normal `agents repo push/pull user` merges the fleet without devices dialing
6
+ * one another on every daemon tick. OAuth/setup-token values never belong here:
7
+ * auth publishes only a readiness verdict; the existing encrypted SSH transport
8
+ * remains the one path that may carry secret material.
9
+ */
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ import { assertValidDeviceName } from './devices/registry.js';
13
+ import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
14
+ import { getUserAgentsDir } from './state.js';
15
+ export const FLEET_SHARED_STATE_VERSION = 1;
16
+ export const FLEET_SHARED_STATE_FILE = 'daemon-state.json';
17
+ /** Path owned by one device in the conflict-free tracked device-doc tree. */
18
+ export function fleetSharedStatePath(device, userAgentsDir = getUserAgentsDir()) {
19
+ assertValidDeviceName(device);
20
+ return path.join(userAgentsDir, 'devices', device, FLEET_SHARED_STATE_FILE);
21
+ }
22
+ function isRecord(value) {
23
+ return !!value && typeof value === 'object' && !Array.isArray(value);
24
+ }
25
+ function parseFleetSharedDeviceState(raw, owner) {
26
+ const parsed = JSON.parse(raw);
27
+ if (!isRecord(parsed) || parsed.version !== FLEET_SHARED_STATE_VERSION || parsed.device !== owner) {
28
+ throw new Error('unrecognized shared-state envelope');
29
+ }
30
+ const usage = parsed.usage;
31
+ if (usage !== undefined && (!isRecord(usage) || !isRecord(usage.rows))) {
32
+ throw new Error('unrecognized usage snapshot');
33
+ }
34
+ const auth = parsed.auth;
35
+ if (auth !== undefined &&
36
+ (!isRecord(auth) || !['ready', 'missing', 'invalid'].includes(String(auth.status)))) {
37
+ throw new Error('unrecognized auth verdict');
38
+ }
39
+ return parsed;
40
+ }
41
+ /**
42
+ * Merge one daemon-owned field into this device's shared file under a real
43
+ * inter-process lock. Stable serialization avoids dirtying the user repo when
44
+ * neither usage nor auth state changed.
45
+ */
46
+ export function updateFleetSharedDeviceState(device, patch, userAgentsDir = getUserAgentsDir()) {
47
+ const file = fleetSharedStatePath(device, userAgentsDir);
48
+ ensureLockTarget(file, '');
49
+ return withFileLock(file, () => {
50
+ let current = {
51
+ version: FLEET_SHARED_STATE_VERSION,
52
+ device,
53
+ };
54
+ try {
55
+ const raw = fs.readFileSync(file, 'utf-8').trim();
56
+ if (raw)
57
+ current = parseFleetSharedDeviceState(raw, device);
58
+ }
59
+ catch {
60
+ // The owning device repairs its own malformed file from authoritative
61
+ // local state. Peer files are never repaired by this writer.
62
+ }
63
+ const next = {
64
+ ...current,
65
+ ...(patch.usage !== undefined ? { usage: patch.usage } : {}),
66
+ ...(patch.auth !== undefined ? { auth: patch.auth } : {}),
67
+ version: FLEET_SHARED_STATE_VERSION,
68
+ device,
69
+ };
70
+ const serialized = `${JSON.stringify(next, null, 2)}\n`;
71
+ if (fs.readFileSync(file, 'utf-8') === serialized)
72
+ return { changed: false, path: file };
73
+ atomicWriteFileSync(file, serialized, 'utf-8');
74
+ return { changed: true, path: file };
75
+ });
76
+ }
77
+ /** Read every valid peer-owned state file; malformed peers fail separately. */
78
+ export function readFleetSharedDeviceStates(userAgentsDir = getUserAgentsDir()) {
79
+ const devicesDir = path.join(userAgentsDir, 'devices');
80
+ let entries;
81
+ try {
82
+ entries = fs.readdirSync(devicesDir, { withFileTypes: true });
83
+ }
84
+ catch (err) {
85
+ if (err.code === 'ENOENT')
86
+ return { states: [], errors: [] };
87
+ return { states: [], errors: [{ device: '*', message: err.message }] };
88
+ }
89
+ const states = [];
90
+ const errors = [];
91
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
92
+ if (!entry.isDirectory())
93
+ continue;
94
+ const file = path.join(devicesDir, entry.name, FLEET_SHARED_STATE_FILE);
95
+ if (!fs.existsSync(file))
96
+ continue;
97
+ try {
98
+ states.push(parseFleetSharedDeviceState(fs.readFileSync(file, 'utf-8'), entry.name));
99
+ }
100
+ catch (err) {
101
+ errors.push({ device: entry.name, message: err.message });
102
+ }
103
+ }
104
+ return { states, errors };
105
+ }
@@ -118,6 +118,8 @@ export interface WindowsAgentsCommand {
118
118
  * pass false for probes whose reachability keys off a sentinel, not the code.
119
119
  */
120
120
  propagateExit?: boolean;
121
+ /** Remap a reached peer command's 255 to 254 so SSH's own 255 stays unambiguous. */
122
+ remapExit255?: boolean;
121
123
  }
122
124
  /**
123
125
  * The PowerShell script (pre-encoding) that {@link buildWindowsAgentsCommand}
@@ -131,6 +131,11 @@ export const RUN_OPTION_FORWARDING = {
131
131
  // process follows the remote run to completion, so its exit handler fires at
132
132
  // the right moment anyway.
133
133
  notify: 'local-only',
134
+ // --no-trace-sync (traceSync=false) gates the LOCAL run-exit trace auto-sync,
135
+ // which only arms for local runs anyway (exec.ts skips it for --device/--lease).
136
+ // On a --device dispatch the remote box runs its own run-exit sync, so this
137
+ // flag is never forwarded — it is a local-exit-behavior toggle, not remote.
138
+ traceSync: 'local-only',
134
139
  // Deprecated alias for --device auto; resolved on the launching box before SSH.
135
140
  smart: 'local-only',
136
141
  // Broadcast mode (agents run --broadcast) is its own fan-out dispatch — mutually
@@ -289,7 +294,7 @@ export function stripClixml(stdout) {
289
294
  .trim();
290
295
  }
291
296
  export function windowsAgentsScript(cmd) {
292
- const { args, env, cwd, propagateExit = true } = cmd;
297
+ const { args, env, cwd, propagateExit = true, remapExit255 = false } = cmd;
293
298
  const parts = [POWERSHELL_PROGRESS_SILENCE];
294
299
  if (env)
295
300
  for (const [k, v] of Object.entries(env))
@@ -297,8 +302,12 @@ export function windowsAgentsScript(cmd) {
297
302
  if (cwd)
298
303
  parts.push(`Set-Location -LiteralPath ${powershellQuote(cwd)}`);
299
304
  parts.push(`& ${['agents', ...args].map(powershellQuote).join(' ')}`);
300
- if (propagateExit)
301
- parts.push('exit $LASTEXITCODE');
305
+ if (propagateExit) {
306
+ if (remapExit255)
307
+ parts.push('$agentsExit = $LASTEXITCODE', 'if ($agentsExit -eq 255) { exit 254 }', 'exit $agentsExit');
308
+ else
309
+ parts.push('exit $LASTEXITCODE');
310
+ }
302
311
  return parts.join('; ');
303
312
  }
304
313
  /**
@@ -0,0 +1,8 @@
1
+ /**
2
+ * proper-lockfile invokes this callback from its background refresh timer.
3
+ * Its default is to throw there, outside the caller's promise chain, which can
4
+ * terminate the daemon. These leases are advisory serialization barriers: log
5
+ * a broken lease and let the owning operation finish instead of crashing the
6
+ * shared process.
7
+ */
8
+ export declare function logAndContinueOnLockCompromised(scope: string): (err: Error) => void;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * proper-lockfile invokes this callback from its background refresh timer.
3
+ * Its default is to throw there, outside the caller's promise chain, which can
4
+ * terminate the daemon. These leases are advisory serialization barriers: log
5
+ * a broken lease and let the owning operation finish instead of crashing the
6
+ * shared process.
7
+ */
8
+ export function logAndContinueOnLockCompromised(scope) {
9
+ return (err) => {
10
+ console.warn(`[agents ${scope}] Lock was compromised; continuing without crashing: ${err.message}`);
11
+ };
12
+ }
@@ -58,6 +58,7 @@ export declare class MonitorEngine {
58
58
  private monitors;
59
59
  private lastEval;
60
60
  private ticking;
61
+ private running;
61
62
  constructor(logFn?: LogFn);
62
63
  /** Load owned+enabled monitors and start the tick loop. */
63
64
  start(options?: {
@@ -65,7 +66,7 @@ export declare class MonitorEngine {
65
66
  }): void;
66
67
  /** Reload monitor configs (SIGHUP). */
67
68
  reload(): void;
68
- /** Stop the tick loop. */
69
+ /** Stop the tick loop. No monitor dispatches after this until `start()`. */
69
70
  stop(): void;
70
71
  private loadAll;
71
72
  private intervalMs;
@@ -54,6 +54,14 @@ export function decideFire(monitor, observation) {
54
54
  const raw = observation.raw;
55
55
  const payload = observation.meta ?? {};
56
56
  const dedupeKey = cond.dedupeKey;
57
+ // A snapshot the source flagged as an OBSERVATION FAILURE (a poll that exited
58
+ // non-zero or emitted a transport/auth/rate-limit error) is never a value
59
+ // change: don't fire, don't move the baseline — so an empty→error→empty flap
60
+ // can't read as two value changes (PHNX-3510). The engine records it as a
61
+ // failed check separately, feeding the drought health streak.
62
+ if (observation.failed) {
63
+ return { fire: false, value: raw, dedupeKey, persist: false, event: null };
64
+ }
57
65
  if (cond.mode === 'every') {
58
66
  // Fire on every tick that carries a real observation. An empty (or
59
67
  // whitespace-only) observation means "nothing to report": firing an action
@@ -122,11 +130,13 @@ export class MonitorEngine {
122
130
  monitors = [];
123
131
  lastEval = new Map();
124
132
  ticking = false;
133
+ running = false;
125
134
  constructor(logFn = () => { }) {
126
135
  this.logFn = logFn;
127
136
  }
128
137
  /** Load owned+enabled monitors and start the tick loop. */
129
138
  start(options = {}) {
139
+ this.running = true;
130
140
  this.loadAll();
131
141
  this.logFn('INFO', `Monitor engine started (${this.monitors.length} monitor(s) on this device)`);
132
142
  if (!options.externalScheduler) {
@@ -138,8 +148,9 @@ export class MonitorEngine {
138
148
  this.loadAll();
139
149
  this.logFn('INFO', `Monitor engine reloaded (${this.monitors.length} monitor(s) on this device)`);
140
150
  }
141
- /** Stop the tick loop. */
151
+ /** Stop the tick loop. No monitor dispatches after this until `start()`. */
142
152
  stop() {
153
+ this.running = false;
143
154
  if (this.timer) {
144
155
  clearInterval(this.timer);
145
156
  this.timer = null;
@@ -159,7 +170,11 @@ export class MonitorEngine {
159
170
  }
160
171
  /** Evaluate every due monitor once. Overlap-guarded so a slow cycle never stacks. */
161
172
  async tick() {
162
- if (this.ticking)
173
+ // A stopped engine dispatches nothing (PHNX-3608): under the external
174
+ // scheduler the supervisor owns the timer, so a `stop()` (monitors service
175
+ // disabled) must be honoured HERE — otherwise the next supervised tick would
176
+ // still fire the last-loaded monitors even though the engine is stopped.
177
+ if (!this.running || this.ticking)
163
178
  return;
164
179
  this.ticking = true;
165
180
  try {
@@ -196,6 +211,16 @@ export class MonitorEngine {
196
211
  if (!observation) {
197
212
  checkError = 'source produced no observation';
198
213
  }
214
+ else if (observation.failed) {
215
+ // The poll ran but did not OBSERVE (non-zero exit, or a transport/auth/
216
+ // rate-limit error in its output). Skip it entirely: no decideFire, no
217
+ // fire, watched-state untouched — so no empty→error→empty flap dispatches
218
+ // an agent on a dead premise. Record it as a failed check so a sustained
219
+ // streak escalates as a drought, the same health surface `--postcondition`
220
+ // uses on the action side (PHNX-3510).
221
+ checkError = `poll failed: ${observation.failureReason ?? 'observation failure'}`;
222
+ this.logFn('WARN', `monitor '${monitor.name}' poll failed (${observation.failureReason ?? 'observation failure'}) — not treated as a value change`);
223
+ }
199
224
  else {
200
225
  const decision = decideFire(monitor, observation);
201
226
  if (decision.fire && decision.event) {
@@ -6,6 +6,7 @@
6
6
  * here. No agent, no sandbox: a plain `/bin/sh -c` (or `cmd /c` on Windows).
7
7
  */
8
8
  import { execFile } from 'child_process';
9
+ import { classifyPollFailure } from './failure.js';
9
10
  const DEFAULT_TIMEOUT_MS = 60_000;
10
11
  /** Run the source command and return its combined stdout as the observation. */
11
12
  export function evaluate(source) {
@@ -22,10 +23,19 @@ export function evaluate(source) {
22
23
  : err
23
24
  ? 1
24
25
  : 0;
25
- // A non-zero exit is still a real observation (the diff might be exactly
26
- // "command started failing"); surface stderr when stdout is empty.
26
+ // Surface stderr when stdout is empty.
27
27
  const raw = (stdout && stdout.length > 0 ? stdout : stderr ?? '').replace(/\s+$/, '');
28
- resolve({ raw, meta: { exitCode } });
28
+ // A poll that failed to OBSERVE — non-zero exit, or a transport/auth/
29
+ // rate-limit error shape in its output (which a piped `gh … | jq`
30
+ // swallows the exit code of) — is not a new value. Flag it so the engine
31
+ // skips it instead of reading empty→error→empty as two value changes and
32
+ // dispatching an agent on a dead premise (PHNX-3510).
33
+ const failureReason = classifyPollFailure({ exitCode, text: raw });
34
+ resolve({
35
+ raw,
36
+ meta: { exitCode },
37
+ ...(failureReason ? { failed: true, failureReason } : {}),
38
+ });
29
39
  });
30
40
  });
31
41
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Poll-failure classifier (PHNX-3510).
3
+ *
4
+ * A poll that FAILS to observe — the command exited non-zero, or its output
5
+ * carries a transport/auth/rate-limit error shape — is an OBSERVATION FAILURE,
6
+ * not a new value. Reading it as a value is the defect this closes: a
7
+ * `gh pr list … | jq` monitor whose gh half intermittently prints
8
+ * `GraphQL: API rate limit already exceeded …` flapped empty→error→empty, and an
9
+ * `[on-change]` monitor read that as two value changes and dispatched a full
10
+ * agent run on a premise that was false.
11
+ *
12
+ * The exit code alone is not enough: when gh is piped into jq the shell's exit
13
+ * status is jq's (0 on empty input), so the rate-limit text can ride an exit-0
14
+ * observation. The text patterns catch exactly that case. They stay tightly
15
+ * scoped to unambiguous failure shapes so a legitimate observation whose content
16
+ * merely mentions "timeout" is never misread as a failure.
17
+ */
18
+ /** The failure reason matched in a poll's output text, or null when it looks clean. */
19
+ export declare function matchFailureText(text: string): string | null;
20
+ /**
21
+ * Classify one poll snapshot. Returns a short failure reason when the snapshot is
22
+ * an observation failure (non-zero exit, or a failure-shaped output), else null —
23
+ * in which case the snapshot is a genuine value the condition may diff.
24
+ *
25
+ * A failure-shaped OUTPUT is checked even on exit 0, because a piped command
26
+ * (`gh … | jq`) swallows the failing half's exit code. A non-zero exit is a
27
+ * failure regardless of output shape.
28
+ */
29
+ export declare function classifyPollFailure(input: {
30
+ exitCode?: number;
31
+ text: string;
32
+ }): string | null;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Poll-failure classifier (PHNX-3510).
3
+ *
4
+ * A poll that FAILS to observe — the command exited non-zero, or its output
5
+ * carries a transport/auth/rate-limit error shape — is an OBSERVATION FAILURE,
6
+ * not a new value. Reading it as a value is the defect this closes: a
7
+ * `gh pr list … | jq` monitor whose gh half intermittently prints
8
+ * `GraphQL: API rate limit already exceeded …` flapped empty→error→empty, and an
9
+ * `[on-change]` monitor read that as two value changes and dispatched a full
10
+ * agent run on a premise that was false.
11
+ *
12
+ * The exit code alone is not enough: when gh is piped into jq the shell's exit
13
+ * status is jq's (0 on empty input), so the rate-limit text can ride an exit-0
14
+ * observation. The text patterns catch exactly that case. They stay tightly
15
+ * scoped to unambiguous failure shapes so a legitimate observation whose content
16
+ * merely mentions "timeout" is never misread as a failure.
17
+ */
18
+ const FAILURE_TEXT_PATTERNS = [
19
+ { re: /\bAPI rate limit (?:already )?exceeded\b/i, reason: 'API rate limit exceeded' },
20
+ { re: /\bsecondary rate limit\b/i, reason: 'secondary rate limit' },
21
+ { re: /^\s*GraphQL:\s/im, reason: 'GraphQL error' },
22
+ { re: /\bbad credentials\b/i, reason: 'bad credentials' },
23
+ { re: /\b(?:401 Unauthorized|403 Forbidden)\b/i, reason: 'auth error' },
24
+ { re: /\bcould not resolve host\b/i, reason: 'transport error (DNS)' },
25
+ { re: /\bconnection (?:refused|reset|timed out)\b/i, reason: 'connection error' },
26
+ { re: /\bnetwork is unreachable\b/i, reason: 'network unreachable' },
27
+ ];
28
+ /** The failure reason matched in a poll's output text, or null when it looks clean. */
29
+ export function matchFailureText(text) {
30
+ for (const { re, reason } of FAILURE_TEXT_PATTERNS) {
31
+ if (re.test(text))
32
+ return reason;
33
+ }
34
+ return null;
35
+ }
36
+ /**
37
+ * Classify one poll snapshot. Returns a short failure reason when the snapshot is
38
+ * an observation failure (non-zero exit, or a failure-shaped output), else null —
39
+ * in which case the snapshot is a genuine value the condition may diff.
40
+ *
41
+ * A failure-shaped OUTPUT is checked even on exit 0, because a piped command
42
+ * (`gh … | jq`) swallows the failing half's exit code. A non-zero exit is a
43
+ * failure regardless of output shape.
44
+ */
45
+ export function classifyPollFailure(input) {
46
+ const textReason = matchFailureText(input.text);
47
+ const badExit = typeof input.exitCode === 'number' && input.exitCode !== 0;
48
+ if (badExit) {
49
+ return textReason ? `${textReason} (exit ${input.exitCode})` : `command exited ${input.exitCode}`;
50
+ }
51
+ return textReason;
52
+ }
@@ -11,6 +11,15 @@ import type { MonitorSource } from '../config.js';
11
11
  export interface Observation {
12
12
  raw: string;
13
13
  meta?: Record<string, unknown>;
14
+ /**
15
+ * The source flagged this snapshot as an OBSERVATION FAILURE (a poll that
16
+ * exited non-zero or emitted a transport/auth/rate-limit error), not a value.
17
+ * The engine skips it: no fire, watched-state untouched, counted as a failed
18
+ * check for drought health (PHNX-3510).
19
+ */
20
+ failed?: boolean;
21
+ /** Short human reason for `failed`, surfaced in drought health and `test`. */
22
+ failureReason?: string;
14
23
  }
15
24
  /** Poll-model evaluator: return one observation, or null when none is available. */
16
25
  export type SourceEvaluator = (source: MonitorSource) => Promise<Observation | null>;
@@ -0,0 +1,12 @@
1
+ export interface OwnerMessageOptions {
2
+ /** Scannable subject line, when the caller has one (feed post does; notify does not). */
3
+ title?: string;
4
+ /** Explicit session id (`--session`); otherwise resolved from the run environment. */
5
+ sessionId?: string;
6
+ }
7
+ /**
8
+ * Shape a raw owner-send body into the composed broadcast message. Pure except
9
+ * for the identity/index reads that {@link resolvePostIdentity} /
10
+ * {@link getSessionById} already perform for feed posts.
11
+ */
12
+ export declare function composeOwnerMessage(rawText: string, opts?: OwnerMessageOptions): string;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Compose an owner-bound phone ping through the SAME shaper `agents feed post`
3
+ * uses, so `agents notify` / `agents send --to owner` stop shipping a raw body
4
+ * dump (PHNX-3698).
5
+ *
6
+ * Before this, an owner send delivered the body verbatim: a long wall of prose,
7
+ * a `TEAM-N` key iMessage renders as dead text, and no way back to the session.
8
+ * Routing the body through {@link composeBroadcastMessage} makes an owner ping
9
+ * identical to an important feed post of the same event — short-shaped body,
10
+ * every ticket key linkified to its Linear URL, and the current session's
11
+ * `…/console/sessions/<id>` page as a tappable crumb.
12
+ *
13
+ * The session/agent/host are resolved the same way a feed post resolves them
14
+ * ({@link resolvePostIdentity} — the pid-registry / env walk), so a `notify`
15
+ * run inside an agent session inherits that session with no flag. Outside a
16
+ * session (a human at a shell) identity is undefined: the ping still gets
17
+ * short-shaping and ticket linkification, just no session crumb.
18
+ */
19
+ import { composeBroadcastMessage } from './feed-broadcast.js';
20
+ import { resolvePostIdentity } from './feed-post.js';
21
+ import { getSessionById, resolveFullSessionId } from './session/db.js';
22
+ import { linearIssueUrl } from './session/linear.js';
23
+ /**
24
+ * Shape a raw owner-send body into the composed broadcast message. Pure except
25
+ * for the identity/index reads that {@link resolvePostIdentity} /
26
+ * {@link getSessionById} already perform for feed posts.
27
+ */
28
+ export function composeOwnerMessage(rawText, opts = {}) {
29
+ const identity = resolvePostIdentity({ sessionId: opts.sessionId });
30
+ // A footer crumb that would 404 (an 8-char short id) is upgraded to the full
31
+ // indexed id so the console URL resolves; a full/native id passes through.
32
+ const session = resolveFullSessionId(identity?.sessionId);
33
+ const ticket = session ? getSessionById(session)?.ticketId : undefined;
34
+ const ctx = {
35
+ ...(opts.title?.trim() ? { title: opts.title.trim() } : {}),
36
+ text: rawText,
37
+ level: 'important',
38
+ ...(ticket ? { ticket, ticketUrl: linearIssueUrl(ticket) } : {}),
39
+ ...(identity?.agent ? { agent: identity.agent } : {}),
40
+ ...(identity?.host ? { host: identity.host } : {}),
41
+ ...(session ? { session } : {}),
42
+ };
43
+ return composeBroadcastMessage(ctx);
44
+ }
@@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
4
  import lockfile from 'proper-lockfile';
5
5
  import { getCacheDir } from './state.js';
6
+ import { logAndContinueOnLockCompromised } from './lock-compromise.js';
6
7
  const REFRESH_LOCK_STALE_MS = 2 * 60_000;
7
8
  let refreshLockRootOverride = null;
8
9
  export function setRefreshLockRootForTest(root) {
@@ -39,6 +40,7 @@ export async function withRefreshLease(options) {
39
40
  stale: REFRESH_LOCK_STALE_MS,
40
41
  update: REFRESH_LOCK_STALE_MS / 4,
41
42
  retries: { retries: 240, minTimeout: 25, maxTimeout: 500, factor: 1.2 },
43
+ onCompromised: logAndContinueOnLockCompromised('refresh lease'),
42
44
  });
43
45
  try {
44
46
  const completed = options.readCompleted();
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The policy gate, pure and unit-testable. `disabled` is the resolved
3
+ * `--no-trace-sync` (commander maps it to `traceSync === false`).
4
+ */
5
+ export declare function shouldAutoSyncTraces(disabled: boolean): boolean;
6
+ /**
7
+ * Arm a fire-and-forget `agents traces sync` for when this process exits.
8
+ * No-op unless {@link shouldAutoSyncTraces} passes. Best-effort by
9
+ * construction: a missing binary or a stalled child never affects the run.
10
+ * The spawn happens in the exit handler because the upload is async work Node
11
+ * cannot run after `exit` — a watchdog here could never fire.
12
+ */
13
+ export declare function armRunFinishTraceSync(opts?: {
14
+ disabled?: boolean;
15
+ }): void;
16
+ /**
17
+ * Fire a fire-and-forget `agents traces sync` NOW (not on exit). An important
18
+ * owner-bound ping (`feed post --level important`, `agents notify`,
19
+ * `send --to owner`) links the caller's `…/console/sessions/<id>` page, and that
20
+ * page only exists once the session's shard has been uploaded — trace sync fires
21
+ * on run exit (PHNX-3628), not when a mid-run ping is posted. This closes that
22
+ * gap so the tapped link resolves instead of 404ing. No-op unless
23
+ * {@link shouldAutoSyncTraces} passes (signed in + already opted into the store);
24
+ * the incremental watermark keeps the push to essentially just this session.
25
+ */
26
+ export declare function fireTraceSyncInBackground(opts?: {
27
+ disabled?: boolean;
28
+ }): void;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Auto-sync this device's just-finished session trace when a local, headless
3
+ * `agents run` exits (PHNX-3628).
4
+ *
5
+ * Why exit-armed + spawn, exactly like `run-notify`: the sync reads this
6
+ * device's `sessions.db` and PUTs redacted shards over the network — async work
7
+ * that CANNOT run inside a `process.on('exit')` handler (Node runs no more
8
+ * microtasks after exit). So, mirroring `notifyDesktop`, on exit we SPAWN a
9
+ * detached `agents traces sync` and unref it; it finishes after the parent is
10
+ * gone, and the run itself returns immediately instead of waiting on the PUTs.
11
+ *
12
+ * The upload is incremental by construction (`syncTraces` de-dups by the
13
+ * `traces-sync.json` mtime watermark), so the spawned sync pushes essentially
14
+ * just the session that finished, not the whole history.
15
+ *
16
+ * Default policy: fire ONLY when the user has ALREADY opted into the traces
17
+ * store — i.e. they are signed in AND have run `agents traces sync` at least
18
+ * once (`hasSyncedBefore`). A user who never synced is never touched. Opt out
19
+ * of a given run with `--no-trace-sync` or `AGENTS_NO_TRACE_SYNC=1`.
20
+ *
21
+ * Local runs only: a run placed on another machine (`--device`/`--on`/
22
+ * `--computer`/`--where device:`, `--lease`, `--box`, or `--cloud`) has its
23
+ * trajectory on the REMOTE box, not this device's `sessions.db`, so arming here
24
+ * would upload nothing useful — the remote box runs its own exit sync. The
25
+ * caller gates every remote-placement signal out before arming (it reuses the
26
+ * file's own `hostTargetGiven` predicate plus `--lease`/`--box`; `--cloud`
27
+ * returns earlier).
28
+ */
29
+ import { spawn } from 'child_process';
30
+ import { getCliLaunch } from './cli-entry.js';
31
+ import { readSession } from './identity/client.js';
32
+ import { hasSyncedBefore } from './traces/sync.js';
33
+ /**
34
+ * The policy gate, pure and unit-testable. `disabled` is the resolved
35
+ * `--no-trace-sync` (commander maps it to `traceSync === false`).
36
+ */
37
+ export function shouldAutoSyncTraces(disabled) {
38
+ if (disabled)
39
+ return false;
40
+ if (process.env.AGENTS_NO_TRACE_SYNC === '1')
41
+ return false;
42
+ if (!readSession())
43
+ return false; // not signed in → nothing to authenticate an upload
44
+ if (!hasSyncedBefore())
45
+ return false; // never opted into the traces store
46
+ return true;
47
+ }
48
+ /**
49
+ * Spawn a detached, unref'd `agents traces sync` and return immediately. The one
50
+ * place the fire-and-forget child is built, shared by the run-exit arm and the
51
+ * feed/notify important-post trigger.
52
+ *
53
+ * Re-invoke this CLI through getCliLaunch, NOT a hand-rolled
54
+ * [process.execPath, process.argv[1], …] — under the compiled standalone binary
55
+ * argv[1] is the bun virtual entry (/$bunfs/root/agents), which would become a
56
+ * bogus subcommand and silently no-op the whole feature (cli-entry.ts). The
57
+ * spawned `traces sync` is a fresh process with its own event loop, so its own
58
+ * per-request network timeouts (syncTraces) bound it.
59
+ */
60
+ function spawnDetachedTraceSync() {
61
+ try {
62
+ const { command, args } = getCliLaunch(['traces', 'sync']);
63
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
64
+ // A child that never starts (ENOENT) emits an async 'error'; without a
65
+ // listener Node re-throws it as uncaught. Swallow — this is best-effort.
66
+ child.on('error', () => { });
67
+ child.unref();
68
+ }
69
+ catch {
70
+ // A synchronous spawn failure must never change the caller's outcome.
71
+ }
72
+ }
73
+ /**
74
+ * Arm a fire-and-forget `agents traces sync` for when this process exits.
75
+ * No-op unless {@link shouldAutoSyncTraces} passes. Best-effort by
76
+ * construction: a missing binary or a stalled child never affects the run.
77
+ * The spawn happens in the exit handler because the upload is async work Node
78
+ * cannot run after `exit` — a watchdog here could never fire.
79
+ */
80
+ export function armRunFinishTraceSync(opts = {}) {
81
+ if (!shouldAutoSyncTraces(!!opts.disabled))
82
+ return;
83
+ process.on('exit', spawnDetachedTraceSync);
84
+ }
85
+ /**
86
+ * Fire a fire-and-forget `agents traces sync` NOW (not on exit). An important
87
+ * owner-bound ping (`feed post --level important`, `agents notify`,
88
+ * `send --to owner`) links the caller's `…/console/sessions/<id>` page, and that
89
+ * page only exists once the session's shard has been uploaded — trace sync fires
90
+ * on run exit (PHNX-3628), not when a mid-run ping is posted. This closes that
91
+ * gap so the tapped link resolves instead of 404ing. No-op unless
92
+ * {@link shouldAutoSyncTraces} passes (signed in + already opted into the store);
93
+ * the incremental watermark keeps the push to essentially just this session.
94
+ */
95
+ export function fireTraceSyncInBackground(opts = {}) {
96
+ if (!shouldAutoSyncTraces(!!opts.disabled))
97
+ return;
98
+ spawnDetachedTraceSync();
99
+ }
@@ -207,6 +207,10 @@ export declare function _resetFileStoreForTest(opts?: {
207
207
  passphraseDir?: string | null;
208
208
  passphrase?: string | null;
209
209
  }): void;
210
+ /** Test-only: flush the in-memory metadata cache to disk now. Production flushes
211
+ * once at process exit; a test needs the on-disk copy mid-run to simulate the
212
+ * next `agents` process reading it. */
213
+ export declare function _flushMetaCacheForTest(): void;
210
214
  /** Test-only: shorten the store-lock acquire timeout so a contended-lock assertion
211
215
  * fails fast instead of waiting out the 30s production budget. */
212
216
  export declare function _setFileStoreLockTimeoutForTest(ms: number | null): void;