@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
@@ -79,11 +79,100 @@ export function resolveClaudeSetupToken(home) {
79
79
  // Require a known account (email) up front: without it we cannot key a
80
80
  // per-account token, and we must NOT fall back to a bare shared key that
81
81
  // would misapply one account's setup-token to another.
82
- const email = readClaudeAccountEmail(home);
82
+ const email = readClaudeAccountEmail(home)
83
+ // Self-heal (PHNX-3660): a home provisioned before seed-on-attach carries an
84
+ // `.oauth_token` but no identity. Recover the email from the bundle and
85
+ // write it back, so the home converges instead of needing a re-attach.
86
+ // Explicit-home only: with no home the probe targets the operator's real
87
+ // ~/.claude.json, which a library read must never rewrite.
88
+ ?? (home ? discoverClaudeAccountEmailFromOauthToken(home) : null);
83
89
  if (!email)
84
90
  return null;
85
91
  return resolveClaudeSetupTokenForEmail(email, home ?? os.homedir());
86
92
  }
93
+ /** Negative/positive discovery cache, keyed by home + .oauth_token fingerprint (SHOULD-2). */
94
+ const discoveryCache = new Map();
95
+ /**
96
+ * Recover a home's account email from its `.claude/.oauth_token` by matching
97
+ * the token VALUE against the `auth` bundle (the slug encodes the email, and
98
+ * the re-encode check makes the decode lossless or fail). On a match the
99
+ * identity is written back via {@link seedClaudeWorkerHomeIdentity}, so this
100
+ * runs at most once per home. Returns null — and writes nothing — when the
101
+ * file is missing, malformed, or matches no bundle key: a token that no longer
102
+ * exists in the bundle must not resurrect an account mapping. A no-match
103
+ * result is cached against the token file's fingerprint so a rotated-out home
104
+ * does not re-decrypt the bundle on every probe.
105
+ */
106
+ function discoverClaudeAccountEmailFromOauthToken(home) {
107
+ try {
108
+ const tokenPath = path.join(home, '.claude', '.oauth_token');
109
+ const fingerprint = credentialFingerprint(tokenPath);
110
+ if (fingerprint === 'missing')
111
+ return null;
112
+ const cached = discoveryCache.get(home);
113
+ if (cached && cached.fingerprint === fingerprint)
114
+ return cached.email;
115
+ const email = discoverEmailUncached(home, tokenPath);
116
+ discoveryCache.set(home, { fingerprint, email });
117
+ return email;
118
+ }
119
+ catch (err) {
120
+ if (err instanceof ReservedBundleWrongBackendError)
121
+ throw err;
122
+ return null;
123
+ }
124
+ }
125
+ function discoverEmailUncached(home, tokenPath) {
126
+ let token;
127
+ try {
128
+ token = fs.readFileSync(tokenPath, 'utf-8').trim();
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ if (!isValidClaudeSetupToken(token))
134
+ return null;
135
+ if (!bundleExists(AUTH_BUNDLE))
136
+ return null;
137
+ if (bundleBackend(AUTH_BUNDLE) !== 'file') {
138
+ throw new ReservedBundleWrongBackendError(AUTH_BUNDLE, bundleBackend(AUTH_BUNDLE));
139
+ }
140
+ const { env } = readAndResolveBundleEnv(AUTH_BUNDLE, { caller: 'usage', agentOnly: true });
141
+ for (const [key, value] of Object.entries(env)) {
142
+ if (value.trim() !== token)
143
+ continue;
144
+ const email = emailFromTokenKey(key);
145
+ if (!email)
146
+ continue;
147
+ seedClaudeWorkerHomeIdentity(home, email);
148
+ return email;
149
+ }
150
+ return null;
151
+ }
152
+ /**
153
+ * Decode the email a `CLAUDE_CODE_OAUTH_TOKEN_<slug>` key encodes — or null
154
+ * when the decode is ambiguous. The mapping collapses every non-alphanumeric
155
+ * to `_`, so a `_` surviving in the decoded address cannot be told apart from
156
+ * a folded `.`/`+`/etc. (`FIRST_DOT_LAST_AT_GMAIL_DOT_COM` decodes to
157
+ * `first_dot_last@gmail.com`, which round-trips — wrongly — under the bare
158
+ * re-encode check; review BLOCKER 1). Only a fully unambiguous decode — local
159
+ * and domain pure `[a-z0-9]`, dots in the domain only — is trusted.
160
+ */
161
+ function emailFromTokenKey(key) {
162
+ const prefix = 'CLAUDE_CODE_OAUTH_TOKEN_';
163
+ if (!key.startsWith(prefix))
164
+ return null;
165
+ const slug = key.slice(prefix.length);
166
+ const [local, domain, ...rest] = slug.split('_AT_');
167
+ if (!local || !domain || rest.length > 0)
168
+ return null;
169
+ const email = `${local}@${domain.replace(/_DOT_/g, '.')}`.toLowerCase();
170
+ if (!/^[a-z0-9]+@[a-z0-9.]+$/.test(email))
171
+ return null;
172
+ if (claudeAccountTokenKey(email) !== key)
173
+ return null;
174
+ return email;
175
+ }
87
176
  /**
88
177
  * Resolve a long-lived setup-token for an EXPLICIT account email, independent of
89
178
  * any version home's `.claude.json`. This is what lets `agents accounts attach`
@@ -167,14 +256,29 @@ export function seedClaudeWorkerHomeIdentity(versionHome, email) {
167
256
  try {
168
257
  doc = JSON.parse(fs.readFileSync(p, 'utf-8'));
169
258
  }
170
- catch {
171
- // Missing or unreadable at this location — write a fresh minimal document.
259
+ catch (err) {
260
+ // Missing file → write a fresh minimal document. A file that EXISTS but
261
+ // does not parse is being concurrently rewritten by Claude Code itself —
262
+ // skip it rather than overwrite a live config with the minimal doc.
263
+ if (err.code !== 'ENOENT')
264
+ continue;
172
265
  }
173
266
  const existing = (doc.oauthAccount && typeof doc.oauthAccount === 'object'
174
267
  ? doc.oauthAccount
175
268
  : {});
176
269
  doc.oauthAccount = { ...existing, emailAddress: trimmed };
177
270
  fs.mkdirSync(path.dirname(p), { recursive: true });
178
- fs.writeFileSync(p, JSON.stringify(doc));
271
+ // Temp-write + rename: a reader mid-write never sees a truncated doc.
272
+ const tmp = `${p}.agents-${process.pid}.tmp`;
273
+ try {
274
+ fs.writeFileSync(tmp, JSON.stringify(doc));
275
+ fs.renameSync(tmp, p);
276
+ }
277
+ catch {
278
+ try {
279
+ fs.rmSync(tmp, { force: true });
280
+ }
281
+ catch { /* best effort */ }
282
+ }
179
283
  }
