@phnx-labs/agents-cli 1.22.70 → 1.22.72

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 (72) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +31 -1
  3. package/dist/bootstrap.js +4 -4
  4. package/dist/commands/repo.js +2 -2
  5. package/dist/commands/sessions-export.d.ts +5 -1
  6. package/dist/commands/sessions-export.js +100 -24
  7. package/dist/commands/sessions-import.d.ts +2 -1
  8. package/dist/commands/sessions-import.js +85 -21
  9. package/dist/lib/accounting/usage-sync.d.ts +1 -1
  10. package/dist/lib/accounting/usage-sync.js +3 -3
  11. package/dist/lib/browser/ipc.d.ts +34 -0
  12. package/dist/lib/browser/ipc.js +140 -19
  13. package/dist/lib/browser/types.d.ts +3 -1
  14. package/dist/lib/daemon/auth-sync-service.js +1 -1
  15. package/dist/lib/daemon/browser-task-reap-service.js +1 -1
  16. package/dist/lib/daemon/daemon.js +13 -3
  17. package/dist/lib/daemon/heartbeat-service.js +3 -3
  18. package/dist/lib/daemon/keychain-reap-service.js +1 -1
  19. package/dist/lib/daemon/runner.d.ts +18 -1
  20. package/dist/lib/daemon/runner.js +231 -78
  21. package/dist/lib/daemon/self-heal-service.js +13 -3
  22. package/dist/lib/daemon/self-update-service.d.ts +174 -0
  23. package/dist/lib/daemon/self-update-service.js +353 -0
  24. package/dist/lib/daemon/state-dir-check-service.js +3 -3
  25. package/dist/lib/daemon/usage-sync-service.js +1 -1
  26. package/dist/lib/daemon/watchdog-service.js +4 -4
  27. package/dist/lib/daemon-services.d.ts +1 -1
  28. package/dist/lib/daemon-services.js +5 -0
  29. package/dist/lib/device-config.d.ts +12 -1
  30. package/dist/lib/device-config.js +63 -13
  31. package/dist/lib/exec-bounded.d.ts +52 -0
  32. package/dist/lib/exec-bounded.js +113 -0
  33. package/dist/lib/feed/events.d.ts +22 -14
  34. package/dist/lib/feed/events.js +84 -44
  35. package/dist/lib/fleet-shared-state.d.ts +12 -5
  36. package/dist/lib/fleet-shared-state.js +50 -20
  37. package/dist/lib/fs-atomic.d.ts +11 -0
  38. package/dist/lib/fs-atomic.js +60 -0
  39. package/dist/lib/hosts/reconcile.d.ts +11 -4
  40. package/dist/lib/hosts/reconcile.js +31 -5
  41. package/dist/lib/project-resources.d.ts +12 -0
  42. package/dist/lib/project-resources.js +138 -0
  43. package/dist/lib/routine-process-cleanup.d.ts +2 -2
  44. package/dist/lib/routine-process-cleanup.js +45 -34
  45. package/dist/lib/secrets/reaper.d.ts +2 -2
  46. package/dist/lib/secrets/reaper.js +13 -10
  47. package/dist/lib/secrets/reserved-sync.d.ts +1 -1
  48. package/dist/lib/secrets/reserved-sync.js +4 -4
  49. package/dist/lib/self-update.d.ts +21 -8
  50. package/dist/lib/self-update.js +54 -31
  51. package/dist/lib/session/sync/backend.d.ts +61 -0
  52. package/dist/lib/session/sync/backend.js +89 -0
  53. package/dist/lib/session/sync/managed-config.d.ts +29 -0
  54. package/dist/lib/session/sync/managed-config.js +23 -0
  55. package/dist/lib/session/sync/managed-key.d.ts +45 -0
  56. package/dist/lib/session/sync/managed-key.js +128 -0
  57. package/dist/lib/session/sync/net-client.d.ts +65 -0
  58. package/dist/lib/session/sync/net-client.js +117 -0
  59. package/dist/lib/session/sync/provision.d.ts +19 -0
  60. package/dist/lib/session/sync/provision.js +38 -0
  61. package/dist/lib/session/sync/r2.d.ts +5 -2
  62. package/dist/lib/session/sync/r2.js +5 -2
  63. package/dist/lib/session/sync/worker-template.d.ts +6 -0
  64. package/dist/lib/session/sync/worker-template.js +847 -0
  65. package/dist/lib/tmux/orphan-reap.js +6 -4
  66. package/dist/lib/tmux/session.js +4 -1
  67. package/dist/lib/traces/classify.d.ts +8 -1
  68. package/dist/lib/traces/insights.d.ts +13 -1
  69. package/dist/lib/traces/insights.js +78 -3
  70. package/dist/lib/traces/sync.js +8 -3
  71. package/dist/lib/traces/worker-template.js +9 -5
  72. package/package.json +1 -1
