@phnx-labs/agents-cli 1.22.54 → 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 (51) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +19 -2
  3. package/dist/commands/routines.js +31 -2
  4. package/dist/commands/share.d.ts +27 -0
  5. package/dist/commands/share.js +85 -0
  6. package/dist/commands/view.d.ts +8 -0
  7. package/dist/commands/view.js +31 -4
  8. package/dist/lib/accounting/usage.d.ts +31 -0
  9. package/dist/lib/accounting/usage.js +45 -4
  10. package/dist/lib/browser/cdp.d.ts +1 -1
  11. package/dist/lib/browser/cdp.js +1 -1
  12. package/dist/lib/browser/ffmpeg.d.ts +12 -0
  13. package/dist/lib/browser/ffmpeg.js +184 -0
  14. package/dist/lib/browser/service.js +119 -25
  15. package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
  16. package/dist/lib/daemon/browser-task-reap-service.js +26 -0
  17. package/dist/lib/daemon/daemon.js +57 -172
  18. package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
  19. package/dist/lib/daemon/heartbeat-service.js +26 -0
  20. package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
  21. package/dist/lib/daemon/monitor-engine-service.js +15 -7
  22. package/dist/lib/daemon/runner.d.ts +15 -0
  23. package/dist/lib/daemon/runner.js +23 -0
  24. package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
  25. package/dist/lib/daemon/secrets-broker-service.js +17 -32
  26. package/dist/lib/daemon/service.d.ts +2 -2
  27. package/dist/lib/daemon/service.js +1 -1
  28. package/dist/lib/daemon/session-state-service.d.ts +21 -0
  29. package/dist/lib/daemon/session-state-service.js +34 -0
  30. package/dist/lib/daemon/supervisor.d.ts +17 -7
  31. package/dist/lib/daemon/supervisor.js +87 -14
  32. package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
  33. package/dist/lib/daemon/tmux-reap-service.js +28 -0
  34. package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
  35. package/dist/lib/daemon/webhook-receiver-service.js +17 -0
  36. package/dist/lib/daemon-services.d.ts +1 -1
  37. package/dist/lib/daemon-services.js +20 -0
  38. package/dist/lib/devices/harness-inventory.js +5 -2
  39. package/dist/lib/monitors/engine.d.ts +5 -1
  40. package/dist/lib/monitors/engine.js +5 -3
  41. package/dist/lib/scheduling/routines.d.ts +47 -0
  42. package/dist/lib/scheduling/routines.js +70 -1
  43. package/dist/lib/session/db.d.ts +2 -1
  44. package/dist/lib/session/db.js +2 -1
  45. package/dist/lib/share/capture.js +11 -2
  46. package/dist/lib/share/publish.d.ts +4 -1
  47. package/dist/lib/share/publish.js +27 -1
  48. package/dist/lib/share/worker-template.d.ts +3 -12
  49. package/dist/lib/share/worker-template.js +279 -3
  50. package/dist/lib/view-types.d.ts +6 -1
  51. package/package.json +8 -2
@@ -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' | 'auth-sync';
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,6 +67,26 @@ 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',
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import chalk from 'chalk';
17
17
  import { ALL_AGENT_IDS, accountDisplayLabel, getAccountInfo, } from '../agents.js';
18
- import { deriveUsageStatusFromSnapshot, getUsageInfoByIdentity, getUsageLookupKey, } from '../accounting/usage.js';
18
+ import { deriveUsageStatusFromSnapshot, getUsageInfoByIdentity, getUsageLookupKey, usageErrorForDisplay, } from '../accounting/usage.js';
19
19
  import { getVersionHomePath, listInstalledVersions } from '../installations/store.js';
20
20
  import { readMeta } from '../state.js';
21
21
  import { listNativeAccounts } from '../account-registry.js';