180
284
  }
@@ -1,16 +1,56 @@
1
1
  /**
2
- * Account-state service lifecycle as a `DaemonService` (RUSH-3193 P2).
2
+ * Account-state services as supervised `PeriodicService`s (PHNX-3608).
3
3
  *
4
- * Wraps `startAccountStateService()` under the `ServiceSupervisor` contract
5
- * so the supervisor owns start/stop and reports health through the uniform
6
- * path. The account-state service owns its own internal intervals for usage
7
- * and auth refreshes; the supervisor just starts and stops it.
4
+ * The daemon owns usage and authentication health as first-party device state.
5
+ * This used to run its OWN two `setInterval` loops behind a `usageRunning` /
6
+ * `authRunning` latch with NO deadline (`account-state-service.ts`, now removed):
7
+ * a `runUsageRefreshTick` that hung on an unbounded provider await latched
8
+ * `usageRunning = true` forever, so the usage cache froze for the daemon's whole
9
+ * life while the service still looked healthy — the "12h usage-dark" root cause.
10
+ *
11
+ * Now usage and auth are TWO independent supervised services, each with its own
12
+ * timer, per-tick deadline, AbortSignal, and — crucially — its own circuit
13
+ * breaker (`AccountUsageService` = `account-state`, `AccountAuthService` =
14
+ * `account-auth`). Keeping them separate means a run of usage-refresh failures
15
+ * parks ONLY usage and never starves the slower auth refresh (they hit different
16
+ * endpoints), matching the old design's independent loops. Each threads its
17
+ * deadline AbortSignal into its refresh so the tick unwinds at the deadline
18
+ * instead of blocking; the provider fetch is itself already bounded
19
+ * (`AbortSignal.timeout` at the leaf), and the supervisor abandons + restarts a
20
+ * hung tick regardless.
8
21
  */
