@phnx-labs/agents-cli 1.22.53 → 1.22.55

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 (166) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +59 -9
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +219 -173
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +10 -8
  16. package/dist/commands/focus.d.ts +1 -0
  17. package/dist/commands/focus.js +2 -2
  18. package/dist/commands/go.d.ts +5 -4
  19. package/dist/commands/go.js +7 -7
  20. package/dist/commands/insights.js +9 -0
  21. package/dist/commands/monitors.js +85 -30
  22. package/dist/commands/output.js +8 -2
  23. package/dist/commands/repo.js +18 -0
  24. package/dist/commands/routines.js +31 -2
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions.d.ts +20 -12
  27. package/dist/commands/sessions.js +64 -20
  28. package/dist/commands/setup-accounts.d.ts +8 -0
  29. package/dist/commands/setup-accounts.js +47 -0
  30. package/dist/commands/setup.d.ts +1 -1
  31. package/dist/commands/setup.js +11 -2
  32. package/dist/commands/share.d.ts +79 -3
  33. package/dist/commands/share.js +347 -18
  34. package/dist/commands/ssh.d.ts +7 -0
  35. package/dist/commands/ssh.js +18 -2
  36. package/dist/commands/status.js +14 -0
  37. package/dist/commands/view.d.ts +11 -1
  38. package/dist/commands/view.js +35 -7
  39. package/dist/lib/account-registry.js +15 -3
  40. package/dist/lib/accounting/rotate.d.ts +20 -6
  41. package/dist/lib/accounting/rotate.js +38 -7
  42. package/dist/lib/accounting/usage.d.ts +68 -1
  43. package/dist/lib/accounting/usage.js +116 -10
  44. package/dist/lib/agent-spec/agents.d.ts +5 -2
  45. package/dist/lib/agent-spec/agents.js +25 -7
  46. package/dist/lib/analytics/mix-commands.js +12 -6
  47. package/dist/lib/auth-mint.d.ts +150 -0
  48. package/dist/lib/auth-mint.js +434 -0
  49. package/dist/lib/browser/cdp.d.ts +1 -1
  50. package/dist/lib/browser/cdp.js +1 -1
  51. package/dist/lib/browser/ffmpeg.d.ts +12 -0
  52. package/dist/lib/browser/ffmpeg.js +184 -0
  53. package/dist/lib/browser/remote-control.d.ts +9 -7
  54. package/dist/lib/browser/remote-control.js +9 -7
  55. package/dist/lib/browser/service.js +119 -25
  56. package/dist/lib/claude-account-token.d.ts +10 -0
  57. package/dist/lib/claude-account-token.js +14 -4
  58. package/dist/lib/config-drift.d.ts +37 -0
  59. package/dist/lib/config-drift.js +72 -0
  60. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  61. package/dist/lib/daemon/auth-sync-service.js +34 -0
  62. package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
  63. package/dist/lib/daemon/browser-task-reap-service.js +26 -0
  64. package/dist/lib/daemon/daemon.js +87 -176
  65. package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
  66. package/dist/lib/daemon/heartbeat-service.js +26 -0
  67. package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
  68. package/dist/lib/daemon/monitor-engine-service.js +15 -7
  69. package/dist/lib/daemon/runner.d.ts +15 -0
  70. package/dist/lib/daemon/runner.js +23 -0
  71. package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
  72. package/dist/lib/daemon/secrets-broker-service.js +17 -32
  73. package/dist/lib/daemon/service.d.ts +2 -2
  74. package/dist/lib/daemon/service.js +1 -1
  75. package/dist/lib/daemon/session-state-service.d.ts +21 -0
  76. package/dist/lib/daemon/session-state-service.js +34 -0
  77. package/dist/lib/daemon/supervisor.d.ts +17 -7
  78. package/dist/lib/daemon/supervisor.js +87 -14
  79. package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
  80. package/dist/lib/daemon/tmux-reap-service.js +28 -0
  81. package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
  82. package/dist/lib/daemon/webhook-receiver-service.js +17 -0
  83. package/dist/lib/daemon-services.d.ts +1 -1
  84. package/dist/lib/daemon-services.js +25 -0
  85. package/dist/lib/device-config.d.ts +3 -3
  86. package/dist/lib/device-config.js +5 -5
  87. package/dist/lib/devices/connect.d.ts +26 -0
  88. package/dist/lib/devices/connect.js +48 -1
  89. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  90. package/dist/lib/devices/doctor-findings.js +19 -1
  91. package/dist/lib/devices/harness-inventory.js +5 -2
  92. package/dist/lib/exec.d.ts +28 -0
  93. package/dist/lib/exec.js +73 -7
  94. package/dist/lib/feed/feed.d.ts +1 -1
  95. package/dist/lib/feed/feed.js +23 -1
  96. package/dist/lib/feed-broadcast.js +1 -1
  97. package/dist/lib/fleet/apply.d.ts +11 -0
  98. package/dist/lib/fleet/apply.js +23 -3
  99. package/dist/lib/fleet/auth-sync.js +5 -3
  100. package/dist/lib/help.d.ts +9 -0
  101. package/dist/lib/help.js +29 -1
  102. package/dist/lib/hosts/passthrough.d.ts +1 -10
  103. package/dist/lib/hosts/passthrough.js +1 -13
  104. package/dist/lib/installations/versions.js +9 -1
  105. package/dist/lib/linux-userns.d.ts +58 -0
  106. package/dist/lib/linux-userns.js +116 -0
  107. package/dist/lib/memory.d.ts +26 -0
  108. package/dist/lib/memory.js +80 -1
  109. package/dist/lib/monitors/config.d.ts +11 -0
  110. package/dist/lib/monitors/config.js +8 -0
  111. package/dist/lib/monitors/engine.d.ts +5 -1
  112. package/dist/lib/monitors/engine.js +13 -4
  113. package/dist/lib/monitors/state.d.ts +37 -1
  114. package/dist/lib/monitors/state.js +79 -4
  115. package/dist/lib/permissions-registry.d.ts +2 -0
  116. package/dist/lib/permissions-registry.js +116 -14
  117. package/dist/lib/permissions.d.ts +5 -3
  118. package/dist/lib/permissions.js +25 -27
  119. package/dist/lib/profiles.d.ts +8 -7
  120. package/dist/lib/profiles.js +12 -0
  121. package/dist/lib/project-key.d.ts +9 -0
  122. package/dist/lib/project-key.js +11 -0
  123. package/dist/lib/scheduling/routines.d.ts +47 -0
  124. package/dist/lib/scheduling/routines.js +70 -1
  125. package/dist/lib/secrets/bundles.d.ts +35 -0
  126. package/dist/lib/secrets/bundles.js +78 -1
  127. package/dist/lib/secrets/push.d.ts +3 -8
  128. package/dist/lib/secrets/push.js +18 -14
  129. package/dist/lib/secrets/remote.d.ts +9 -18
  130. package/dist/lib/secrets/remote.js +11 -26
  131. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  132. package/dist/lib/secrets/reserved-sync.js +129 -0
  133. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  134. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  135. package/dist/lib/self-heal/registry.js +4 -0
  136. package/dist/lib/self-heal/types.d.ts +1 -1
  137. package/dist/lib/session/active.js +1 -4
  138. package/dist/lib/session/db.d.ts +41 -5
  139. package/dist/lib/session/db.js +132 -30
  140. package/dist/lib/session/discover.d.ts +32 -4
  141. package/dist/lib/session/discover.js +119 -25
  142. package/dist/lib/session/insights.d.ts +14 -0
  143. package/dist/lib/session/insights.js +25 -2
  144. package/dist/lib/session/linear.js +1 -1
  145. package/dist/lib/session/shell-programs.d.ts +17 -0
  146. package/dist/lib/session/shell-programs.js +21 -0
  147. package/dist/lib/session/state.js +2 -1
  148. package/dist/lib/session/stream-render.js +2 -1
  149. package/dist/lib/session/tool-calls.js +2 -5
  150. package/dist/lib/session/trajectory-html.js +2 -1
  151. package/dist/lib/session/trajectory.js +3 -12
  152. package/dist/lib/session/types.d.ts +8 -0
  153. package/dist/lib/share/capture.js +11 -2
  154. package/dist/lib/share/publish.d.ts +56 -5
  155. package/dist/lib/share/publish.js +126 -18
  156. package/dist/lib/share/worker-template.d.ts +3 -12
  157. package/dist/lib/share/worker-template.js +860 -59
  158. package/dist/lib/startup/root-command.js +2 -1
  159. package/dist/lib/state.d.ts +16 -0
  160. package/dist/lib/state.js +178 -46
  161. package/dist/lib/sync-status.d.ts +4 -0
  162. package/dist/lib/sync-status.js +3 -0
  163. package/dist/lib/traces/classify.js +24 -19
  164. package/dist/lib/usage-refresh.js +2 -1
  165. package/dist/lib/view-types.d.ts +7 -0
  166. package/package.json +9 -2