@@ -123,7 +123,10 @@ export async function collectLocalHarnessInventory(opts) {
123
123
  const key = getUsageLookupKey(info);
124
124
  const usage = key ? usageByKey.get(key) : undefined;
125
125
  const snapshot = usage?.snapshot ?? null;
126
- const quota = summarizeQuota(snapshot, usage?.error ?? null, info?.usageStatus ?? null);
126
+ // Normalize the internal 'stale' not-collected sentinel out before it reaches
127
+ // the JSON `quota.unavailableReason` — same leak PHNX-3348 fixed for
128
+ // `agents view --json`, here for `agents devices harnesses/accounts --json`.
129
+ const quota = summarizeQuota(snapshot, usageErrorForDisplay(usage?.error), info?.usageStatus ?? null);
127
130
  const signedIn = !!info?.signedIn;
128
131
  const { ready, reason } = computeReady(signedIn, quota);
129
132
  const display = info ? accountDisplayLabel(info) || null : null;
@@ -13,6 +13,8 @@
13
13
  */
14
14
  import { type MonitorConfig, type MonitorEvent } from './config.js';
15
15
  import { type Observation } from './sources/index.js';
16
+ /** How often the engine wakes to check which monitors are due. */
17
+ export declare const MONITOR_ENGINE_TICK_MS = 5000;
16
18
  /** Poll-model source types the engine actually evaluates on a cadence; ws/webhook are push-only and inert here. */
17
19
  export declare const POLL_SOURCE_TYPES: Set<string>;
18
20
  /**
@@ -58,7 +60,9 @@ export declare class MonitorEngine {
58
60
  private ticking;
59
61
  constructor(logFn?: LogFn);
60
62
  /** Load owned+enabled monitors and start the tick loop. */
61
- start(): void;
63
+ start(options?: {
64
+ externalScheduler?: boolean;
65
+ }): void;
62
66
  /** Reload monitor configs (SIGHUP). */
63
67
  reload(): void;
64
68
  /** Stop the tick loop. */
@@ -18,7 +18,7 @@ import { dispatchAction, injectEvent } from './dispatch.js';
18
18
  import { sendToOwner } from '../notify.js';
19
19
  import { readRunMeta } from '../scheduling/routines.js';
20
20
  /** How often the engine wakes to check which monitors are due. */
21
- const TICK_MS = 5_000;
21
+ export const MONITOR_ENGINE_TICK_MS = 5_000;
22
22
  /** Default evaluation cadence for sources that carry no explicit interval. */
23
23
  const DEFAULT_INTERVAL_MS = 60_000;
24
24
  /**
@@ -126,10 +126,12 @@ export class MonitorEngine {
126
126
  this.logFn = logFn;
127
127
  }
128
128
  /** Load owned+enabled monitors and start the tick loop. */
129
- start() {
129
+ start(options = {}) {
130
130
  this.loadAll();
131
131
  this.logFn('INFO', `Monitor engine started (${this.monitors.length} monitor(s) on this device)`);
132
- this.timer = setInterval(() => void this.tick(), TICK_MS);
132
+ if (!options.externalScheduler) {
133
+ this.timer = setInterval(() => void this.tick(), MONITOR_ENGINE_TICK_MS);
134
+ }
133
135
  }
134
136
  /** Reload monitor configs (SIGHUP). */
135
137
  reload() {
@@ -16,6 +16,53 @@ export declare function nextRunLabel(job: JobConfig, scheduler: JobScheduler, no
16
16
  export declare function localLatestRun(job: JobConfig): RunMeta | null;
17
17
  export declare function listJobsForDisplay(cwd?: string): JobConfig[];
18
18
  export declare function buildRoutineListJson(): Record<string, unknown>[];
19
+ /** One routine's live scheduler-status row for `agents routines status --json`. */
20
+ export interface RoutineStatusRow {
21
+ name: string;
22
+ /** The single device this routine is pinned to fire on, or null when unpinned. */
23
+ ownerDevice: string | null;
24
+ /** True when `devices:` names more than one device — no single owner (doctor flags it). */
25
+ ambiguousDevicePin: boolean;
26
+ /** Every device this routine is enabled on. */
27
+ enabledDevices: string[];
28
+ /** Whether THIS device is the one that fires the routine. */
29
+ runsHere: boolean;
30
+ enabled: boolean;
31
+ overdue: boolean;
32
+ nextRun: string | null;
33
+ /** Terminal status of the last local fire: completed/failed/timeout/missed/blocked/skipped/running, or null if it never ran here. */
34
+ lastStatus: RunMeta['status'] | null;
35
+ /** The last fire's failure reason, when it did not complete cleanly. Named to match `list --json`'s `failureReason`. */
36
+ failureReason: string | null;
37
+ lastRunStartedAt: string | null;
38
+ lastRunCompletedAt: string | null;
39
+ /** Present only while a run is genuinely in flight on THIS device (a live local child or a host-placed run) — never a provisional pre-spawn claim. */
40
+ inFlight: {
41
+ runId: string;
42
+ pid: number | null;
43
+ startedAt: string;
44
+ triggerKind: RunMeta['triggerKind'] | null;
45
+ } | null;
46
+ }
47
+ /**
48
+ * The per-routine rows behind `agents routines status --json`. Distinct from
49
+ * {@link buildRoutineListJson}: this is the scheduler-truth surface the daemon
50
+ * owns — per routine it names the single owner device, the last fire's outcome
51
+ * and error, and any in-flight spawn — the fields an operator (or the menu bar /
52
+ * ext) needs to answer "did this routine fire, and is one running right now?"
53
+ * that the definition-shaped `list --json` does not carry (PHNX-3215).
54
+ *
55
+ * `monitorRunningJobs()` runs first to reap runs whose process has exited, then
56
+ * `inFlight` is gated on {@link isRunGenuinelyInFlight} — NOT on `status ===
57
+ * 'running'` alone, because a provisional pre-spawn claim is `running` with a
58
+ * null pid that the reaper does not touch (RUSH-2640).
59
+ *
60
+ * The routine set is the schedulable one ({@link listJobs}, the same
61
+ * `getDaemonStatus`/`routines status` counts), not the display set
62
+ * {@link buildRoutineListJson} uses — a scheduler-status surface names what the
63
+ * daemon can actually fire, not discoverable-but-unmaterialised project routines.
64
+ */
65
+ export declare function buildRoutineStatusRows(): RoutineStatusRow[];
19
66
  /** Tool/site/directory allow-list for sandboxed job execution. */
20
67
  export interface JobAllowConfig {
21
68
  tools?: string[];
@@ -26,7 +26,7 @@ import { enabledRoutineNames, devicesWithRoutineEnabled, replaceEnabledRoutines,
26
26
  import { humanizeCron, humanizeNextRun } from '../routines-format.js';
27
27
  import { discoverProjectRoutines } from '../routines-project.js';
28
28
  import { listProjectDefs } from '../projects.js';
29
- import { monitorRunningJobs } from '../daemon/runner.js';
29
+ import { monitorRunningJobs, isRunGenuinelyInFlight } from '../daemon/runner.js';
30
30
  import { JobScheduler } from '../scheduler.js';
31
31
  import { detectOverdueJobs } from '../overdue.js';
32
32
  export function fireConditionLabel(job) {
@@ -142,6 +142,75 @@ export function buildRoutineListJson() {
142
142
  scheduler.stopAll();
143
143
  }
144
144
  }
145
+ /**
146
+ * The per-routine rows behind `agents routines status --json`. Distinct from
147
+ * {@link buildRoutineListJson}: this is the scheduler-truth surface the daemon
148
+ * owns — per routine it names the single owner device, the last fire's outcome
149
+ * and error, and any in-flight spawn — the fields an operator (or the menu bar /
150
+ * ext) needs to answer "did this routine fire, and is one running right now?"
151
+ * that the definition-shaped `list --json` does not carry (PHNX-3215).
152
+ *
153
+ * `monitorRunningJobs()` runs first to reap runs whose process has exited, then
154
+ * `inFlight` is gated on {@link isRunGenuinelyInFlight} — NOT on `status ===
155
+ * 'running'` alone, because a provisional pre-spawn claim is `running` with a
156
+ * null pid that the reaper does not touch (RUSH-2640).
157
+ *
158
+ * The routine set is the schedulable one ({@link listJobs}, the same
159
+ * `getDaemonStatus`/`routines status` counts), not the display set
160
+ * {@link buildRoutineListJson} uses — a scheduler-status surface names what the
161
+ * daemon can actually fire, not discoverable-but-unmaterialised project routines.
162
+ */
163
+ export function buildRoutineStatusRows() {
164
+ try {
165
+ monitorRunningJobs();
166
+ }
167
+ catch { /* best-effort orphan reap */ }
168
+ const jobs = listJobs();
169
+ if (jobs.length === 0)
170
+ return [];
171
+ const scheduler = new JobScheduler(async () => { });
172
+ scheduler.loadAll();
173
+ try {
174
+ const overdueSet = new Set();
175
+ try {
176
+ for (const job of detectOverdueJobs())
177
+ overdueSet.add(job.name);
178
+ }
179
+ catch {
180
+ // Best-effort indicator; never block status on detection errors.
181
+ }
182
+ const now = new Date();
183
+ return jobs.map((job) => {
184
+ const latestRun = localLatestRun(job);
185
+ const inFlight = latestRun && isRunGenuinelyInFlight(latestRun)
186
+ ? {
187
+ runId: latestRun.runId,
188
+ pid: latestRun.pid,
189
+ startedAt: latestRun.startedAt,
190
+ triggerKind: latestRun.triggerKind ?? null,
191
+ }
192
+ : null;
193
+ return {
194
+ name: job.name,
195
+ ownerDevice: routineOwnerDevice(job),
196
+ ambiguousDevicePin: hasAmbiguousDevicePin(job),
197
+ enabledDevices: devicesWithRoutineEnabled(job.name),
198
+ runsHere: jobRunsOnThisDevice(job),
199
+ enabled: job.enabled,
200
+ overdue: overdueSet.has(job.name),
201
+ nextRun: nextRunForDisplay(job, scheduler)?.toISOString() ?? null,
202
+ lastStatus: latestRun?.status ?? null,
203
+ failureReason: latestRun?.errorMessage ?? null,
204
+ lastRunStartedAt: latestRun?.startedAt ?? null,
205
+ lastRunCompletedAt: latestRun?.completedAt ?? null,
206
+ inFlight,
207
+ };
208
+ });
209
+ }
210
+ finally {
211
+ scheduler.stopAll();
212
+ }
213
+ }
145
214
  export const HOST_STRATEGIES = ['local', 'host', 'fleet', 'cloud'];
146
215
  /** Canonical set of accepted GitHub trigger events — single source for validation. */
147
216
  export const GITHUB_TRIGGER_EVENTS = [
@@ -33,7 +33,8 @@ export declare const CONTENT_INDEX_VERSION = 1;
33
33
  */
34
34
  /** Bump when facet extraction changes so cached rows recompute (shell-command-by-binary v7). */
35
35
  export declare const INSIGHTS_EXTRACTOR_VERSION = 7;
36
- export declare const SESSION_TOPIC_EXTRACTOR_VERSION = 1;
36
+ /** Bump when classifyTopic's output changes so cached topics recompute (human task taxonomy v2). */
37
+ export declare const SESSION_TOPIC_EXTRACTOR_VERSION = 2;
37
38
  /** File stat snapshot used to detect changes between scan runs. */
38
39
  export interface ScanStamp {
39
40
  fileMtimeMs: number;