@@ -3,6 +3,16 @@ import { BrowserService } from './service.js';
3
3
  import type { IPCRequest, IPCResponse } from './types.js';
4
4
  export interface IPCRequestOptions {
5
5
  autoStartDaemon?: boolean;
6
+ /**
7
+ * Opt-in client-side deadline in ms — most IPC actions (browser automation,
8
+ * long-running recordings) have no fixed budget and must NOT get one by
9
+ * default. Set explicitly for a request that genuinely has one, e.g.
10
+ * `request-self-update`, whose handler answers immediately (it kicks the
11
+ * install off in the background — see `triggerSelfUpdateInBackground`), so its
12
+ * client deadline (`DAEMON_SELF_UPDATE_TRIGGER_TIMEOUT_MS`) is a short
13
+ * "did the daemon accept the trigger?" bound, not a wait on the install.
14
+ */
15
+ timeoutMs?: number;
6
16
  }
7
17
  export declare class BrowserServiceNotRunningError extends Error {
8
18
  constructor();
@@ -148,6 +158,23 @@ export declare class BrowserIPCServer {
148
158
  * owns the shared process.
149
159
  */
150
160
  export declare function shouldRecommendDaemonRefresh(daemonVersion: string | undefined, clientVersion: string): boolean;
161
+ /**
162
+ * Reconcile the running daemon's version with ours without evicting it. If the
163
+ * daemon is serving stale code, ask the daemon to run its OWN self-update path
164
+ * (`request-self-update`, same fail-closed check/install/verify/exit
165
+ * SelfUpdateService's periodic tick runs, self-update-service.ts) rather than
166
+ * telling a human to run `agents daemon restart` by hand. A browser client
167
+ * still owns neither the shared supervisor nor its sibling services, so
168
+ * version skew can never be permission for THIS client to stop or restart
169
+ * that process directly (PHNX-3605 holds) — it only asks the daemon to run a
170
+ * path the daemon already runs on its own schedule, which only exits once the
171
+ * daemon itself has verified the new version is good. If self-update declines
172
+ * (dev build, shadowed install, already current, or a real failure) this
173
+ * degrades to the same "nothing changed" outcome PHNX-3605 shipped.
174
+ */
175
+ export declare function reconcileDaemonVersion(): Promise<void>;
176
+ /** Test-only: `reconcileDaemonVersion` runs at most once per real process — this clears that latch so a test file can drive more than one branch. */
177
+ export declare function resetVersionReconciliationForTest(): void;
151
178
  export declare function sendIPCRequest(request: IPCRequest, opts?: IPCRequestOptions): Promise<IPCResponse>;
152
179
  /**
153
180
  * Fill actor / launchId / sessionId from the calling process when the request
@@ -160,3 +187,10 @@ export declare function stampCallerIdentity(request: IPCRequest, env?: NodeJS.Pr
160
187
  * only the client process and IPC socket lifetime differ.
161
188
  */
162
189
  export declare function connectBrowserIPC(opts?: IPCRequestOptions): Promise<BrowserIPCConnection>;
190
+ /**
191
+ * Exported (rather than kept module-private) so `ipc.test.ts` can drive the
192
+ * `opts.timeoutMs` client-side deadline directly against a raw `net.Server`
193
+ * test double that never responds — the hermetic way to prove the timeout
194
+ * actually fires without needing a slow real IPC action to provoke it.
195
+ */
196
+ export declare function sendRawIPCRequest(request: IPCRequest, opts?: IPCRequestOptions): Promise<IPCResponse>;
@@ -7,6 +7,8 @@ import { resolveBrowserTaskIdleMs } from '../device-config.js';
7
7
  import { getDaemonLogPath, isDaemonRunning, signalDaemonReload, startDaemon, } from '../daemon/daemon.js';
8
8
  import { getCliVersion } from '../version.js';
9
9
  import { compareVersions } from '../agent-spec/primitives.js';
10
+ import { scheduleSelfUpdateExit, selfUpdateSyncDeclineReason, triggerSelfUpdateInBackground } from '../daemon/self-update-service.js';
11
+ import { log as daemonLog } from '../daemon/daemon.js';
10
12
  import { isDaemonServiceEnabled, queueDaemonServiceRestart, setDaemonServiceEnabled, } from '../daemon-services.js';
11
13
  import { isFleetRemoteInvocation } from './remote-control.js';
12
14
  import { resolveCallerIdentity } from './caller-identity.js';
@@ -82,6 +84,17 @@ function isSameSocket(socketPath, expected) {
82
84
  * coming near the grace window.
83
85
  */
84
86
  const IPC_CLOSE_TIMEOUT_MS = 1_500;
87
+ /**
88
+ * Client-side ceiling on the on-demand `request-self-update` call inside
89
+ * {@link reconcileDaemonVersion}. The daemon handler is DECOUPLED from the
90
+ * install — it kicks the self-update off in the background and answers
91
+ * immediately — so this only needs to be long enough to confirm the daemon
92
+ * ACCEPTED the trigger, never long enough to wait out a check/install/verify.
93
+ * It must stay short because a version-skewed browser verb routes through this
94
+ * reconcile on its way to the real request; a wedged or gone daemon degrades to
95
+ * the advisory PHNX-3605 shipped rather than stalling the verb.
96
+ */
97
+ const DAEMON_SELF_UPDATE_TRIGGER_TIMEOUT_MS = 5_000;
85
98
  /**
86
99
  * How long {@link waitForBrowserService} waits for the browser service to come up before
87
100
  * failing loud (PHNX-3289).
@@ -655,6 +668,44 @@ export class BrowserIPCServer {
655
668
  case 'version': {
656
669
  return { ok: true, version: getCliVersion() };
657
670
  }
671
+ // On-demand self-update, triggered by a client that noticed version
672
+ // skew (reconcileDaemonVersion below). DECOUPLED from the install
673
+ // (PHNX-3605): this handler MUST NOT await the check→download→install→
674
+ // verify, because it sits on the path of EVERY version-skewed
675
+ // `agents browser <verb>` (reconcileDaemonVersion → prepareIPC → here), so
676
+ // awaiting the whole thing would synchronously stall an ordinary browser
677
+ // verb for tens of seconds — worst case ~15 min — behind daemon
678
+ // housekeeping, exactly the client-stall PHNX-3605 exists to prevent.
679
+ // Instead: kick the self-update off in the BACKGROUND (bounded, and
680
+ // sharing the module-level in-flight guard so two triggers can't race a
681
+ // second install) and respond IMMEDIATELY that it was accepted. The daemon
682
+ // does install→verify→exit(0) on its own; the OS supervisor relaunches it
683
+ // and the browser reconnects. scheduleSelfUpdateExit (fired from the
684
+ // background continuation on a verified update) still delays the exit so
685
+ // this response has flushed to the socket first, and serializes against
686
+ // the periodic tick's own exit.
687
+ case 'request-self-update': {
688
+ // Instant, network-free decline gate FIRST (dev build / shadowed
689
+ // install), so a version-skewed browser client gets the PHNX-3605
690
+ // "nothing changed, not evicting" advisory immediately rather than a
691
+ // "triggered" it would never act on — reconcileDaemonVersion keys the
692
+ // advisory on !selfUpdateTriggered. Only a genuinely-eligible install is
693
+ // backgrounded below; the slow registry/download/install/verify never
694
+ // runs on this response path.
695
+ const declineReason = selfUpdateSyncDeclineReason();
696
+ if (declineReason) {
697
+ return { ok: true, selfUpdateTriggered: false, reason: declineReason };
698
+ }
699
+ void triggerSelfUpdateInBackground({ log: daemonLog })
700
+ .then((outcome) => {
701
+ if (outcome.updated)
702
+ scheduleSelfUpdateExit();
703
+ })
704
+ .catch((err) => {
705
+ daemonLog('ERROR', `self-update: background trigger failed: ${err instanceof Error ? err.message : String(err)}`);
706
+ });
707
+ return { ok: true, selfUpdateTriggered: true };
708
+ }
658
709
  case 'show': {
659
710
  // Task-less by design — see BrowserService.showUrl. Absent from both
660
711
  // PAGE_CREATE_VERBS and PAGE_RESOLVE_VERBS, so bindTask never ran above
@@ -707,7 +758,7 @@ export class BrowserIPCServer {
707
758
  // this box's own configured default (or 30 minutes when unset).
708
759
  const idleMs = request.idleMinutes !== undefined
709
760
  ? (request.idleMinutes === 0 ? null : request.idleMinutes * 60_000)
710
- : resolveBrowserTaskIdleMs();
761
+ : await resolveBrowserTaskIdleMs();
711
762
  const reaped = await this.service.reapAbandoned({ idleMs, dryRun: request.dryRun });
712
763
  return { ok: true, reaped };
713
764
  }
@@ -1077,12 +1128,19 @@ export function shouldRecommendDaemonRefresh(daemonVersion, clientVersion) {
1077
1128
  }
1078
1129
  /**
1079
1130
  * Reconcile the running daemon's version with ours without evicting it. If the
1080
- * daemon is serving stale code, surface the deliberate operator command once
1081
- * and continue on the already-running service. A browser client owns neither
1082
- * the shared supervisor nor its sibling services, so version skew can never be
1083
- * permission to stop or restart that process (PHNX-3605).
1131
+ * daemon is serving stale code, ask the daemon to run its OWN self-update path
1132
+ * (`request-self-update`, same fail-closed check/install/verify/exit
1133
+ * SelfUpdateService's periodic tick runs, self-update-service.ts) rather than
1134
+ * telling a human to run `agents daemon restart` by hand. A browser client
1135
+ * still owns neither the shared supervisor nor its sibling services, so
1136
+ * version skew can never be permission for THIS client to stop or restart
1137
+ * that process directly (PHNX-3605 holds) — it only asks the daemon to run a
1138
+ * path the daemon already runs on its own schedule, which only exits once the
1139
+ * daemon itself has verified the new version is good. If self-update declines
1140
+ * (dev build, shadowed install, already current, or a real failure) this
1141
+ * degrades to the same "nothing changed" outcome PHNX-3605 shipped.
1084
1142
  */
1085
- async function reconcileDaemonVersion() {
1143
+ export async function reconcileDaemonVersion() {
1086
1144
  if (versionReconciledThisProcess)
1087
1145
  return;
1088
1146
  versionReconciledThisProcess = true;
@@ -1099,8 +1157,41 @@ async function reconcileDaemonVersion() {
1099
1157
  if (!shouldRecommendDaemonRefresh(daemon, client))
1100
1158
  return;
1101
1159
  process.stderr.write(`\nBrowser service is running on shared daemon ${daemon}, while this CLI is ${client}. `
1102
- + 'Continuing without evicting the daemon or its other services.\n'
1103
- + 'To load current daemon code when it is safe to interrupt every hosted service: agents daemon restart\n\n');
1160
+ + 'Asking the daemon to self-update in the background (verify-then-exit; the OS supervisor relaunches it onto the new code)...\n');
1161
+ try {
1162
+ // SHORT timeout — the handler is decoupled from the install now (it kicks
1163
+ // the self-update off in the background and answers at once), so this only
1164
+ // waits for the daemon to CONFIRM it accepted the trigger, not for the
1165
+ // install to finish. It must stay short so the real browser verb that
1166
+ // triggered this reconcile proceeds promptly; a wedged/gone daemon degrades
1167
+ // to the same advisory PHNX-3605 shipped.
1168
+ const resp = await sendRawIPCRequest({ action: 'request-self-update' }, { autoStartDaemon: false, timeoutMs: DAEMON_SELF_UPDATE_TRIGGER_TIMEOUT_MS });
1169
+ if (resp.selfUpdateTriggered) {
1170
+ process.stderr.write('Daemon self-update requested — it verifies a newer version in the background and restarts if one is available. '
1171
+ + 'Continuing without waiting.\n\n');
1172
+ }
1173
+ else {
1174
+ // Two shapes reach here: a CURRENT daemon that ran the instant decline
1175
+ // gate (dev build / shadowed install) and answered {selfUpdateTriggered:
1176
+ // false, reason}, and an OLDER daemon that predates the field entirely and
1177
+ // answered without it (no reason). Both degrade to the same non-evicting
1178
+ // advisory PHNX-3605 shipped; only the lead sentence differs.
1179
+ const declineDetail = resp.reason
1180
+ ? `Daemon self-update declined (${resp.reason}). `
1181
+ : 'Daemon did not accept the self-update request. ';
1182
+ process.stderr.write(declineDetail
1183
+ + 'Continuing without evicting the daemon or its other services. '
1184
+ + 'To load current daemon code when it is safe to interrupt every hosted service: agents daemon restart\n\n');
1185
+ }
1186
+ }
1187
+ catch (err) {
1188
+ process.stderr.write(`Could not reach the daemon to request a self-update (${err instanceof Error ? err.message : String(err)}). `
1189
+ + 'To load current daemon code when it is safe to interrupt every hosted service: agents daemon restart\n\n');
1190
+ }
1191
+ }
1192
+ /** Test-only: `reconcileDaemonVersion` runs at most once per real process — this clears that latch so a test file can drive more than one branch. */
1193
+ export function resetVersionReconciliationForTest() {
1194
+ versionReconciledThisProcess = false;
1104
1195
  }
1105
1196
  export async function sendIPCRequest(request, opts = {}) {
1106
1197
  // Stamp caller identity ONCE here so the 28+ call sites don't each have to.
@@ -1155,13 +1246,37 @@ export async function connectBrowserIPC(opts = {}) {
1155
1246
  });
1156
1247
  });
1157
1248
  }
1158
- async function sendRawIPCRequest(request, opts = {}) {
1249
+ /**
1250
+ * Exported (rather than kept module-private) so `ipc.test.ts` can drive the
1251
+ * `opts.timeoutMs` client-side deadline directly against a raw `net.Server`
1252
+ * test double that never responds — the hermetic way to prove the timeout
1253
+ * actually fires without needing a slow real IPC action to provoke it.
1254
+ */
1255
+ export async function sendRawIPCRequest(request, opts = {}) {
1159
1256
  const endpoint = getIpcEndpoint();
1160
1257
  const autoStartDaemon = opts.autoStartDaemon ?? true;
1161
1258
  await prepareIPC(request.action, opts);
1162
1259
  return new Promise((resolve, reject) => {
1163
1260
  const socket = net.createConnection(endpoint);
1164
1261
  let buffer = '';
1262
+ let settled = false;
1263
+ let deadline;
1264
+ const settle = (fn) => {
1265
+ if (settled)
1266
+ return;
1267
+ settled = true;
1268
+ if (deadline)
1269
+ clearTimeout(deadline);
1270
+ fn();
1271
+ };
1272
+ if (opts.timeoutMs) {
1273
+ deadline = setTimeout(() => {
1274
+ settle(() => {
1275
+ socket.destroy();
1276
+ reject(new Error(`IPC request '${request.action}' timed out after ${opts.timeoutMs}ms`));
1277
+ });
1278
+ }, opts.timeoutMs);
1279
+ }
1165
1280
  socket.on('connect', () => {
1166
1281
  socket.write(JSON.stringify(request) + '\n');
1167
1282
  });
@@ -1170,21 +1285,27 @@ async function sendRawIPCRequest(request, opts = {}) {
1170
1285
  const idx = buffer.indexOf('\n');
1171
1286
  if (idx !== -1) {
1172
1287
  const response = JSON.parse(buffer.slice(0, idx));
1173
- socket.end();
1174
- resolve(response);
1288
+ settle(() => {
1289
+ socket.end();
1290
+ resolve(response);
1291
+ });
1175
1292
  }
1176
1293
  });
1177
1294
  socket.on('error', (err) => {
1178
- if (!autoStartDaemon && (err.code === 'ENOENT' || err.code === 'ECONNREFUSED')) {
1179
- reject(new BrowserServiceNotRunningError());
1180
- return;
1181
- }
1182
- reject(new Error(`IPC error: ${err.message}`));
1295
+ settle(() => {
1296
+ if (!autoStartDaemon && (err.code === 'ENOENT' || err.code === 'ECONNREFUSED')) {
1297
+ reject(new BrowserServiceNotRunningError());
1298
+ return;
1299
+ }
1300
+ reject(new Error(`IPC error: ${err.message}`));
1301
+ });
1183
1302
  });
1184
1303
  socket.on('close', () => {
1185
- if (!buffer.includes('\n')) {
1186
- reject(new Error('Connection closed before response'));
1187
- }
1304
+ settle(() => {
1305
+ if (!buffer.includes('\n')) {
1306
+ reject(new Error('Connection closed before response'));
1307
+ }
1308
+ });
1188
1309
  });
1189
1310
  });
1190
1311
  }
@@ -287,7 +287,7 @@ export interface ReapResult {
287
287
  /** Tasks left alone this pass: still live, still inside the idle window, or recording. */
288
288
  skipped: number;
289
289
  }
290
- export type IPCAction = 'start' | 'show' | 'gc' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'pdf' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload' | 'getAppLogs' | 'version';
290
+ export type IPCAction = 'start' | 'show' | 'gc' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'pdf' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload' | 'getAppLogs' | 'version' | 'request-self-update';
291
291
  export interface IPCRequest {
292
292
  action: IPCAction;
293
293
  task?: string;
@@ -425,6 +425,8 @@ export interface IPCResponse {
425
425
  uploadMode?: 'input' | 'drop' | 'chooser';
426
426
  appLogs?: any[];
427
427
  version?: string;
428
+ selfUpdateTriggered?: boolean;
429
+ reason?: string;
428
430
  skill?: {
429
431
  name: string;
430
432
  path: string;
@@ -23,7 +23,7 @@ export class AuthSyncService extends BasePeriodicService {
23
23
  }
24
24
  async onTick(ctx) {
25
25
  const { publishReservedAuthVerdict, syncReservedAuthBundle } = await import('../secrets/reserved-sync.js');
26
- const published = publishReservedAuthVerdict();
26
+ const published = await publishReservedAuthVerdict();
27
27
  if (published.error)
28
28
  ctx.log('WARN', `auth-sync: verdict: ${published.error}`);
29
29
  const { syncFleetSharedStateRepo } = await import('../fleet-shared-repo-sync.js');
@@ -15,7 +15,7 @@ export class BrowserTaskReapService extends BasePeriodicService {
15
15
  async onStart(_ctx) { }
16
16
  async onStop() { }
17
17
  async onTick(ctx) {
18
- const result = await this.browserService.reapAbandoned({ idleMs: resolveBrowserTaskIdleMs() });
18
+ const result = await this.browserService.reapAbandoned({ idleMs: await resolveBrowserTaskIdleMs() });
19
19
  if (result.closed.length > 0) {
20
20
  ctx.log('INFO', `Browser-task reaper: closed ${result.closed.length} task(s)`);
21
21
  for (const closed of result.closed) {
@@ -40,6 +40,7 @@ import { BrowserIPCService } from './browser-ipc-service.js';
40
40
  import { WatchdogService } from './watchdog-service.js';
41
41
  import { DeviceProbeService } from './device-probe-service.js';
42
42
  import { SelfHealService } from './self-heal-service.js';
43
+ import { SelfUpdateService } from './self-update-service.js';
43
44
  import { KeychainReapService } from './keychain-reap-service.js';
44
45
  import { AuthSyncService } from './auth-sync-service.js';
45
46
  import { UsageSyncService } from './usage-sync-service.js';
@@ -49,7 +50,7 @@ import { WebhookReceiverService } from './webhook-receiver-service.js';
49
50
  import { HeartbeatService } from './heartbeat-service.js';
50
51
  import { TmuxReapService } from './tmux-reap-service.js';
51
52
  import { BrowserTaskReapService } from './browser-task-reap-service.js';
52
- import { emit, emitRoutineEnd } from '../feed/events.js';
53
+ import { emit, emitAsync, emitRoutineEnd } from '../feed/events.js';
53
54
  import { readDaemonServicesConfig, drainDaemonServiceRestartQueue } from '../daemon-services.js';
54
55
  import { sleepSync } from '../fs-atomic.js';
55
56
  /**
@@ -749,11 +750,16 @@ export function log(level, message) {
749
750
  : lvl === 'START' || /starting|started/i.test(message) ? 'daemon.start'
750
751
  : lvl === 'STOP' || /stopping|stopped|shutting down/i.test(message) ? 'daemon.stop'
751
752
  : 'daemon.info';
752
- emit(event, {
753
+ // Fire-and-forget the event mirror: `log()` is a synchronous primitive on
754
+ // every daemon tick's `ctx.log`, and the mirror's event-log lock would
755
+ // otherwise block the shared event loop for up to 30s under contention
756
+ // (PHNX-3695). The daemon-log append above is the primary, synchronous sink;
757
+ // the mirror is best-effort, so a fire-and-forget async write is correct.
758
+ void emitAsync(event, {
753
759
  module: 'daemon',
754
760
  detail: redactSecrets(message).slice(0, 500),
755
761
  status: lvl,
756
- });
762
+ }).catch(() => { });
757
763
  }
758
764
  catch { /* never crash the daemon on event-log failure */ }
759
765
  }
@@ -1073,6 +1079,10 @@ export async function runDaemon() {
1073
1079
  supervisor.register(new SelfHealService());
1074
1080
  else
1075
1081
  log('INFO', 'Self-heal service disabled');
1082
+ if (isEnabled('self-update'))
1083
+ supervisor.register(new SelfUpdateService());
1084
+ else
1085
+ log('INFO', 'Self-update service disabled');
1076
1086
  if (isEnabled('keychain-reap'))
1077
1087
  supervisor.register(new KeychainReapService());
1078
1088
  else
@@ -1,5 +1,5 @@
1
1
  /** Daemon heartbeat and routine-process reconciliation under supervision. */
2
- import { monitorRunningJobs } from './runner.js';
2
+ import { reapExitedRunningJobs } from './runner.js';
3
3
  import { reapTerminalRoutineProcesses } from '../routine-process-cleanup.js';
4
4
  import { BasePeriodicService } from './service.js';
5
5
  const HEARTBEAT_TICK_MS = 60_000;
@@ -17,8 +17,8 @@ export class HeartbeatService extends BasePeriodicService {
17
17
  async onStop() { }
18
18
  async onTick(ctx) {
19
19
  this.publishHeartbeat();
20
- monitorRunningJobs();
21
- const reaped = reapTerminalRoutineProcesses();
20
+ await reapExitedRunningJobs();
21
+ const reaped = await reapTerminalRoutineProcesses();
22
22
  if (reaped.length > 0) {
23
23
  ctx.log('WARN', `Reaped ${reaped.length} terminal routine process group(s): ${reaped.join(', ')}`);
24
24
  }
@@ -22,7 +22,7 @@ export class KeychainReapService extends BasePeriodicService {
22
22
  }
23
23
  async onTick(ctx) {
24
24
  const { reapOrphanedKeychainProcesses } = await import('../secrets/reaper.js');
25
- const result = reapOrphanedKeychainProcesses();
25
+ const result = await reapOrphanedKeychainProcesses();
26
26
  if (result.reaped > 0) {
27
27
  ctx.log('WARN', `Reaped ${result.reaped} keychain orphan/stuck process(es)`);
28
28
  for (const d of result.details)
@@ -251,6 +251,23 @@ export declare function listLiveRoutineChildren(): number[];
251
251
  * remote `.exit` is reconciled (`finalizeHostRun`).
252
252
  */
253
253
  export declare function isRunGenuinelyInFlight(meta: RunMeta): boolean;
254
- /** Scan all runs marked "running" and finalize any whose process has exited. */
254
+ /**
255
+ * Scan all runs marked "running" and finalize any whose process has exited.
256
+ *
257
+ * SYNCHRONOUS — for OFF-loop callers only (the `agents routines list/status`
258
+ * builders and CLI best-effort orphan reaps), which run in short-lived CLI
259
+ * processes where a bounded synchronous `ps`/scan cannot starve a served event
260
+ * loop. The daemon's own heartbeat tick MUST use {@link reapExitedRunningJobs}
261
+ * instead — a sync scan there would freeze the shared event loop (PHNX-3695).
262
+ */
255
263
  export declare function monitorRunningJobs(): void;
264
+ /**
265
+ * Async, non-blocking twin of {@link monitorRunningJobs} for the daemon's
266
+ * heartbeat tick (PHNX-3695). Identical reconciliation via
267
+ * {@link reconcileRunningRecord}, but the directory walk uses `fs/promises` and
268
+ * the pid-identity probe uses the deadline-bounded {@link isPidOursAsync}, so
269
+ * the every-tick scan never freezes the shared event loop — the freeze that
270
+ * left the browser IPC `version` probe unanswered.
271
+ */
272
+ export declare function reapExitedRunningJobs(): Promise<void>;
256
273
  export {};