@@ -8,18 +8,19 @@
8
8
  * over hosting whenever the daemon is NOT already hosting AND no healthy
9
9
  * standalone broker answers a ping.
10
10
  */
11
- import { BaseDaemonService } from './service.js';
11
+ import { BasePeriodicService } from './service.js';
12
12
  /** Matches the constant previously in daemon.ts. */
13
13
  const BROKER_SELF_HEAL_TICK_MS = 60_000;
14
+ const BROKER_SELF_HEAL_DEADLINE_MS = 30_000;
14
15
  /** Take over only when we are not already hosting AND no broker is reachable. */
15
16
  function shouldTakeOver(isHosting, brokerReachable) {
16
17
  return !isHosting && !brokerReachable;
17
18
  }
18
- export class SecretsBrokerService extends BaseDaemonService {
19
+ export class SecretsBrokerService extends BasePeriodicService {
19
20
  id = 'secrets-broker';
21
+ intervalMs = BROKER_SELF_HEAL_TICK_MS;
22
+ deadlineMs = BROKER_SELF_HEAL_DEADLINE_MS;
20
23
  hostedBroker = null;
21
- selfHealTimer;
22
- selfHealInFlight = false;
23
24
  async onStart(ctx) {
24
25
  const { agentPing, startHostedBroker } = await import('../secrets/agent.js');
25
26
  if ((await agentPing()).reachable) {
@@ -30,37 +31,21 @@ export class SecretsBrokerService extends BaseDaemonService {
30
31
  if (this.hostedBroker)
31
32
  ctx.log('INFO', 'Secrets broker hosted in daemon (socket-first)');
32
33
  }
33
- // RUSH-1817: if the standalone the daemon deferred to at start later dies,
34
- // take over hosting on the next self-heal probe.
35
- const runSelfHeal = async () => {
36
- if (this.selfHealInFlight)
37
- return;
38
- this.selfHealInFlight = true;
39
- try {
40
- const { agentPing: ping, startHostedBroker: startBroker } = await import('../secrets/agent.js');
41
- const reachable = (await ping()).reachable;
42
- if (!shouldTakeOver(this.hostedBroker != null, reachable))
43
- return;
44
- this.hostedBroker = await startBroker();
45
- if (this.hostedBroker) {
46
- ctx.log('WARN', 'Secrets broker was unreachable; daemon took over hosting (self-heal)');
47
- }
48
- }
49
- catch (err) {
50
- ctx.log('WARN', `Secrets broker self-heal skipped: ${err.message}`);
51
- }
52
- finally {
53
- this.selfHealInFlight = false;
54
- }
55
- };
56
- this.selfHealTimer = setInterval(() => { void runSelfHeal(); }, BROKER_SELF_HEAL_TICK_MS);
57
34
  }
58
35
  async onStop() {
59
- if (this.selfHealTimer !== undefined) {
60
- clearInterval(this.selfHealTimer);
61
- this.selfHealTimer = undefined;
62
- }
63
36
  this.hostedBroker?.close();
64
37
  this.hostedBroker = null;
65
38
  }
39
+ async onTick(ctx) {
40
+ // RUSH-1817: if the standalone the daemon deferred to at start later dies,
41
+ // take over hosting on the next supervisor-owned self-heal probe.
42
+ const { agentPing, startHostedBroker } = await import('../secrets/agent.js');
43
+ const reachable = (await agentPing()).reachable;
44
+ if (!shouldTakeOver(this.hostedBroker != null, reachable))
45
+ return;
46
+ this.hostedBroker = await startHostedBroker();
47
+ if (this.hostedBroker) {
48
+ ctx.log('WARN', 'Secrets broker was unreachable; daemon took over hosting (self-heal)');
49
+ }
50
+ }
66
51
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * DaemonService contract (RUSH-3193 P1).
3
3
  *
4
- * Today the daemon's ~13 background services are bare `setInterval` closures
4
+ * Historically the daemon's background services were bare `setInterval` closures
5
5
  * inside one long `runDaemon()` (daemon.ts), sharing one event loop with no
6
6
  * per-service error boundary or deadline. A throw escaping a tick's local
7
7
  * try/catch hits the process-wide handler and kills every service; a tick that
@@ -41,7 +41,7 @@ export interface DaemonService {
41
41
  /** A service the supervisor ticks on a fixed interval, under a hard per-tick deadline. */
42
42
  export interface PeriodicService extends DaemonService {
43
43
  readonly intervalMs: number;
44
- /** Hard cap per tick. An over-budget tick is abandoned (never awaited past this) so its in-flight guard always releases. */
44
+ /** Hard cap per tick. An over-budget tick parks the service; its in-flight guard remains held until the real promise settles. */
45
45
  readonly deadlineMs: number;
46
46
  /**
47
47
  * Delay before the FIRST tick after `start()`, in ms. Every later tick
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * DaemonService contract (RUSH-3193 P1).
3
3
  *
4
- * Today the daemon's ~13 background services are bare `setInterval` closures
4
+ * Historically the daemon's background services were bare `setInterval` closures
5
5
  * inside one long `runDaemon()` (daemon.ts), sharing one event loop with no
6
6
  * per-service error boundary or deadline. A throw escaping a tick's local
7
7
  * try/catch hits the process-wide handler and kills every service; a tick that
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Live-session metadata publisher as a supervised periodic service.
3
+ *
4
+ * This is the single daemon-owned writer behind `sessions watch`: consumers
5
+ * announce reader presence, and the service publishes an incremental journal
6
+ * snapshot immediately on the idle->active edge and every 15 seconds while a
7
+ * reader remains. Callers consume the row; they do not run their own gather.
8
+ */
9
+ import type { DaemonServiceId } from '../daemon-services.js';
10
+ import { BasePeriodicService, type DaemonContext } from './service.js';
11
+ export declare class SessionStateService extends BasePeriodicService {
12
+ private readonly wake;
13
+ readonly id: DaemonServiceId;
14
+ readonly intervalMs = 15000;
15
+ readonly deadlineMs = 30000;
16
+ private stopReaderWatch;
17
+ constructor(wake: () => void);
18
+ protected onStart(_ctx: DaemonContext): Promise<void>;
19
+ protected onStop(): Promise<void>;
20
+ protected onTick(_ctx: DaemonContext): Promise<void>;
21
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Live-session metadata publisher as a supervised periodic service.
3
+ *
4
+ * This is the single daemon-owned writer behind `sessions watch`: consumers
5
+ * announce reader presence, and the service publishes an incremental journal
6
+ * snapshot immediately on the idle->active edge and every 15 seconds while a
7
+ * reader remains. Callers consume the row; they do not run their own gather.
8
+ */
9
+ import { runActiveSessionsWarmTick } from '../daemon-ticks.js';
10
+ import { watchActiveSessionsReaderPresence } from '../session/session-cache.js';
11
+ import { BasePeriodicService } from './service.js';
12
+ const SESSION_STATE_TICK_MS = 15_000;
13
+ const SESSION_STATE_DEADLINE_MS = 30_000;
14
+ export class SessionStateService extends BasePeriodicService {
15
+ wake;
16
+ id = 'session-state';
17
+ intervalMs = SESSION_STATE_TICK_MS;
18
+ deadlineMs = SESSION_STATE_DEADLINE_MS;
19
+ stopReaderWatch = null;
20
+ constructor(wake) {
21
+ super();
22
+ this.wake = wake;
23
+ }
24
+ async onStart(_ctx) {
25
+ this.stopReaderWatch = watchActiveSessionsReaderPresence(this.wake);
26
+ }
27
+ async onStop() {
28
+ this.stopReaderWatch?.();
29
+ this.stopReaderWatch = null;
30
+ }
31
+ async onTick(_ctx) {
32
+ await runActiveSessionsWarmTick();
33
+ }
34
+ }
@@ -12,9 +12,9 @@
12
12
  * - A tick that hangs on an unbounded await (SSH, keychain) used to latch its
13
13
  * local in-flight guard `true` forever, silently freezing that one service
14
14
  * for the daemon's life (observed ~51h). Here, every tick races a
15
- * `deadlineMs` timeout; when the deadline wins, the guard is released in
16
- * `finally` regardless of whether the real tick promise ever settles, so
17
- * the NEXT scheduled tick can still run.
15
+ * `deadlineMs` timeout; when the deadline wins, that service is parked and
16
+ * its in-flight guard stays held until the real promise settles. This keeps
17
+ * a timed-out tick from overlapping another tick or lifecycle transition.
18
18
  *
19
19
  * Repeated failures (thrown or timed-out) open a circuit breaker: the service
20
20
  * is parked (its timer stopped) and retried on exponential backoff via its own
@@ -24,7 +24,7 @@
24
24
  import type { DaemonServiceId } from '../daemon-services.js';
25
25
  import type { DaemonContext, DaemonService, ServiceHealth } from './service.js';
26
26
  export interface ServiceSupervisorOptions {
27
- /** Consecutive tick failures (throw or deadline breach) before a service is parked. Default 3. */
27
+ /** Consecutive thrown tick failures before a service is parked. Deadline breaches park immediately. Default 3. */
28
28
  parkAfterFailures?: number;
29
29
  /** First restart backoff delay, doubled on each further failed restart attempt. Default 5s. */
30
30
  backoffBaseMs?: number;
@@ -57,6 +57,12 @@ export declare class ServiceSupervisor {
57
57
  isRegistered(id: DaemonServiceId): boolean;
58
58
  /** Every currently registered service id. */
59
59
  registeredIds(): DaemonServiceId[];
60
+ /**
61
+ * Resolve when the service's current tick has really settled. Daemon control
62
+ * edges use this to queue a requested live transition without polling or
63
+ * treating a deadline race as cancellation.
64
+ */
65
+ awaitIdle(id: DaemonServiceId): Promise<void>;
60
66
  /**
61
67
  * Start one registered service live — drives `agents daemon services enable
62
68
  * <id>` (RUSH-3193 P4). Only affects a service already registered on this
@@ -67,6 +73,8 @@ export declare class ServiceSupervisor {
67
73
  start(id: DaemonServiceId): Promise<void>;
68
74
  /** Stop one registered service live — drives `agents daemon services disable <id>` (RUSH-3193 P4). */
69
75
  stop(id: DaemonServiceId): Promise<void>;
76
+ /** Request an immediate supervised tick, used by event edges that cannot wait for the regular cadence. */
77
+ runNow(id: DaemonServiceId): void;
70
78
  /** Health for every registered service, keyed by service id. */
71
79
  health(): Record<string, ServiceHealth>;
72
80
  private startOne;
@@ -75,13 +83,15 @@ export declare class ServiceSupervisor {
75
83
  /**
76
84
  * Run one tick under a hard deadline. The deadline is enforced with
77
85
  * `Promise.race`, not true cancellation — JS cannot abort an arbitrary
78
- * in-flight await. What matters for the freeze this replaces is that
79
- * `inFlight` is released in `finally` as soon as the race settles, so an
80
- * abandoned tick can never block the next scheduled one again.
86
+ * in-flight await. A timeout parks the service immediately but deliberately
87
+ * keeps `inFlight` set until the REAL promise settles. Starting another tick
88
+ * or tearing resources down underneath the abandoned one would trade a
89
+ * visible parked service for duplicated side effects and lifecycle races.
81
90
  */
82
91
  private runTick;
83
92
  private park;
84
93
  private scheduleRestart;
85
94
  private attemptRestart;
86
95
  private recordFailure;
96
+ private finishTick;
87
97
  }
@@ -12,9 +12,9 @@
12
12
  * - A tick that hangs on an unbounded await (SSH, keychain) used to latch its
13
13
  * local in-flight guard `true` forever, silently freezing that one service
14
14
  * for the daemon's life (observed ~51h). Here, every tick races a
15
- * `deadlineMs` timeout; when the deadline wins, the guard is released in
16
- * `finally` regardless of whether the real tick promise ever settles, so
17
- * the NEXT scheduled tick can still run.
15
+ * `deadlineMs` timeout; when the deadline wins, that service is parked and
16
+ * its in-flight guard stays held until the real promise settles. This keeps
17
+ * a timed-out tick from overlapping another tick or lifecycle transition.
18
18
  *
19
19
  * Repeated failures (thrown or timed-out) open a circuit breaker: the service
20
20
  * is parked (its timer stopped) and retried on exponential backoff via its own
@@ -67,17 +67,35 @@ export class ServiceSupervisor {
67
67
  /** Stop every registered service and clear all timers. */
68
68
  async stopAll() {
69
69
  for (const id of this.registry.keys())
70
- await this.stopOne(id);
70
+ await this.stopOne(id, true);
71
71
  }
72
72
  /** Force one service to restart right now, outside its normal backoff schedule. Drives `agents daemon services restart <id>` (RUSH-3193 P4). */
73
73
  async restartOne(id) {
74
74
  const entry = this.registry.get(id);
75
75
  if (!entry)
76
76
  throw new Error(`service '${id}' is not registered`);
77
+ if (entry.inFlight) {
78
+ throw new Error(`service '${id}' cannot restart while a tick is still in flight`);
79
+ }
80
+ // A live periodic service already owns an interval. attemptRestart() installs
81
+ // a fresh one after restart, so leaving the existing handles alive would
82
+ // multiply the tick rate on every operator-requested restart.
83
+ if (entry.timer) {
84
+ clearInterval(entry.timer);
85
+ entry.timer = undefined;
86
+ }
87
+ if (entry.startupTimer) {
88
+ clearTimeout(entry.startupTimer);
89
+ entry.startupTimer = undefined;
90
+ }
77
91
  if (entry.restartTimer) {
78
92
  clearTimeout(entry.restartTimer);
79
93
  entry.restartTimer = undefined;
80
94
  }
95
+ // Block a timer callback already queued in this event-loop turn while the
96
+ // service tears down and starts again.
97
+ entry.state = 'parked';
98
+ recordSubsystemState(id, 'parked');
81
99
  await this.attemptRestart(id);
82
100
  }
83
101
  /** Whether `id` is registered on this supervisor. A service disabled at daemon boot is never registered — see {@link start}. */
@@ -88,6 +106,20 @@ export class ServiceSupervisor {
88
106
  registeredIds() {
89
107
  return Array.from(this.registry.keys());
90
108
  }
109
+ /**
110
+ * Resolve when the service's current tick has really settled. Daemon control
111
+ * edges use this to queue a requested live transition without polling or
112
+ * treating a deadline race as cancellation.
113
+ */
114
+ async awaitIdle(id) {
115
+ const entry = this.registry.get(id);
116
+ if (!entry)
117
+ throw new Error(`service '${id}' is not registered`);
118
+ const activeTick = entry.activeTick;
119
+ if (!activeTick)
120
+ return;
121
+ await activeTick.catch(() => undefined);
122
+ }
91
123
  /**
92
124
  * Start one registered service live — drives `agents daemon services enable
93
125
  * <id>` (RUSH-3193 P4). Only affects a service already registered on this
@@ -101,6 +133,9 @@ export class ServiceSupervisor {
101
133
  throw new Error(`service '${id}' is not registered`);
102
134
  if (entry.state === 'running')
103
135
  return;
136
+ if (entry.inFlight) {
137
+ throw new Error(`service '${id}' cannot start while a tick is still in flight`);
138
+ }
104
139
  await this.startOne(id);
105
140
  }
106
141
  /** Stop one registered service live — drives `agents daemon services disable <id>` (RUSH-3193 P4). */
@@ -110,7 +145,13 @@ export class ServiceSupervisor {
110
145
  throw new Error(`service '${id}' is not registered`);
111
146
  if (entry.state === 'stopped')
112
147
  return;
113
- await this.stopOne(id);
148
+ await this.stopOne(id, false);
149
+ }
150
+ /** Request an immediate supervised tick, used by event edges that cannot wait for the regular cadence. */
151
+ runNow(id) {
152
+ if (!this.registry.has(id))
153
+ throw new Error(`service '${id}' is not registered`);
154
+ void this.runTick(id);
114
155
  }
115
156
  /** Health for every registered service, keyed by service id. */
116
157
  health() {
@@ -164,10 +205,13 @@ export class ServiceSupervisor {
164
205
  recordSubsystemOk(id);
165
206
  }
166
207
  }
167
- async stopOne(id) {
208
+ async stopOne(id, force) {
168
209
  const entry = this.registry.get(id);
169
210
  if (!entry)
170
211
  return;
212
+ if (entry.inFlight && !force) {
213
+ throw new Error(`service '${id}' cannot stop while a tick is still in flight`);
214
+ }
171
215
  if (entry.timer) {
172
216
  clearInterval(entry.timer);
173
217
  entry.timer = undefined;
@@ -182,6 +226,11 @@ export class ServiceSupervisor {
182
226
  }
183
227
  entry.state = 'stopped';
184
228
  recordSubsystemState(id, 'stopped');
229
+ // Whole-daemon shutdown must not hang forever on an unresolved tick, but it
230
+ // also must not tear down resources the tick may still be using. Stop its
231
+ // timers and mark it stopped; process exit owns final cleanup in this case.
232
+ if (entry.inFlight && force)
233
+ return;
185
234
  if (!entry.everStarted)
186
235
  return; // start() never succeeded — nothing to stop.
187
236
  try {
@@ -200,9 +249,10 @@ export class ServiceSupervisor {
200
249
  /**
201
250
  * Run one tick under a hard deadline. The deadline is enforced with
202
251
  * `Promise.race`, not true cancellation — JS cannot abort an arbitrary
203
- * in-flight await. What matters for the freeze this replaces is that
204
- * `inFlight` is released in `finally` as soon as the race settles, so an
205
- * abandoned tick can never block the next scheduled one again.
252
+ * in-flight await. A timeout parks the service immediately but deliberately
253
+ * keeps `inFlight` set until the REAL promise settles. Starting another tick
254
+ * or tearing resources down underneath the abandoned one would trade a
255
+ * visible parked service for duplicated side effects and lifecycle races.
206
256
  */
207
257
  async runTick(id) {
208
258
  const entry = this.registry.get(id);
@@ -216,11 +266,17 @@ export class ServiceSupervisor {
216
266
  const periodicService = entry.service;
217
267
  entry.inFlight = true;
218
268
  let deadlineTimer;
269
+ let timedOut = false;
270
+ const tickPromise = periodicService.tick(ctx);
271
+ entry.activeTick = tickPromise;
219
272
  try {
220
273
  const deadline = new Promise((_, reject) => {
221
- deadlineTimer = setTimeout(() => reject(new Error(`tick exceeded deadline of ${periodicService.deadlineMs}ms`)), periodicService.deadlineMs);
274
+ deadlineTimer = setTimeout(() => {
275
+ timedOut = true;
276
+ reject(new Error(`tick exceeded deadline of ${periodicService.deadlineMs}ms`));
277
+ }, periodicService.deadlineMs);
222
278
  });
223
- await Promise.race([periodicService.tick(ctx), deadline]);
279
+ await Promise.race([tickPromise, deadline]);
224
280
  entry.consecutiveFailures = 0;
225
281
  entry.lastError = undefined;
226
282
  entry.lastRunMs = Date.now();
@@ -228,13 +284,20 @@ export class ServiceSupervisor {
228
284
  }
229
285
  catch (err) {
230
286
  this.recordFailure(entry, id, err);
231
- if (entry.consecutiveFailures >= this.parkAfterFailures)
287
+ if (timedOut || entry.consecutiveFailures >= this.parkAfterFailures)
232
288
  this.park(id);
233
289
  }
234
290
  finally {
235
291
  if (deadlineTimer)
236
292
  clearTimeout(deadlineTimer);
237
- entry.inFlight = false;
293
+ if (timedOut) {
294
+ // Promise.race cannot cancel arbitrary work. Keep the service parked and
295
+ // in-flight until the REAL tick settles; only then may backoff restart it.
296
+ void tickPromise.then(() => this.finishTick(id, tickPromise), () => this.finishTick(id, tickPromise));
297
+ }
298
+ else {
299
+ this.finishTick(id, tickPromise);
300
+ }
238
301
  }
239
302
  }
240
303
  park(id) {
@@ -248,7 +311,8 @@ export class ServiceSupervisor {
248
311
  entry.timer = undefined;
249
312
  }
250
313
  this.ctx?.log('WARN', `service '${id}' parked after ${entry.consecutiveFailures} consecutive failure(s)`);
251
- this.scheduleRestart(id);
314
+ if (!entry.inFlight)
315
+ this.scheduleRestart(id);
252
316
  }
253
317
  scheduleRestart(id) {
254
318
  const entry = this.registry.get(id);
@@ -295,4 +359,13 @@ export class ServiceSupervisor {
295
359
  recordSubsystemError(id, message);
296
360
  this.ctx?.log('WARN', `service '${id}' failed: ${message}`);
297
361
  }
362
+ finishTick(id, tickPromise) {
363
+ const entry = this.registry.get(id);
364
+ if (!entry || entry.activeTick !== tickPromise)
365
+ return;
366
+ entry.activeTick = undefined;
367
+ entry.inFlight = false;
368
+ if (entry.state === 'parked' && !entry.restartTimer)
369
+ this.scheduleRestart(id);
370
+ }
298
371
  }
@@ -0,0 +1,11 @@
1
+ /** Dead managed tmux session and orphan-helper cleanup under supervision. */
2
+ import type { DaemonServiceId } from '../daemon-services.js';
3
+ import { BasePeriodicService, type DaemonContext } from './service.js';
4
+ export declare class TmuxReapService extends BasePeriodicService {
5
+ readonly id: DaemonServiceId;
6
+ readonly intervalMs: number;
7
+ readonly deadlineMs: number;
8
+ protected onStart(_ctx: DaemonContext): Promise<void>;
9
+ protected onStop(): Promise<void>;
10
+ protected onTick(ctx: DaemonContext): Promise<void>;
11
+ }
@@ -0,0 +1,28 @@
1
+ /** Dead managed tmux session and orphan-helper cleanup under supervision. */
2
+ import { getDefaultSocketPath } from '../tmux/paths.js';
3
+ import { reapDeadTmuxPanes } from '../tmux/session.js';
4
+ import { BasePeriodicService } from './service.js';
5
+ const TMUX_REAP_TICK_MS = 5 * 60_000;
6
+ const TMUX_REAP_DEADLINE_MS = 2 * 60_000;
7
+ export class TmuxReapService extends BasePeriodicService {
8
+ id = 'tmux-reap';
9
+ intervalMs = TMUX_REAP_TICK_MS;
10
+ deadlineMs = TMUX_REAP_DEADLINE_MS;
11
+ async onStart(_ctx) { }
12
+ async onStop() { }
13
+ async onTick(ctx) {
14
+ const result = await reapDeadTmuxPanes(getDefaultSocketPath());
15
+ for (const warning of result.warnings)
16
+ ctx.log('WARN', `Dead-pane reaper: ${warning}`);
17
+ if (result.processes > 0) {
18
+ ctx.log('INFO', `Dead-pane reaper: terminated ${result.processes} orphaned helper process(es)`);
19
+ for (const detail of result.processDetails)
20
+ ctx.log('INFO', ` ${detail}`);
21
+ }
22
+ if (result.reaped > 0) {
23
+ ctx.log('INFO', `Dead-pane reaper: reaped ${result.reaped} session(s)`);
24
+ for (const detail of result.details)
25
+ ctx.log('INFO', ` killed ${detail}`);
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,9 @@
1
+ /** Signed webhook receiver lifecycle under the daemon service supervisor. */
2
+ import type { DaemonServiceId } from '../daemon-services.js';
3
+ import { BaseDaemonService, type DaemonContext } from './service.js';
4
+ export declare class WebhookReceiverService extends BaseDaemonService {
5
+ readonly id: DaemonServiceId;
6
+ private receivers;
7
+ protected onStart(ctx: DaemonContext): Promise<void>;
8
+ protected onStop(): Promise<void>;
9
+ }
@@ -0,0 +1,17 @@
1
+ /** Signed webhook receiver lifecycle under the daemon service supervisor. */
2
+ import { startHostedWebhookReceivers } from '../daemon-webhooks.js';
3
+ import { BaseDaemonService } from './service.js';
4
+ export class WebhookReceiverService extends BaseDaemonService {
5
+ id = 'webhook-receiver';
6
+ receivers = null;
7
+ async onStart(ctx) {
8
+ this.receivers = await startHostedWebhookReceivers({ log: ctx.log });
9
+ ctx.log('INFO', this.receivers.count > 0
10
+ ? `Webhook receiver hosting ${this.receivers.count} receiver(s)`
11
+ : 'Webhook receiver service enabled; no receivers declared in daemon/webhooks.yaml');
12
+ }
13
+ async onStop() {
14
+ await this.receivers?.close();
15
+ this.receivers = null;
16
+ }
17
+ }
@@ -7,7 +7,7 @@
7
7
  * enabled without pulling in the whole daemon lifecycle.
8
8
  */
9
9
  /** Every service the daemon can host. IDs are kebab-case and stable. */
10
- export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'keychain-reap' | 'account-state' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index';
10
+ export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'keychain-reap' | 'account-state' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state';
11
11
  /** Human-readable metadata for each service. */
12
12
  export interface DaemonServiceDef {
13
13
  id: DaemonServiceId;
@@ -67,11 +67,36 @@ export const DAEMON_SERVICES = [
67
67
  title: 'State-dir self-check',
68
68
  description: 'Self-terminates the daemon if its state directory is removed.',
69
69
  },
70
+ {
71
+ id: 'daemon-heartbeat',
72
+ title: 'Daemon heartbeat',
73
+ description: 'Publishes daemon liveness and reconciles routine process state.',
74
+ },
75
+ {
76
+ id: 'tmux-reap',
77
+ title: 'Tmux reap',
78
+ description: 'Reaps dead managed tmux sessions and their orphaned helper processes.',
79
+ },
80
+ {
81
+ id: 'browser-task-reap',
82
+ title: 'Browser task reap',
83
+ description: 'Closes abandoned browser-task tabs whose owner exited or idle lease expired.',
84
+ },
85
+ {
86
+ id: 'session-state',
87
+ title: 'Live session state',
88
+ description: 'Publishes this host\'s active session metadata for sessions watch and fleet consumers.',
89
+ },
70
90
  {
71
91
  id: 'session-index',
72
92
  title: 'Session-index warm',
73
93
  description: 'Keeps this host\'s transcript index current so a locally-started session is discoverable within seconds.',
74
94
  },
95
+ {
96
+ id: 'auth-sync',
97
+ title: 'Auth bundle sync',
98
+ description: 'Pushes the reserved file-backed auth bundle (setup-tokens) to pinned fleet devices that do not yet have it.',
99
+ },
75
100
  ];
76
101
  /** Stable order of service IDs. */
77
102
  export const DAEMON_SERVICE_IDS = DAEMON_SERVICES.map((s) => s.id);
@@ -223,9 +223,9 @@ export declare function isDaemonEnabled(): boolean;
223
223
  /**
224
224
  * Throw when the daemon is disabled on this machine, naming the setting and
225
225
  * the fix. Every AUTO-start surface (routines add/start/catchup/webhook,
226
- * `ensureDaemonStarted`) refuses with this before calling `startDaemon()`.
227
- * `agents daemon start` is the deliberate override and does NOT call this —
228
- * disable only blocks auto-start, mirroring `systemctl disable`.
226
+ * monitors add, `ensureDaemonStarted`) refuses with this before calling
227
+ * `startDaemon()`. `agents daemon start` is the deliberate override and does
228
+ * NOT call this — disable only blocks auto-start, mirroring `systemctl disable`.
229
229
  */
230
230
  export declare function assertDaemonEnabled(): void;
231
231
  /**
@@ -145,7 +145,7 @@ export const CONFIG_KEYS = [
145
145
  defaultValue: true,
146
146
  description: 'Whether the daemon may run on this device at all (secrets broker, browser IPC, watchdog, and the ' +
147
147
  'routines scheduler). Disabling is the top-level kill switch: nothing auto-starts the daemon while it ' +
148
- 'is set, including `routines add`/`routines start`/`routines catchup`/webhook triggers. ' +
148
+ 'is set, including `routines add`/`routines start`/`routines catchup`/`monitors add`/webhook triggers. ' +
149
149
  '`agents daemon start` still starts it explicitly.',
150
150
  },
151
151
  {
@@ -176,7 +176,7 @@ export const CONFIG_KEYS = [
176
176
  visibility: 'machine',
177
177
  type: 'bool',
178
178
  defaultValue: false,
179
- description: "Whether other fleet machines may drive THIS device's browser over `browser --device <this-device>`. " +
179
+ description: "Whether other fleet machines may drive THIS device's browser over `browser --device <this-device>` or `agents ssh <this-device> …` (`agents browser` / `ag browser` / standalone `browser`). " +
180
180
  'Default off — a fleet-remote drive is refused until the owner runs `agents browser remote-control on`.',
181
181
  },
182
182
  {
@@ -813,9 +813,9 @@ export function isDaemonEnabled() {
813
813
  /**
814
814
  * Throw when the daemon is disabled on this machine, naming the setting and
815
815
  * the fix. Every AUTO-start surface (routines add/start/catchup/webhook,
816
- * `ensureDaemonStarted`) refuses with this before calling `startDaemon()`.
817
- * `agents daemon start` is the deliberate override and does NOT call this —
818
- * disable only blocks auto-start, mirroring `systemctl disable`.
816
+ * monitors add, `ensureDaemonStarted`) refuses with this before calling
817
+ * `startDaemon()`. `agents daemon start` is the deliberate override and does
818
+ * NOT call this — disable only blocks auto-start, mirroring `systemctl disable`.
819
819
  */
820
820
  export function assertDaemonEnabled() {
821
821
  if (isDaemonEnabled())
@@ -37,6 +37,26 @@ export declare function fleetDialTarget(device: DeviceProfile): string;
37
37
  * login).
38
38
  */
39
39
  export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]): string | undefined;
40
+ /**
41
+ * True when `cmd` is a browser drive: `agents browser …`, `ag browser …`, or
42
+ * the standalone `browser` binary (`cli/package.json` `bin.browser` →
43
+ * `src/browser.ts`), including the quoted single-string form
44
+ * `agents ssh box 'browser …'`. The `--device` passthrough stamps
45
+ * {@link markFleetRemote} on this shape so the far-side consent gate can fire;
46
+ * `agents ssh` must too (PHNX-3065).
47
+ */
48
+ export declare function isAgentsBrowserDrive(cmd: string[]): boolean;
49
+ /**
50
+ * Prefix a remote command so the far side sees `AGENTS_FLEET_REMOTE=1` — the
51
+ * marker the browser consent gate reads. `wrapRemoteCommand` joins the argv
52
+ * with spaces (POSIX) or base64-encodes it for PowerShell, so a
53
+ * shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
54
+ * `$env:VAR='1'; …` on PowerShell.
55
+ *
56
+ * Already-marked argv (the `--device` fan-out stamps this before
57
+ * {@link buildSshInvocation}) is left unchanged so the prefix is not doubled.
58
+ */
59
+ export declare function markFleetRemote(cmd: string[], device: Pick<DeviceProfile, 'shell'>): string[];
40
60
  /**
41
61
  * Build the remote command that starts an INTERACTIVE LOGIN shell inside a
42
62
  * mirrored project directory, falling back to the remote home when that
@@ -97,6 +117,12 @@ export declare function deviceIdentityArgs(device: DeviceProfile): string[];
97
117
  * to the remote home), matching `agents run --device`. It is ignored when a `cmd`
98
118
  * is given: an explicit command keeps its current cwd (the remote home) and its
99
119
  * behavior unchanged (RUSH-2412).
120
+ *
121
+ * An `agents browser …` / `ag browser …` / standalone `browser …` command is
122
+ * prefixed with {@link markFleetRemote} so the far-side consent gate sees
123
+ * `AGENTS_FLEET_REMOTE=1` the same way `browser --device` does. The local `env`
124
+ * overlay here is for the ssh *client* (askpass); it cannot carry the marker —
125
+ * OpenSSH does not forward arbitrary env (PHNX-3065).
100
126
  */
101
127
  export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions, opts?: {
102
128
  agentOnly?: boolean;