@phnx-labs/agents-cli 1.22.66 → 1.22.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +7 -2
  3. package/dist/bootstrap.js +3 -3
  4. package/dist/commands/browser.js +46 -21
  5. package/dist/commands/daemon-test-harness.d.ts +1 -1
  6. package/dist/commands/daemon-test-harness.js +2 -2
  7. package/dist/commands/daemon.js +21 -22
  8. package/dist/commands/exec.js +40 -0
  9. package/dist/commands/monitors.js +2 -2
  10. package/dist/commands/repo.js +57 -19
  11. package/dist/commands/resume.d.ts +16 -0
  12. package/dist/commands/resume.js +41 -8
  13. package/dist/commands/routines.js +42 -21
  14. package/dist/commands/sessions-optimize.js +1 -1
  15. package/dist/commands/sessions.js +4 -11
  16. package/dist/commands/share.d.ts +5 -3
  17. package/dist/commands/share.js +33 -9
  18. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  19. package/dist/lib/accounting/account-pool-collect.js +6 -4
  20. package/dist/lib/accounting/usage-ingest.js +4 -2
  21. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  22. package/dist/lib/accounting/usage-sync.js +66 -210
  23. package/dist/lib/accounting/usage.d.ts +11 -3
  24. package/dist/lib/accounting/usage.js +23 -10
  25. package/dist/lib/auth-health.d.ts +8 -0
  26. package/dist/lib/auth-health.js +4 -4
  27. package/dist/lib/browser/caller-identity.d.ts +12 -0
  28. package/dist/lib/browser/caller-identity.js +19 -0
  29. package/dist/lib/browser/ipc.d.ts +37 -32
  30. package/dist/lib/browser/ipc.js +146 -94
  31. package/dist/lib/browser/task-index.d.ts +10 -2
  32. package/dist/lib/browser/task-index.js +22 -3
  33. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  34. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  35. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  36. package/dist/lib/daemon/auth-sync-service.js +17 -6
  37. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  38. package/dist/lib/daemon/catchup-service.js +51 -0
  39. package/dist/lib/daemon/daemon.d.ts +12 -22
  40. package/dist/lib/daemon/daemon.js +463 -176
  41. package/dist/lib/daemon/runner.js +2 -0
  42. package/dist/lib/daemon/service.d.ts +22 -4
  43. package/dist/lib/daemon/service.js +2 -2
  44. package/dist/lib/daemon/supervisor.d.ts +55 -15
  45. package/dist/lib/daemon/supervisor.js +119 -29
  46. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  47. package/dist/lib/daemon/usage-sync-service.js +22 -18
  48. package/dist/lib/daemon-health.js +36 -31
  49. package/dist/lib/daemon-services.d.ts +1 -1
  50. package/dist/lib/daemon-services.js +12 -2
  51. package/dist/lib/daemon-ticks.d.ts +9 -6
  52. package/dist/lib/daemon-ticks.js +14 -8
  53. package/dist/lib/devices/registry.js +2 -0
  54. package/dist/lib/exec.js +11 -0
  55. package/dist/lib/feed/watch.d.ts +3 -0
  56. package/dist/lib/feed/watch.js +13 -3
  57. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  58. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  59. package/dist/lib/fleet-shared-state.d.ts +38 -0
  60. package/dist/lib/fleet-shared-state.js +105 -0
  61. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  62. package/dist/lib/hosts/remote-cmd.js +12 -3
  63. package/dist/lib/lock-compromise.d.ts +8 -0
  64. package/dist/lib/lock-compromise.js +12 -0
  65. package/dist/lib/monitors/engine.d.ts +2 -1
  66. package/dist/lib/monitors/engine.js +9 -2
  67. package/dist/lib/refresh-coordinator.js +2 -0
  68. package/dist/lib/run-trace-sync.d.ts +13 -0
  69. package/dist/lib/run-trace-sync.js +77 -0
  70. package/dist/lib/secrets/push.d.ts +10 -0
  71. package/dist/lib/secrets/push.js +86 -7
  72. package/dist/lib/secrets/remote.d.ts +18 -6
  73. package/dist/lib/secrets/remote.js +29 -4
  74. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  75. package/dist/lib/secrets/reserved-sync.js +119 -101
  76. package/dist/lib/session/active.d.ts +3 -1
  77. package/dist/lib/session/active.js +2 -0
  78. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  79. package/dist/lib/session/actor-sidecar.js +2 -0
  80. package/dist/lib/session/db.d.ts +5 -2
  81. package/dist/lib/session/db.js +54 -22
  82. package/dist/lib/session/discover.d.ts +12 -3
  83. package/dist/lib/session/discover.js +106 -25
  84. package/dist/lib/session/prompt.d.ts +11 -0
  85. package/dist/lib/session/prompt.js +25 -0
  86. package/dist/lib/session/recovery.d.ts +30 -2
  87. package/dist/lib/session/recovery.js +56 -4
  88. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  89. package/dist/lib/session/remote/remote-list.js +29 -4
  90. package/dist/lib/session/remote/watch.d.ts +25 -2
  91. package/dist/lib/session/remote/watch.js +185 -11
  92. package/dist/lib/session/session-cache.d.ts +2 -1
  93. package/dist/lib/session/session-cache.js +1 -0
  94. package/dist/lib/session/types.d.ts +2 -0
  95. package/dist/lib/share/publish.d.ts +55 -9
  96. package/dist/lib/share/publish.js +87 -9
  97. package/dist/lib/share/worker-template.js +100 -8
  98. package/dist/lib/ssh-exec.d.ts +2 -0
  99. package/dist/lib/ssh-exec.js +20 -4
  100. package/dist/lib/teams/registry.js +2 -0
  101. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  102. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  103. package/dist/lib/traces/sync.d.ts +7 -0
  104. package/dist/lib/traces/sync.js +9 -0
  105. package/dist/lib/usage-refresh.d.ts +8 -2
  106. package/dist/lib/usage-refresh.js +3 -3
  107. package/package.json +1 -1
  108. package/dist/lib/account-state-service.d.ts +0 -21
  109. package/dist/lib/account-state-service.js +0 -60
