@phnx-labs/agents-cli 1.20.90 → 1.20.92

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 (112) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +1 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +72 -6
  5. package/dist/commands/activity.js +198 -49
  6. package/dist/commands/beta.js +1 -0
  7. package/dist/commands/doctor.js +4 -2
  8. package/dist/commands/exec.d.ts +14 -0
  9. package/dist/commands/exec.js +144 -14
  10. package/dist/commands/feed.js +77 -4
  11. package/dist/commands/hooks.js +22 -6
  12. package/dist/commands/perf.d.ts +14 -0
  13. package/dist/commands/perf.js +221 -0
  14. package/dist/commands/projects.d.ts +12 -0
  15. package/dist/commands/projects.js +358 -0
  16. package/dist/commands/routines.js +30 -24
  17. package/dist/commands/secrets.d.ts +43 -4
  18. package/dist/commands/secrets.js +217 -32
  19. package/dist/commands/send.d.ts +5 -1
  20. package/dist/commands/send.js +1 -1
  21. package/dist/commands/sessions-picker.d.ts +15 -0
  22. package/dist/commands/sessions-picker.js +107 -13
  23. package/dist/commands/sessions-resume.d.ts +2 -0
  24. package/dist/commands/sessions-resume.js +9 -1
  25. package/dist/commands/sessions.d.ts +10 -5
  26. package/dist/commands/sessions.js +65 -27
  27. package/dist/index.js +19 -3
  28. package/dist/lib/activity.d.ts +80 -13
  29. package/dist/lib/activity.js +418 -74
  30. package/dist/lib/beta.d.ts +1 -1
  31. package/dist/lib/beta.js +1 -1
  32. package/dist/lib/catchup.d.ts +105 -0
  33. package/dist/lib/catchup.js +160 -0
  34. package/dist/lib/channels/providers/desktop.d.ts +49 -0
  35. package/dist/lib/channels/providers/desktop.js +132 -0
  36. package/dist/lib/channels/providers/index.js +2 -0
  37. package/dist/lib/daemon.js +74 -13
  38. package/dist/lib/devices/registry.d.ts +14 -0
  39. package/dist/lib/devices/registry.js +37 -0
  40. package/dist/lib/events.d.ts +12 -0
  41. package/dist/lib/events.js +122 -9
  42. package/dist/lib/exec.js +10 -0
  43. package/dist/lib/feed-broadcast.d.ts +47 -0
  44. package/dist/lib/feed-broadcast.js +65 -1
  45. package/dist/lib/feed-post.d.ts +10 -0
  46. package/dist/lib/feed-post.js +9 -3
  47. package/dist/lib/feed.d.ts +47 -1
  48. package/dist/lib/feed.js +38 -0
  49. package/dist/lib/hooks/cache.d.ts +2 -0
  50. package/dist/lib/hooks/cache.js +24 -4
  51. package/dist/lib/hosts/remote-cmd.js +4 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  54. package/dist/lib/menubar/install-menubar.js +20 -6
  55. package/dist/lib/overdue.d.ts +14 -0
  56. package/dist/lib/overdue.js +37 -1
  57. package/dist/lib/perf/db.d.ts +25 -0
  58. package/dist/lib/perf/db.js +290 -0
  59. package/dist/lib/perf/spool.d.ts +18 -0
  60. package/dist/lib/perf/spool.js +79 -0
  61. package/dist/lib/perf/types.d.ts +45 -0
  62. package/dist/lib/perf/types.js +2 -0
  63. package/dist/lib/project-key.d.ts +44 -0
  64. package/dist/lib/project-key.js +79 -0
  65. package/dist/lib/project-root.js +16 -0
  66. package/dist/lib/project-status.d.ts +69 -0
  67. package/dist/lib/project-status.js +101 -0
  68. package/dist/lib/projects.d.ts +138 -0
  69. package/dist/lib/projects.js +301 -0
  70. package/dist/lib/remote-agents-json.d.ts +9 -0
  71. package/dist/lib/remote-agents-json.js +11 -5
  72. package/dist/lib/routines-project.js +6 -0
  73. package/dist/lib/routines.d.ts +30 -1
  74. package/dist/lib/routines.js +11 -0
  75. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  77. package/dist/lib/secrets/list-filter.d.ts +94 -0
  78. package/dist/lib/secrets/list-filter.js +245 -0
  79. package/dist/lib/session/bash-command.d.ts +53 -0
  80. package/dist/lib/session/bash-command.js +364 -0
  81. package/dist/lib/session/digest.d.ts +13 -0
  82. package/dist/lib/session/digest.js +48 -1
  83. package/dist/lib/session/discover.d.ts +1 -2
  84. package/dist/lib/session/discover.js +7 -24
  85. package/dist/lib/session/highlights.d.ts +82 -0
  86. package/dist/lib/session/highlights.js +251 -0
  87. package/dist/lib/session/parse.js +23 -1
  88. package/dist/lib/session/relative-time.d.ts +37 -0
  89. package/dist/lib/session/relative-time.js +96 -8
  90. package/dist/lib/session/remote-list.js +5 -2
  91. package/dist/lib/session/render.d.ts +9 -9
  92. package/dist/lib/session/render.js +112 -73
  93. package/dist/lib/session/types.d.ts +4 -1
  94. package/dist/lib/ssh-exec.d.ts +6 -0
  95. package/dist/lib/ssh-exec.js +10 -1
  96. package/dist/lib/startup/command-registry.d.ts +2 -0
  97. package/dist/lib/startup/command-registry.js +4 -0
  98. package/dist/lib/state.d.ts +11 -0
  99. package/dist/lib/state.js +16 -0
  100. package/dist/lib/terminal/backends/index.d.ts +10 -2
  101. package/dist/lib/terminal/backends/index.js +14 -2
  102. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  103. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  104. package/dist/lib/terminal/index.d.ts +2 -1
  105. package/dist/lib/terminal/index.js +2 -1
  106. package/dist/lib/terminal/preferred.d.ts +89 -0
  107. package/dist/lib/terminal/preferred.js +87 -0
  108. package/dist/lib/terminal/run-surface.d.ts +82 -0
  109. package/dist/lib/terminal/run-surface.js +146 -0
  110. package/dist/lib/terminal/types.d.ts +1 -1
  111. package/dist/lib/types.d.ts +1 -1
  112. package/package.json +4 -1
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Catch-up: run a routine whose scheduled fire this device missed.
3
+ *
4
+ * Fires are in-process croner timers, and croner only ever schedules forward
5
+ * from "now". A daemon that is down, asleep, or wedged when a routine comes due
6
+ * therefore loses that fire outright — `loadAll()` rebuilds every Cron looking
7
+ * only at the future (scheduler.ts), so nothing replays it. Detection has always
8
+ * existed (`detectOverdueJobs`), but it ran once at daemon startup and only
9
+ * logged plus popped a notification; the routine still never ran.
10
+ *
11
+ * This module closes that loop. A missed fire is:
12
+ *
13
+ * 1. CLAIMED — `claimMissedFire` writes a real run with status `missed`,
14
+ * stamped at the time the fire was DUE, so `agents routines runs <name>`
15
+ * shows the gap instead of the listing showing a weeks-old `completed` as
16
+ * though it were current. The write is an atomic claim (see below), and
17
+ * only the claimant proceeds to step 2.
18
+ * 2. RUN — unless the routine sets `catchup: false`, it is executed late via
19
+ * the same `executeJobDetached` path `agents routines catchup` already used.
20
+ *
21
+ * The `missed` record is also what makes this idempotent, so there is no
22
+ * separate ledger to keep in sync. `detectOverdueJobs` compares the most recent
23
+ * expected fire against `getLatestRun(...).startedAt`; a `missed` record stamped
24
+ * at `expectedAt` advances that comparison, so the same missed fire is never
25
+ * reconsidered — across ticks, daemon restarts, or a restart storm. (A job that
26
+ * is overdue by definition has no run later than `expectedAt`, so the
27
+ * back-stamped record always sorts last in `listRuns`.)
28
+ *
29
+ * That comparison alone is not enough when two callers overlap, because both
30
+ * can read the same overdue set before either writes. The claim in
31
+ * `claimMissedFire` closes that: the record's directory is created with a
32
+ * non-recursive `mkdir`, an atomic test-and-set, and only the caller that wins
33
+ * it runs the routine. This holds across processes — the daemon's timer and a
34
+ * human running `agents routines catchup` — which neither an in-process flag
35
+ * nor `withFileLock` (synchronous; this pass awaits a spawn) can cover.
36
+ */
37
+ import { type JobConfig, type RunMeta } from './routines.js';
38
+ import { type OverdueJob } from './overdue.js';
39
+ /** What happened to one overdue routine on a catch-up pass. */
40
+ export interface CatchupOutcome {
41
+ name: string;
42
+ /** The fire that was missed. */
43
+ expectedAt: Date;
44
+ /**
45
+ * `ran` — re-run late. `recorded` — miss logged, not re-run (`catchup: false`
46
+ * or a dry run). `claimed-elsewhere` — a concurrent pass or process already
47
+ * owns this fire. `error` — could not start the late run.
48
+ */
49
+ result: 'ran' | 'recorded' | 'claimed-elsewhere' | 'error';
50
+ /** Run id of the late run, when one was started. */
51
+ runId?: string;
52
+ /** Why the late run could not be started. */
53
+ error?: string;
54
+ }
55
+ /**
56
+ * Is this routine allowed to run late? Default true — a routine you scheduled
57
+ * is one you expect to have run, so losing a fire silently is never the helpful
58
+ * default. `catchup: false` opts out a routine whose worth expires with its slot.
59
+ */
60
+ export declare function shouldCatchUp(job: Pick<JobConfig, 'catchup'>): boolean;
61
+ /** The run id a missed fire is recorded under — derived from when it was DUE. */
62
+ export declare function missedRunId(expectedAt: Date): string;
63
+ /**
64
+ * CLAIM a missed fire: atomically record that it never happened, and report
65
+ * whether this caller is the one that recorded it.
66
+ *
67
+ * Returns the run on a successful claim, or `null` when another caller already
68
+ * claimed the same (routine, expected-fire) pair. That return value is the
69
+ * concurrency control for the whole module — only the claimant runs the routine
70
+ * late, so a fire can never be spawned twice.
71
+ *
72
+ * The atomicity is the non-recursive `mkdir` of the run directory: on every
73
+ * POSIX filesystem that is a single test-and-set, failing with EEXIST if the
74
+ * directory is already there. It therefore holds between the daemon's timer and
75
+ * a human running `agents routines catchup` in a separate process — which an
76
+ * in-process re-entrancy flag cannot cover, and which a lock cannot cover either
77
+ * (`withFileLock` is synchronous and this pass awaits a spawn).
78
+ *
79
+ * The run id is derived from `expectedAt` rather than "now" so the same missed
80
+ * fire always maps to the same directory — that is what makes the claim
81
+ * meaningful — and so the record sorts into `listRuns` (lexical over ISO run
82
+ * ids) at the point the gap actually occurred.
83
+ *
84
+ * Deliberately at-most-once: a process that dies between claiming and spawning
85
+ * leaves the fire un-run. That is the right trade for something that starts
86
+ * agent processes — a double spawn costs real work and money, while the miss is
87
+ * still on the record for a human to see and re-run.
88
+ */
89
+ export declare function claimMissedFire(job: JobConfig, expectedAt: Date): RunMeta | null;
90
+ export interface CatchupOptions {
91
+ /** Record misses but start no late runs. Powers `catchup --dry-run`. */
92
+ dryRun?: boolean;
93
+ /** Clock injection seam for tests. */
94
+ now?: Date;
95
+ /** Overdue set to act on. Defaults to detecting it. Lets a caller reuse a scan. */
96
+ overdue?: OverdueJob[];
97
+ }
98
+ /**
99
+ * Record — and, unless opted out, re-run — every routine this device missed.
100
+ *
101
+ * Device scoping is already enforced upstream: `detectOverdueJobs` skips a job
102
+ * pinned elsewhere (overdue.ts), so a fleet of machines never all catch up the
103
+ * same routine.
104
+ */
105
+ export declare function runCatchup(opts?: CatchupOptions): Promise<CatchupOutcome[]>;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Catch-up: run a routine whose scheduled fire this device missed.
3
+ *
4
+ * Fires are in-process croner timers, and croner only ever schedules forward
5
+ * from "now". A daemon that is down, asleep, or wedged when a routine comes due
6
+ * therefore loses that fire outright — `loadAll()` rebuilds every Cron looking
7
+ * only at the future (scheduler.ts), so nothing replays it. Detection has always
8
+ * existed (`detectOverdueJobs`), but it ran once at daemon startup and only
9
+ * logged plus popped a notification; the routine still never ran.
10
+ *
11
+ * This module closes that loop. A missed fire is:
12
+ *
13
+ * 1. CLAIMED — `claimMissedFire` writes a real run with status `missed`,
14
+ * stamped at the time the fire was DUE, so `agents routines runs <name>`
15
+ * shows the gap instead of the listing showing a weeks-old `completed` as
16
+ * though it were current. The write is an atomic claim (see below), and
17
+ * only the claimant proceeds to step 2.
18
+ * 2. RUN — unless the routine sets `catchup: false`, it is executed late via
19
+ * the same `executeJobDetached` path `agents routines catchup` already used.
20
+ *
21
+ * The `missed` record is also what makes this idempotent, so there is no
22
+ * separate ledger to keep in sync. `detectOverdueJobs` compares the most recent
23
+ * expected fire against `getLatestRun(...).startedAt`; a `missed` record stamped
24
+ * at `expectedAt` advances that comparison, so the same missed fire is never
25
+ * reconsidered — across ticks, daemon restarts, or a restart storm. (A job that
26
+ * is overdue by definition has no run later than `expectedAt`, so the
27
+ * back-stamped record always sorts last in `listRuns`.)
28
+ *
29
+ * That comparison alone is not enough when two callers overlap, because both
30
+ * can read the same overdue set before either writes. The claim in
31
+ * `claimMissedFire` closes that: the record's directory is created with a
32
+ * non-recursive `mkdir`, an atomic test-and-set, and only the caller that wins
33
+ * it runs the routine. This holds across processes — the daemon's timer and a
34
+ * human running `agents routines catchup` — which neither an in-process flag
35
+ * nor `withFileLock` (synchronous; this pass awaits a spawn) can cover.
36
+ */
37
+ import * as fs from 'fs';
38
+ import * as path from 'path';
39
+ import { readJob, writeRunMeta, getRunDir, } from './routines.js';
40
+ import { detectOverdueJobs } from './overdue.js';
41
+ import { executeJobDetached } from './runner.js';
42
+ /**
43
+ * Is this routine allowed to run late? Default true — a routine you scheduled
44
+ * is one you expect to have run, so losing a fire silently is never the helpful
45
+ * default. `catchup: false` opts out a routine whose worth expires with its slot.
46
+ */
47
+ export function shouldCatchUp(job) {
48
+ return job.catchup !== false;
49
+ }
50
+ /** The run id a missed fire is recorded under — derived from when it was DUE. */
51
+ export function missedRunId(expectedAt) {
52
+ return expectedAt.toISOString().replace(/[:.]/g, '-');
53
+ }
54
+ /**
55
+ * CLAIM a missed fire: atomically record that it never happened, and report
56
+ * whether this caller is the one that recorded it.
57
+ *
58
+ * Returns the run on a successful claim, or `null` when another caller already
59
+ * claimed the same (routine, expected-fire) pair. That return value is the
60
+ * concurrency control for the whole module — only the claimant runs the routine
61
+ * late, so a fire can never be spawned twice.
62
+ *
63
+ * The atomicity is the non-recursive `mkdir` of the run directory: on every
64
+ * POSIX filesystem that is a single test-and-set, failing with EEXIST if the
65
+ * directory is already there. It therefore holds between the daemon's timer and
66
+ * a human running `agents routines catchup` in a separate process — which an
67
+ * in-process re-entrancy flag cannot cover, and which a lock cannot cover either
68
+ * (`withFileLock` is synchronous and this pass awaits a spawn).
69
+ *
70
+ * The run id is derived from `expectedAt` rather than "now" so the same missed
71
+ * fire always maps to the same directory — that is what makes the claim
72
+ * meaningful — and so the record sorts into `listRuns` (lexical over ISO run
73
+ * ids) at the point the gap actually occurred.
74
+ *
75
+ * Deliberately at-most-once: a process that dies between claiming and spawning
76
+ * leaves the fire un-run. That is the right trade for something that starts
77
+ * agent processes — a double spawn costs real work and money, while the miss is
78
+ * still on the record for a human to see and re-run.
79
+ */
80
+ export function claimMissedFire(job, expectedAt) {
81
+ const runId = missedRunId(expectedAt);
82
+ const runDir = getRunDir(job.name, runId);
83
+ fs.mkdirSync(path.dirname(runDir), { recursive: true });
84
+ try {
85
+ fs.mkdirSync(runDir); // non-recursive: throws EEXIST if already claimed
86
+ }
87
+ catch (err) {
88
+ if (err.code === 'EEXIST')
89
+ return null;
90
+ throw err;
91
+ }
92
+ const at = expectedAt.toISOString();
93
+ const meta = {
94
+ jobName: job.name,
95
+ runId,
96
+ agent: job.agent,
97
+ workflow: job.workflow,
98
+ command: job.command,
99
+ pid: null,
100
+ status: 'missed',
101
+ startedAt: at,
102
+ completedAt: at,
103
+ exitCode: null,
104
+ errorMessage: 'scheduled fire missed — the scheduler was not running when it came due',
105
+ actor: job.actor,
106
+ };
107
+ writeRunMeta(meta);
108
+ return meta;
109
+ }
110
+ /**
111
+ * Record — and, unless opted out, re-run — every routine this device missed.
112
+ *
113
+ * Device scoping is already enforced upstream: `detectOverdueJobs` skips a job
114
+ * pinned elsewhere (overdue.ts), so a fleet of machines never all catch up the
115
+ * same routine.
116
+ */
117
+ export async function runCatchup(opts = {}) {
118
+ const overdue = opts.overdue ?? detectOverdueJobs(opts.now ?? new Date());
119
+ const outcomes = [];
120
+ for (const entry of overdue) {
121
+ const config = readJob(entry.name);
122
+ if (!config) {
123
+ outcomes.push({
124
+ name: entry.name,
125
+ expectedAt: entry.expectedAt,
126
+ result: 'error',
127
+ error: 'config not found',
128
+ });
129
+ continue;
130
+ }
131
+ // Claim first. Losing the claim means another pass (or another process)
132
+ // already owns this fire — say so rather than running it a second time.
133
+ if (claimMissedFire(config, entry.expectedAt) === null) {
134
+ outcomes.push({ name: entry.name, expectedAt: entry.expectedAt, result: 'claimed-elsewhere' });
135
+ continue;
136
+ }
137
+ if (!shouldCatchUp(config) || opts.dryRun) {
138
+ outcomes.push({ name: entry.name, expectedAt: entry.expectedAt, result: 'recorded' });
139
+ continue;
140
+ }
141
+ try {
142
+ const meta = await executeJobDetached(config);
143
+ outcomes.push({
144
+ name: entry.name,
145
+ expectedAt: entry.expectedAt,
146
+ result: 'ran',
147
+ runId: meta.runId,
148
+ });
149
+ }
150
+ catch (err) {
151
+ outcomes.push({
152
+ name: entry.name,
153
+ expectedAt: entry.expectedAt,
154
+ result: 'error',
155
+ error: err instanceof Error ? err.message : String(err),
156
+ });
157
+ }
158
+ }
159
+ return outcomes;
160
+ }
@@ -0,0 +1,49 @@
1
+ import type { ChannelProvider } from '../registry.js';
2
+ /**
3
+ * Split one message into the notification's title and body.
4
+ *
5
+ * A broadcast sink hands us `composeBroadcastMessage`'s shape — `<project> · <text>`
6
+ * with any link on a second line — so honouring the newline puts the human
7
+ * sentence in the title and the URL underneath, which is how the existing
8
+ * daemon notifications already read.
9
+ *
10
+ * Notification banners show roughly two lines before an ellipsis, so a long
11
+ * single-line message is split at the title boundary rather than truncated away:
12
+ * the head becomes the title and the remainder still arrives in the body.
13
+ */
14
+ export declare function splitDesktopMessage(text: string): {
15
+ title: string;
16
+ body: string;
17
+ };
18
+ /**
19
+ * Which native notifier this platform wires, or undefined when it wires none.
20
+ *
21
+ * macOS always ships `osascript`, and `notifyDesktop` prefers the branded
22
+ * MenubarHelper when installed and degrades to osascript otherwise — either way
23
+ * something delivers. Linux depends on `notify-send`, which is NOT guaranteed
24
+ * (a headless box typically lacks it), so the platform answer alone is not proof
25
+ * there — see `desktopDeliverable`. Every other platform has no wired notifier at
26
+ * all (`notifyDesktop` is a documented no-op), so sending there must fail loud.
27
+ *
28
+ * Pure and platform-injectable so the gate is testable without spawning.
29
+ */
30
+ export declare function desktopNotifier(platform?: NodeJS.Platform): string | undefined;
31
+ /**
32
+ * Whether a notification sent right now would actually arrive.
33
+ *
34
+ * On Linux the notifier is a separate binary that is frequently absent, and
35
+ * `notifyDesktop` spawns it detached — an ENOENT surfaces asynchronously and is
36
+ * swallowed. Reporting `ok` off the platform name alone would therefore mark an
37
+ * undelivered notification as delivered, which is the same silent failure this
38
+ * provider exists to remove, just relocated. So probe for the binary.
39
+ *
40
+ * macOS needs no probe: `osascript` is part of the OS, so the degrade path is
41
+ * always available even when MenubarHelper is not installed.
42
+ */
43
+ export declare function desktopDeliverable(platform?: NodeJS.Platform): {
44
+ ok: true;
45
+ } | {
46
+ ok: false;
47
+ reason: string;
48
+ };
49
+ export declare const desktopProvider: ChannelProvider;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Desktop provider — the local machine's native notification centre.
3
+ *
4
+ * The only channel with no external dependency: no network, no login, no vendor
5
+ * CLI. On the Mac the operator is sitting at it works when every other channel is
6
+ * dead, which is exactly when a blocked agent most needs to reach them.
7
+ *
8
+ * Delivery reuses `notifyDesktop` (lib/menubar/notify-desktop.ts), so a message
9
+ * sent here is attributed to MenubarHelper.app and carries the agents-cli mark —
10
+ * the same path that produces the "agents-cli / <title> / <body>" entries already
11
+ * in Notification Center.
12
+ *
13
+ * WHY THIS ISN'T A THIN PASSTHROUGH: `notifyDesktop` is deliberately
14
+ * fire-and-forget — it spawns detached and swallows every failure so a hiccup can
15
+ * never take the daemon down. A channel provider cannot inherit that: a sink that
16
+ * always reports `ok: true` would make an undelivered notification look delivered,
17
+ * which is the precise class of silent failure this whole subsystem exists to
18
+ * kill. So the provider resolves *deliverability* up front — is there a notifier
19
+ * on this platform at all — and fails loud when there is not. It reports what it
20
+ * can actually know: on a platform with no notifier, nothing will arrive.
21
+ */
22
+ import * as os from 'os';
23
+ import { spawnSync } from 'child_process';
24
+ import { notifyDesktop } from '../../menubar/notify-desktop.js';
25
+ const NAME = 'desktop';
26
+ /** Longest title before macOS truncates it in the banner. Keeps the ask readable. */
27
+ const TITLE_MAX = 64;
28
+ /**
29
+ * Split one message into the notification's title and body.
30
+ *
31
+ * A broadcast sink hands us `composeBroadcastMessage`'s shape — `<project> · <text>`
32
+ * with any link on a second line — so honouring the newline puts the human
33
+ * sentence in the title and the URL underneath, which is how the existing
34
+ * daemon notifications already read.
35
+ *
36
+ * Notification banners show roughly two lines before an ellipsis, so a long
37
+ * single-line message is split at the title boundary rather than truncated away:
38
+ * the head becomes the title and the remainder still arrives in the body.
39
+ */
40
+ export function splitDesktopMessage(text) {
41
+ const trimmed = text.trim();
42
+ const newline = trimmed.indexOf('\n');
43
+ if (newline !== -1) {
44
+ return {
45
+ title: trimmed.slice(0, newline).trim().slice(0, TITLE_MAX),
46
+ body: trimmed.slice(newline + 1).trim(),
47
+ };
48
+ }
49
+ if (trimmed.length <= TITLE_MAX) {
50
+ return { title: trimmed, body: '' };
51
+ }
52
+ // Break on the last word boundary inside the limit so the title doesn't end
53
+ // mid-word; fall back to a hard cut when there is no space to break on.
54
+ const head = trimmed.slice(0, TITLE_MAX);
55
+ const cut = head.lastIndexOf(' ');
56
+ const at = cut > TITLE_MAX / 2 ? cut : TITLE_MAX;
57
+ return { title: trimmed.slice(0, at).trim(), body: trimmed.slice(at).trim() };
58
+ }
59
+ /**
60
+ * Which native notifier this platform wires, or undefined when it wires none.
61
+ *
62
+ * macOS always ships `osascript`, and `notifyDesktop` prefers the branded
63
+ * MenubarHelper when installed and degrades to osascript otherwise — either way
64
+ * something delivers. Linux depends on `notify-send`, which is NOT guaranteed
65
+ * (a headless box typically lacks it), so the platform answer alone is not proof
66
+ * there — see `desktopDeliverable`. Every other platform has no wired notifier at
67
+ * all (`notifyDesktop` is a documented no-op), so sending there must fail loud.
68
+ *
69
+ * Pure and platform-injectable so the gate is testable without spawning.
70
+ */
71
+ export function desktopNotifier(platform = os.platform()) {
72
+ if (platform === 'darwin')
73
+ return 'menubar-or-osascript';
74
+ if (platform === 'linux')
75
+ return 'notify-send';
76
+ return undefined;
77
+ }
78
+ /**
79
+ * Whether a notification sent right now would actually arrive.
80
+ *
81
+ * On Linux the notifier is a separate binary that is frequently absent, and
82
+ * `notifyDesktop` spawns it detached — an ENOENT surfaces asynchronously and is
83
+ * swallowed. Reporting `ok` off the platform name alone would therefore mark an
84
+ * undelivered notification as delivered, which is the same silent failure this
85
+ * provider exists to remove, just relocated. So probe for the binary.
86
+ *
87
+ * macOS needs no probe: `osascript` is part of the OS, so the degrade path is
88
+ * always available even when MenubarHelper is not installed.
89
+ */
90
+ export function desktopDeliverable(platform = os.platform()) {
91
+ const notifier = desktopNotifier(platform);
92
+ if (!notifier) {
93
+ return { ok: false, reason: `no desktop notifier on ${platform} — nothing would be delivered` };
94
+ }
95
+ if (platform === 'linux') {
96
+ const probe = spawnSync('which', ['notify-send'], { stdio: 'ignore' });
97
+ if (probe.status !== 0) {
98
+ return { ok: false, reason: 'notify-send not on PATH — nothing would be delivered' };
99
+ }
100
+ }
101
+ return { ok: true };
102
+ }
103
+ export const desktopProvider = {
104
+ name: NAME,
105
+ async send(text, opts) {
106
+ // `target` is meaningless for a local notification — the recipient is whoever
107
+ // is at this machine — but it is echoed for --json parity with every other
108
+ // provider, and `agents notify` still requires notify.owner.to to be set.
109
+ const id = opts.target || os.hostname();
110
+ // Order matters, and CI caught it: validate the CALLER first, then honour
111
+ // dry-run, and only then probe the platform.
112
+ //
113
+ // An empty message is a caller error on every platform, so it must not be
114
+ // masked by "this box has no notifier" — the specific, actionable error wins.
115
+ // And `--dry-run` means "resolve + build but do not send", so it must not
116
+ // depend on the ability to send; the sibling rush provider likewise returns
117
+ // ok for a dry run before its own `which rush` check (providers/rush.ts).
118
+ const { title, body } = splitDesktopMessage(text);
119
+ if (!title) {
120
+ return { ok: false, channel: NAME, id, error: 'refusing to send an empty notification' };
121
+ }
122
+ if (opts.dryRun) {
123
+ return { ok: true, channel: NAME, id };
124
+ }
125
+ const deliverable = desktopDeliverable();
126
+ if (!deliverable.ok) {
127
+ return { ok: false, channel: NAME, id, error: deliverable.reason };
128
+ }
129
+ notifyDesktop({ title, body });
130
+ return { ok: true, channel: NAME, id };
131
+ },
132
+ };
@@ -7,6 +7,7 @@ import { registerChannelProvider } from '../registry.js';
7
7
  import { mailboxProvider } from './mailbox.js';