9
- import { BaseDaemonService, type DaemonContext } from './service.js';
22
+ import { BasePeriodicService, type DaemonContext } from './service.js';
10
23
  import type { DaemonServiceId } from '../daemon-services.js';
11
- export declare class AccountStateDaemonService extends BaseDaemonService {
24
+ /** Usage cache refresh cadence. */
25
+ export declare const USAGE_STATE_TICK_MS = 60000;
26
+ /** Fleet-auth refresh cadence — deliberately slower (rate-limited endpoint). */
27
+ export declare const AUTH_STATE_TICK_MS: number;
28
+ /**
29
+ * Refresh the usage cache the `agents run` router reads. Independent circuit
30
+ * breaker from auth — a persistently-failing usage endpoint parks only this.
31
+ */
32
+ export declare class AccountUsageService extends BasePeriodicService {
33
+ readonly id: DaemonServiceId;
34
+ readonly intervalMs = 60000;
35
+ readonly deadlineMs: number;
36
+ private readonly refresh;
37
+ constructor(refresh?: (signal: AbortSignal) => Promise<void>);
38
+ protected onStart(): Promise<void>;
39
+ protected onStop(): Promise<void>;
40
+ protected onTick(_ctx: DaemonContext, signal: AbortSignal): Promise<void>;
41
+ }
42
+ /**
43
+ * Publish this host's fleet-status row and refresh auth health. Independent
44
+ * circuit breaker from usage, and on a slower cadence (the auth verdict rides a
45
+ * rate-limited endpoint).
46
+ */
47
+ export declare class AccountAuthService extends BasePeriodicService {
12
48
  readonly id: DaemonServiceId;
13
- private service;
14
- protected onStart(ctx: DaemonContext): Promise<void>;
49
+ readonly intervalMs: number;
50
+ readonly deadlineMs: number;
51
+ private readonly refresh;
52
+ constructor(refresh?: (signal: AbortSignal) => Promise<void>);
53
+ protected onStart(): Promise<void>;
15
54
  protected onStop(): Promise<void>;
55
+ protected onTick(_ctx: DaemonContext, signal: AbortSignal): Promise<void>;
16
56
  }
@@ -1,26 +1,89 @@
1
1
  /**
2
- * Account-state service lifecycle as a `DaemonService` (RUSH-3193 P2).
2
+ * Account-state services as supervised `PeriodicService`s (PHNX-3608).
3
3
  *
4
- * Wraps `startAccountStateService()` under the `ServiceSupervisor` contract
5
- * so the supervisor owns start/stop and reports health through the uniform
6
- * path. The account-state service owns its own internal intervals for usage
7
- * and auth refreshes; the supervisor just starts and stops it.
4
+ * The daemon owns usage and authentication health as first-party device state.
5
+ * This used to run its OWN two `setInterval` loops behind a `usageRunning` /
6
+ * `authRunning` latch with NO deadline (`account-state-service.ts`, now removed):
7
+ * a `runUsageRefreshTick` that hung on an unbounded provider await latched
8
+ * `usageRunning = true` forever, so the usage cache froze for the daemon's whole
9
+ * life while the service still looked healthy — the "12h usage-dark" root cause.
10
+ *
11
+ * Now usage and auth are TWO independent supervised services, each with its own
12
+ * timer, per-tick deadline, AbortSignal, and — crucially — its own circuit
13
+ * breaker (`AccountUsageService` = `account-state`, `AccountAuthService` =
14
+ * `account-auth`). Keeping them separate means a run of usage-refresh failures
15
+ * parks ONLY usage and never starves the slower auth refresh (they hit different
16
+ * endpoints), matching the old design's independent loops. Each threads its
17
+ * deadline AbortSignal into its refresh so the tick unwinds at the deadline
18
+ * instead of blocking; the provider fetch is itself already bounded
19
+ * (`AbortSignal.timeout` at the leaf), and the supervisor abandons + restarts a
20
+ * hung tick regardless.
8
21
  */
9
- import { BaseDaemonService } from './service.js';
10
- import { startAccountStateService } from '../account-state-service.js';
22
+ import { BasePeriodicService } from './service.js';
11
23
  import { runUsageRefreshTick, runFleetCacheWarmTick } from '../daemon-ticks.js';
12
- export class AccountStateDaemonService extends BaseDaemonService {
24
+ /** Usage cache refresh cadence. */
25
+ export const USAGE_STATE_TICK_MS = 60_000;
26
+ /** Fleet-auth refresh cadence — deliberately slower (rate-limited endpoint). */
27
+ export const AUTH_STATE_TICK_MS = 3 * 60_000;
28
+ /** A hung refresh is abandoned at this deadline; a real sweep is far under it. */
29
+ const REFRESH_DEADLINE_MS = 2 * 60_000;
30
+ /**
31
+ * A promise that rejects when `signal` aborts (immediately if already aborted),
32
+ * so a tick can `Promise.race` its work against the supervisor's deadline and
33
+ * unwind instead of blocking on an await that may never settle. The signal is
34
+ * per-tick, so the once-listener is dropped with it — no cross-tick leak.
35
+ */
36
+ function abortRejection(signal, label) {
37
+ return new Promise((_, reject) => {
38
+ if (signal.aborted) {
39
+ reject(new Error(label));
40
+ return;
41
+ }
42
+ signal.addEventListener('abort', () => reject(new Error(label)), { once: true });
43
+ });
44
+ }
45
+ /**
46
+ * Refresh the usage cache the `agents run` router reads. Independent circuit
47
+ * breaker from auth — a persistently-failing usage endpoint parks only this.
48
+ */
49
+ export class AccountUsageService extends BasePeriodicService {
13
50
  id = 'account-state';
14
- service = null;
15
- async onStart(ctx) {
16
- this.service = startAccountStateService({
17
- refreshUsage: runUsageRefreshTick,
18
- refreshAuth: runFleetCacheWarmTick,
19
- onError: (area, error) => ctx.log('WARN', `${area} state refresh failed: ${error.message}`),
20
- });
51
+ intervalMs = USAGE_STATE_TICK_MS;
52
+ deadlineMs = REFRESH_DEADLINE_MS;
53
+ refresh;
54
+ constructor(refresh = runUsageRefreshTick) {
55
+ super();
56
+ this.refresh = refresh;
57
+ }
58
+ async onStart() { }
59
+ async onStop() { }
60
+ async onTick(_ctx, signal) {
61
+ await Promise.race([
62
+ this.refresh(signal),
63
+ abortRejection(signal, 'usage refresh aborted at deadline'),
64
+ ]);
65
+ }
66
+ }
67
+ /**
68
+ * Publish this host's fleet-status row and refresh auth health. Independent
69
+ * circuit breaker from usage, and on a slower cadence (the auth verdict rides a
70
+ * rate-limited endpoint).
71
+ */
72
+ export class AccountAuthService extends BasePeriodicService {
73
+ id = 'account-auth';
74
+ intervalMs = AUTH_STATE_TICK_MS;
75
+ deadlineMs = REFRESH_DEADLINE_MS;
76
+ refresh;
77
+ constructor(refresh = runFleetCacheWarmTick) {
78
+ super();
79
+ this.refresh = refresh;
21
80
  }
22
- async onStop() {
23
- this.service?.stop();
24
- this.service = null;
81
+ async onStart() { }
82
+ async onStop() { }
83
+ async onTick(_ctx, signal) {
84
+ await Promise.race([
85
+ this.refresh(signal),
86
+ abortRejection(signal, 'auth refresh aborted at deadline'),
87
+ ]);
25
88
  }
26
89
  }
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Reserved `auth` bundle fleet sync as a `PeriodicService` (PHNX-2371).
3
3
  *
4
- * Each daemon that has a local file-backed `auth` bundle pushes it to pinned
5
- * fleet devices that do not yet have it. The destination auto-provisions its
6
- * own machine-local key; no passphrase is forwarded. Single-executor per
7
- * destination: this process only writes the remote's own store.
4
+ * Each daemon publishes a safe readiness verdict to the fleet-shared user repo,
5
+ * then runs the same serialized, timeout-bounded git exchange as usage sync.
6
+ * One deterministic ready device asynchronously provisions peers whose delivered
7
+ * verdict says `missing`; the secret never enters Git.
8
8
  */
9
9
  import { BasePeriodicService, type DaemonContext } from './service.js';
10
10
  import type { DaemonServiceId } from '../daemon-services.js';
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Reserved `auth` bundle fleet sync as a `PeriodicService` (PHNX-2371).
3
3
  *
4
- * Each daemon that has a local file-backed `auth` bundle pushes it to pinned
5
- * fleet devices that do not yet have it. The destination auto-provisions its
6
- * own machine-local key; no passphrase is forwarded. Single-executor per
7
- * destination: this process only writes the remote's own store.
4
+ * Each daemon publishes a safe readiness verdict to the fleet-shared user repo,
5
+ * then runs the same serialized, timeout-bounded git exchange as usage sync.
6
+ * One deterministic ready device asynchronously provisions peers whose delivered
7
+ * verdict says `missing`; the secret never enters Git.
8
8
  */
9
9
  import { BasePeriodicService } from './service.js';
10
10
  const AUTH_SYNC_TICK_MS = 15 * 60_000;
@@ -22,8 +22,19 @@ export class AuthSyncService extends BasePeriodicService {
22
22
  // Nothing to release — the supervisor's timer teardown is the only cleanup.
23
23
  }
24
24
  async onTick(ctx) {
25
- const { syncReservedAuthBundle } = await import('../secrets/reserved-sync.js');
26
- const result = syncReservedAuthBundle();
25
+ const { publishReservedAuthVerdict, syncReservedAuthBundle } = await import('../secrets/reserved-sync.js');
26
+ const published = publishReservedAuthVerdict();
27
+ if (published.error)
28
+ ctx.log('WARN', `auth-sync: verdict: ${published.error}`);
29
+ const { syncFleetSharedStateRepo } = await import('../fleet-shared-repo-sync.js');
30
+ const transport = await syncFleetSharedStateRepo();
31
+ if (transport.skipped)
32
+ ctx.log('WARN', `auth-sync: ${transport.skipped}`);
33
+ if (transport.error)
34
+ ctx.log('WARN', `auth-sync: shared-store transport: ${transport.error}`);
35
+ if (!transport.success)
36
+ return;
37
+ const result = await syncReservedAuthBundle();
27
38
  if (result.pushed.length > 0) {
28
39
  ctx.log('INFO', `auth-sync: pushed auth to ${result.pushed.join(', ')}`);
29
40
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Catch-up recovery as a supervised `PeriodicService` (PHNX-3608).
3
+ *
4
+ * A catch-up pass detects routines whose scheduled fire this device missed (the
5
+ * laptop slept, the daemon was wedged, an OS suspend the process survived) and
6
+ * runs them late. It used to run on a bare `setInterval` booted/stopped inside
7
+ * `runDaemon()` alongside the scheduler, with only a local `catchingUp` overlap
8
+ * flag and NO deadline — a pass that hung on an off-box (host/cloud) dispatch
9
+ * could latch that flag and silently stop recovering missed fires for the
10
+ * daemon's life, the same "abstraction too weak" class the supervisor exists for.
11
+ *
12
+ * Under the supervisor it gets a per-tick deadline, an AbortSignal, and the
13
+ * circuit breaker: a hung pass is abandoned and restarted on backoff instead of
14
+ * latching. The scheduler itself (croner-driven `JobScheduler`) stays outside the
15
+ * supervisor — it is not a tick loop — so this service self-gates on whether the
16
+ * scheduler is currently booted and no-ops cheaply when the scheduler gate is off.
17
+ */
18
+ import { BasePeriodicService, type DaemonContext } from './service.js';
19
+ import type { DaemonServiceId } from '../daemon-services.js';
20
+ /**
21
+ * How often to re-run catch-up. A startup pass alone misses a fire lost while the
22
+ * daemon stayed up but its event loop was wedged, or one lost across an OS
23
+ * suspend the process survived — five minutes bounds the cost while still
24
+ * recovering from a wedge or an OS suspend the process survived.
25
+ */
26
+ export declare const CATCHUP_TICK_MS: number;
27
+ export interface CatchupServiceDeps {
28
+ /**
29
+ * Whether the routine scheduler is currently booted. When false (the
30
+ * `scheduler.enabled` gate is off), the pass no-ops — there is nothing to
31
+ * catch up on a device where no routines fire.
32
+ */
33
+ isSchedulerBooted: () => boolean;
34
+ /** Run one catch-up pass. Receives the tick's AbortSignal so it can bound its dispatches. */
35
+ runPass: (signal: AbortSignal) => Promise<void>;
36
+ }
37
+ export declare class CatchupService extends BasePeriodicService {
38
+ private readonly deps;
39
+ readonly id: DaemonServiceId;
40
+ readonly intervalMs: number;
41
+ /**
42
+ * A pass awaits `executeJobDetached` per overdue job and an off-box dispatch
43
+ * can block for a while, so the bound is generous — but finite, which the old
44
+ * un-deadlined loop was not. A pass exceeding it is hung, not slow.
45
+ */
46
+ readonly deadlineMs: number;
47
+ constructor(deps: CatchupServiceDeps);
48
+ protected onStart(): Promise<void>;
49
+ protected onStop(): Promise<void>;
50
+ protected onTick(_ctx: DaemonContext, signal: AbortSignal): Promise<void>;
51
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Catch-up recovery as a supervised `PeriodicService` (PHNX-3608).
3
+ *
4
+ * A catch-up pass detects routines whose scheduled fire this device missed (the
5
+ * laptop slept, the daemon was wedged, an OS suspend the process survived) and
6
+ * runs them late. It used to run on a bare `setInterval` booted/stopped inside
7
+ * `runDaemon()` alongside the scheduler, with only a local `catchingUp` overlap
8
+ * flag and NO deadline — a pass that hung on an off-box (host/cloud) dispatch
9
+ * could latch that flag and silently stop recovering missed fires for the
10
+ * daemon's life, the same "abstraction too weak" class the supervisor exists for.
11
+ *
12
+ * Under the supervisor it gets a per-tick deadline, an AbortSignal, and the
13
+ * circuit breaker: a hung pass is abandoned and restarted on backoff instead of
14
+ * latching. The scheduler itself (croner-driven `JobScheduler`) stays outside the
15
+ * supervisor — it is not a tick loop — so this service self-gates on whether the
16
+ * scheduler is currently booted and no-ops cheaply when the scheduler gate is off.
17
+ */
18
+ import { BasePeriodicService } from './service.js';
19
+ /**
20
+ * How often to re-run catch-up. A startup pass alone misses a fire lost while the
21
+ * daemon stayed up but its event loop was wedged, or one lost across an OS
22
+ * suspend the process survived — five minutes bounds the cost while still
23
+ * recovering from a wedge or an OS suspend the process survived.
24
+ */
25
+ export const CATCHUP_TICK_MS = 5 * 60_000;
26
+ export class CatchupService extends BasePeriodicService {
27
+ deps;
28
+ id = 'catchup';
29
+ intervalMs = CATCHUP_TICK_MS;
30
+ /**
31
+ * A pass awaits `executeJobDetached` per overdue job and an off-box dispatch
32
+ * can block for a while, so the bound is generous — but finite, which the old
33
+ * un-deadlined loop was not. A pass exceeding it is hung, not slow.
34
+ */
35
+ deadlineMs = 4 * 60_000;
36
+ constructor(deps) {
37
+ super();
38
+ this.deps = deps;
39
+ }
40
+ async onStart() {
41
+ // No owned resources — the supervisor owns the timer.
42
+ }
43
+ async onStop() {
44
+ // No owned resources.
45
+ }
46
+ async onTick(_ctx, signal) {
47
+ if (!this.deps.isSchedulerBooted())
48
+ return;
49
+ await this.deps.runPass(signal);
50
+ }
51
+ }
@@ -120,8 +120,11 @@ export declare function removeHeartbeat(): void;
120
120
  export declare function isDaemonWedged(): boolean;
121
121
  /**
122
122
  * Check whether a daemon is alive — via the pid file, or a fresh heartbeat when
123
- * the pid file has been lost (see resolveLiveDaemonPid). Heals the pid file as a
124
- * side effect so a subsequent read is consistent.
123
+ * the pid file has been lost (see resolveLiveDaemonPid). The observation itself
124
+ * is read-only; when it finds desync it opportunistically acquires daemon.lock
125
+ * and repeats the observation there before repairing. A contended probe still
126
+ * returns the observed liveness without mutating another lifecycle operation's
127
+ * state.
125
128
  */
126
129
  export declare function isDaemonRunning(): boolean;
127
130
  /**
@@ -137,9 +140,9 @@ export declare function isDaemonRunning(): boolean;
137
140
  * pid file, this does NOT defer to it — it evicts the incumbent and becomes the
138
141
  * survivor, so a second install can never leave two daemons running. Returns true
139
142
  * and records our PID once the incumbent is provably dead (its resources
140
- * released). Returns false ONLY when another `__daemon-run` currently holds the
141
- * O_EXCL start lock — i.e. a concurrent claimer is mid-takeover and will be the
142
- * singleton — in which case the caller must exit without touching further state.
143
+ * released). Returns false when another `__daemon-run` currently holds the
144
+ * O_EXCL start lock, or when the incumbent cannot be safely identified/evicted,
145
+ * in which case the caller must exit without touching further state.
143
146
  * The read-evict-write is serialized behind the same start lock startDaemon()
144
147
  * uses, so two `_run` processes can't both claim in the window between the
145
148
  * liveness check and the write.
@@ -177,8 +180,11 @@ export declare function reapStrayDaemons(keepPid?: number): {
177
180
  reaped: number;
178
181
  details: string[];
179
182
  };
183
+ export declare function isLiveDaemon(pid: number): boolean;
180
184
  /** Append a JSONL log entry to the daemon log file (owner-only permissions). */
181
185
  export declare function log(level: string, message: string): void;
186
+ /** Keep synchronous signal-handler failures inside the daemon's crash barrier. */
187
+ export declare function guardSignalHandler(handler: () => void, onError: (err: unknown) => void): () => void;
182
188
  /** Main daemon loop: load jobs, schedule crons, monitor runs, and handle signals. */
183
189
  /**
184
190
  * Anchor the daemon's working directory to a stable, always-present path.
@@ -390,7 +396,7 @@ export declare function startDetached(opts?: StartDetachedOptions): {
390
396
  export interface StopResidueArtifact {
391
397
  label: string;
392
398
  present: boolean;
393
- /** The file names a pid that is alive and is NOT the daemon we stopped. */
399
+ /** The file names a live owner, including a stopped target that survived. */
394
400
  ownedByLiveOther: boolean;
395
401
  reclaim: () => void;
396
402
  stillPresent: () => boolean;
@@ -424,22 +430,6 @@ export interface DaemonStopResult {
424
430
  surviving: string[];
425
431
  detachedChildren: number[];
426
432
  }
427
- /**
428
- * Live `__daemon-run` processes still registered in THIS state dir's instance
429
- * registry, excluding `exclude`. State-dir-scoped by construction: the registry
430
- * lives inside this state dir, so a daemon serving a DIFFERENT state dir (a test
431
- * fixture with its own HOME, a separate install/home) registers elsewhere and is
432
- * invisible here — it is never a stop/takeover target. POSIX-only (the registry
433
- * and its `ps` liveness probe are); `[]` on Windows.
434
- *
435
- * Exported for `agents daemon status`/`doctor`/`services` (RUSH-2368): those
436
- * commands previously flagged every `__daemon-run` on the box (a raw `ps` scan)
437
- * as a "duplicate" of this daemon, which misreported test fixtures under their
438
- * own HOME — and therefore their own state dir and registry — as strays to
439
- * kill. This registry read is the same scope the reaper (`reapStrayDaemons`)
440
- * and the stop postcondition (`stopDaemon`) already use, so the display and the
441
- * reaper agree on what a duplicate is.
442
- */
443
433
  export declare function findSurvivingStateDirDaemons(exclude: Set<number>): number[];
444
434
  /**
445
435
  * Stop the daemon and ASSERT its postcondition (SING-12, RUSH-2355), unloading it