@@ -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;
@@ -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;
@@ -122,11 +122,13 @@ export class MonitorEngine {
122
122
  monitors = [];
123
123
  lastEval = new Map();
124
124
  ticking = false;
125
+ running = false;
125
126
  constructor(logFn = () => { }) {
126
127
  this.logFn = logFn;
127
128
  }
128
129
  /** Load owned+enabled monitors and start the tick loop. */
129
130
  start(options = {}) {
131
+ this.running = true;
130
132
  this.loadAll();
131
133
  this.logFn('INFO', `Monitor engine started (${this.monitors.length} monitor(s) on this device)`);
132
134
  if (!options.externalScheduler) {
@@ -138,8 +140,9 @@ export class MonitorEngine {
138
140
  this.loadAll();
139
141
  this.logFn('INFO', `Monitor engine reloaded (${this.monitors.length} monitor(s) on this device)`);
140
142
  }
141
- /** Stop the tick loop. */
143
+ /** Stop the tick loop. No monitor dispatches after this until `start()`. */
142
144
  stop() {
145
+ this.running = false;
143
146
  if (this.timer) {
144
147
  clearInterval(this.timer);
145
148
  this.timer = null;
@@ -159,7 +162,11 @@ export class MonitorEngine {
159
162
  }
160
163
  /** Evaluate every due monitor once. Overlap-guarded so a slow cycle never stacks. */
161
164
  async tick() {
162
- if (this.ticking)
165
+ // A stopped engine dispatches nothing (PHNX-3608): under the external
166
+ // scheduler the supervisor owns the timer, so a `stop()` (monitors service
167
+ // disabled) must be honoured HERE — otherwise the next supervised tick would
168
+ // still fire the last-loaded monitors even though the engine is stopped.
169
+ if (!this.running || this.ticking)
163
170
  return;
164
171
  this.ticking = true;
165
172
  try {
@@ -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,13 @@
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
+ */
11
+ export declare function armRunFinishTraceSync(opts?: {
12
+ disabled?: boolean;
13
+ }): void;