8
8
  import { rushProviders } from './rush.js';
9
9
  import { openclawTelegramProvider } from './openclaw-telegram.js';
10
+ import { desktopProvider } from './desktop.js';
10
11
  let registered = false;
11
12
  /** Register all built-in providers once (idempotent). */
12
13
  export function registerBuiltinProviders() {
@@ -17,4 +18,5 @@ export function registerBuiltinProviders() {
17
18
  for (const p of rushProviders)
18
19
  registerChannelProvider(p);
19
20
  registerChannelProvider(openclawTelegramProvider);
21
+ registerChannelProvider(desktopProvider);
20
22
  }
@@ -18,6 +18,7 @@ import { JobScheduler } from './scheduler.js';
18
18
  import { MonitorEngine } from './monitors/engine.js';
19
19
  import { executeJobDetached, monitorRunningJobs } from './runner.js';
20
20
  import { detectOverdueJobs, notifyOverdue } from './overdue.js';
21
+ import { runCatchup } from './catchup.js';
21
22
  import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from './routine-notify.js';
22
23
  import { BrowserService } from './browser/service.js';
23
24
  import { BrowserIPCServer } from './browser/ipc.js';
@@ -32,6 +33,14 @@ const LOG_ROTATE_COUNT = 3;
32
33
  const PLIST_NAME = 'com.phnx-labs.agents-daemon';
33
34
  const SYSTEMD_UNIT = 'agents-daemon.service';
34
35
  const MONITOR_TICK_MS = 60_000;
36
+ /**
37
+ * How often to re-scan for missed fires. Deliberately slower than the monitor
38
+ * tick: detection walks a week of cron occurrences per routine
39
+ * (`previousExpectedFire`), and a fire that was already missed is not urgent to
40
+ * the second — five minutes bounds the cost while still recovering from a
41
+ * wedge or an OS suspend the process survived.
42
+ */
43
+ const CATCHUP_TICK_MS = 5 * 60_000;
35
44
  const WEDGE_THRESHOLD_TICKS = 3;
36
45
  /**
37
46
  * RUSH-1817: decide whether the daemon should (re)take over hosting the secrets
@@ -477,25 +486,76 @@ export async function runDaemon() {
477
486
  catch (err) {
478
487
  log('ERROR', `Monitor engine failed to start: ${err.message}`);
479
488
  }
480
- // Backlog detection: any enabled recurring job whose most-recent expected
481
- // fire is older than its most-recent recorded run is overdue. Happens when
482
- // the laptop was off or the daemon crashed through a scheduled fire.
483
- // We log it and pop a native notification the user can review with
484
- // `agents routines list` and run them with `agents routines catchup`.
485
- try {
486
- const overdue = detectOverdueJobs();
487
- if (overdue.length > 0) {
488
- log('WARN', `${overdue.length} routine(s) overdue:`);
489
+ // Backlog recovery: any enabled recurring job whose most-recent expected fire
490
+ // is older than its most-recent recorded run was missed the laptop slept,
491
+ // the machine was off, or the daemon crashed through the fire. croner only
492
+ // schedules forward from "now", so nothing replays it on its own.
493
+ //
494
+ // Every miss is RECORDED as a `missed` run and, unless the routine sets
495
+ // `catchup: false`, RUN late. Runs on a timer as well as at startup: a startup
496
+ // pass alone misses a fire lost while the daemon stayed up but its event loop
497
+ // was wedged, or one lost across an OS suspend that the process survived.
498
+ // Overlap guard, same shape as runSessionSync/runHealCheck below. A pass
499
+ // awaits executeJobDetached per job and an off-box (host/cloud) dispatch can
500
+ // block for a while, so a slow pass could still be working when the next tick
501
+ // fires. Both passes would then see a job the first has not yet reached as
502
+ // overdue — the miss is recorded before the await, but only for jobs already
503
+ // processed — and spawn it twice. The idempotency of the `missed` record
504
+ // guards across passes, not within one that is mid-flight.
505
+ let catchingUp = false;
506
+ const catchupPass = async () => {
507
+ if (catchingUp)
508
+ return;
509
+ catchingUp = true;
510
+ try {
511
+ const overdue = detectOverdueJobs();
512
+ if (overdue.length === 0)
513
+ return;
514
+ log('WARN', `${overdue.length} routine(s) missed their fire:`);
489
515
  for (const job of overdue) {
490
516
  const last = job.lastRanAt ? job.lastRanAt.toISOString() : 'never';
491
517
  log('WARN', ` ${job.name} -- expected ${job.expectedAt.toISOString()}, last ran ${last}`);
492
518
  }
493
519
  notifyOverdue(overdue);
520
+ const outcomes = await runCatchup({ overdue });
521
+ for (const o of outcomes) {
522
+ // Every variant handled explicitly: a catch-all else would log the
523
+ // benign 'claimed-elsewhere' (another process legitimately won the
524
+ // claim) as an ERROR with an undefined reason.
525
+ switch (o.result) {
526
+ case 'ran':
527
+ log('INFO', `Caught up '${o.name}' (run: ${o.runId})`);
528
+ break;
529
+ case 'recorded':
530
+ log('INFO', `Recorded missed fire for '${o.name}' (catchup disabled)`);
531
+ break;
532
+ case 'claimed-elsewhere':
533
+ log('INFO', `Missed fire for '${o.name}' already claimed by another catchup`);
534
+ break;
535
+ case 'error':
536
+ log('ERROR', `Catchup for '${o.name}' failed: ${o.error}`);
537
+ break;
538
+ default: {
539
+ // Compile-time exhaustiveness: a new CatchupOutcome variant fails
540
+ // typecheck here rather than silently landing in the wrong log level,
541
+ // which is exactly how 'claimed-elsewhere' was first missed.
542
+ const unhandled = o.result;
543
+ log('ERROR', `Catchup for '${o.name}' returned an unhandled result: ${String(unhandled)}`);
544
+ }
545
+ }
546
+ }
494
547
  }
495
- }
496
- catch (err) {
497
- log('ERROR', `Overdue detection failed: ${err.message}`);
498
- }
548
+ catch (err) {
549
+ log('ERROR', `Catchup pass failed: ${err.message}`);
550
+ }
551
+ finally {
552
+ // finally, not a tail assignment: the no-overdue path returns early, and a
553
+ // throw must not leave the guard latched shut for the daemon's lifetime.
554
+ catchingUp = false;
555
+ }
556
+ };
557
+ await catchupPass();
558
+ const catchupInterval = setInterval(() => { void catchupPass(); }, CATCHUP_TICK_MS);
499
559
  // Before the BrowserService comes up, reap browser + tunnel processes
500
560
  // spawned by previous daemons that are no longer alive. Without this,
501
561
  // a daemon hard-crash (SIGKILL, OOM) would leak every browser and SSH
@@ -834,6 +894,7 @@ export async function runDaemon() {
834
894
  monitorEngine.stop();
835
895
  await browserIPC.stop();
836
896
  clearInterval(monitorInterval);
897
+ clearInterval(catchupInterval);
837
898
  clearInterval(syncInterval);
838
899
  clearInterval(healInterval);
839
900
  clearTimeout(healKickoff);
@@ -80,6 +80,20 @@ export interface DeviceProfile {
80
80
  export declare function deviceRole(d: DeviceProfile): DeviceRole;
81
81
  /** True for a control-only device (a cockpit) that must never be dialed/scheduled. */
82
82
  export declare function isControlDevice(d: DeviceProfile): boolean;
83
+ /**
84
+ * Whether a fan-out should dial this device, honouring the preference stated on
85
+ * {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
86
+ * {@link DeviceTailscale.online} snapshot.
87
+ *
88
+ * Reading only `tailscale.online` is wrong in both directions, and both were
89
+ * live on a real fleet. A `via:"manual"` device never gets a tailscale peer
90
+ * entry at all, so its `online` is permanently `undefined` and a strict
91
+ * `=== true` test skipped it forever — every session on that box was invisible
92
+ * to the cross-fleet sweep. Conversely a box that has since gone to sleep keeps
93
+ * a stale `online:true` and gets dialed, burning a full ConnectTimeout and
94
+ * reporting a false "unreachable" that callers treat as doubt.
95
+ */
96
+ export declare function isDialableDevice(d: DeviceProfile): boolean;
83
97
  /** Map of device name to profile. */
84
98
  export type DeviceRegistry = Record<string, DeviceProfile>;
85
99
  /** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
@@ -27,6 +27,43 @@ export function deviceRole(d) {
27
27
  export function isControlDevice(d) {
28
28
  return deviceRole(d) === 'control';
29
29
  }
30
+ /**
31
+ * Whether a fan-out should dial this device, honouring the preference stated on
32
+ * {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
33
+ * {@link DeviceTailscale.online} snapshot.
34
+ *
35
+ * Reading only `tailscale.online` is wrong in both directions, and both were
36
+ * live on a real fleet. A `via:"manual"` device never gets a tailscale peer
37
+ * entry at all, so its `online` is permanently `undefined` and a strict
38
+ * `=== true` test skipped it forever — every session on that box was invisible
39
+ * to the cross-fleet sweep. Conversely a box that has since gone to sleep keeps
40
+ * a stale `online:true` and gets dialed, burning a full ConnectTimeout and
41
+ * reporting a false "unreachable" that callers treat as doubt.
42
+ */
43
+ export function isDialableDevice(d) {
44
+ // Union, deliberately: either signal saying "go" is enough. A probe may only
45
+ // ADD a peer to the sweep, never remove one.
46
+ //
47
+ // The probe is not trustworthy enough to exclude on. It runs with a short SSH
48
+ // budget, so on a congested tailnet it returns false negatives — observed
49
+ // marking the LOCAL machine unreachable, and flipping a live worker box from
50
+ // reachable to unreachable nine minutes apart. Letting that shrink the sweep
51
+ // would hide sessions on healthy boxes, a worse failure than the one below.
52
+ //
53
+ // The snapshot alone is not enough either: a device registered with
54
+ // `address.via: "manual"` never gets a tailscale peer entry, so `online`
55
+ // stays undefined and a strict `=== true` test skipped it forever, making
56
+ // every session on that box unresolvable from any other machine.
57
+ //
58
+ // So: no tailscale block at all is unknown-not-offline (the rule `ssh.ts`
59
+ // renderDeviceTable and Factory's `isDeviceOnline` already use, so the picker
60
+ // and the sweep agree on who exists), and a positive probe rescues a device
61
+ // whose snapshot says offline. The cost of dialing a box that is actually
62
+ // asleep is one ConnectTimeout — the pre-existing behaviour, not a regression.
63
+ if (d.reachability?.reachable)
64
+ return true;
65
+ return !d.tailscale || d.tailscale.online === true;
66
+ }
30
67
  function registryPath() {
31
68
  return getDevicesRegistryPath();
32
69
  }