@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.
- package/CHANGELOG.md +276 -0
- package/README.md +1 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +72 -6
- package/dist/commands/activity.js +198 -49
- package/dist/commands/beta.js +1 -0
- package/dist/commands/doctor.js +4 -2
- package/dist/commands/exec.d.ts +14 -0
- package/dist/commands/exec.js +144 -14
- package/dist/commands/feed.js +77 -4
- package/dist/commands/hooks.js +22 -6
- package/dist/commands/perf.d.ts +14 -0
- package/dist/commands/perf.js +221 -0
- package/dist/commands/projects.d.ts +12 -0
- package/dist/commands/projects.js +358 -0
- package/dist/commands/routines.js +30 -24
- package/dist/commands/secrets.d.ts +43 -4
- package/dist/commands/secrets.js +217 -32
- package/dist/commands/send.d.ts +5 -1
- package/dist/commands/send.js +1 -1
- package/dist/commands/sessions-picker.d.ts +15 -0
- package/dist/commands/sessions-picker.js +107 -13
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +9 -1
- package/dist/commands/sessions.d.ts +10 -5
- package/dist/commands/sessions.js +65 -27
- package/dist/index.js +19 -3
- package/dist/lib/activity.d.ts +80 -13
- package/dist/lib/activity.js +418 -74
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/catchup.d.ts +105 -0
- package/dist/lib/catchup.js +160 -0
- package/dist/lib/channels/providers/desktop.d.ts +49 -0
- package/dist/lib/channels/providers/desktop.js +132 -0
- package/dist/lib/channels/providers/index.js +2 -0
- package/dist/lib/daemon.js +74 -13
- package/dist/lib/devices/registry.d.ts +14 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/events.d.ts +12 -0
- package/dist/lib/events.js +122 -9
- package/dist/lib/exec.js +10 -0
- package/dist/lib/feed-broadcast.d.ts +47 -0
- package/dist/lib/feed-broadcast.js +65 -1
- package/dist/lib/feed-post.d.ts +10 -0
- package/dist/lib/feed-post.js +9 -3
- package/dist/lib/feed.d.ts +47 -1
- package/dist/lib/feed.js +38 -0
- package/dist/lib/hooks/cache.d.ts +2 -0
- package/dist/lib/hooks/cache.js +24 -4
- package/dist/lib/hosts/remote-cmd.js +4 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +14 -4
- package/dist/lib/menubar/install-menubar.js +20 -6
- package/dist/lib/overdue.d.ts +14 -0
- package/dist/lib/overdue.js +37 -1
- package/dist/lib/perf/db.d.ts +25 -0
- package/dist/lib/perf/db.js +290 -0
- package/dist/lib/perf/spool.d.ts +18 -0
- package/dist/lib/perf/spool.js +79 -0
- package/dist/lib/perf/types.d.ts +45 -0
- package/dist/lib/perf/types.js +2 -0
- package/dist/lib/project-key.d.ts +44 -0
- package/dist/lib/project-key.js +79 -0
- package/dist/lib/project-root.js +16 -0
- package/dist/lib/project-status.d.ts +69 -0
- package/dist/lib/project-status.js +101 -0
- package/dist/lib/projects.d.ts +138 -0
- package/dist/lib/projects.js +301 -0
- package/dist/lib/remote-agents-json.d.ts +9 -0
- package/dist/lib/remote-agents-json.js +11 -5
- package/dist/lib/routines-project.js +6 -0
- package/dist/lib/routines.d.ts +30 -1
- package/dist/lib/routines.js +11 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/list-filter.d.ts +94 -0
- package/dist/lib/secrets/list-filter.js +245 -0
- package/dist/lib/session/bash-command.d.ts +53 -0
- package/dist/lib/session/bash-command.js +364 -0
- package/dist/lib/session/digest.d.ts +13 -0
- package/dist/lib/session/digest.js +48 -1
- package/dist/lib/session/discover.d.ts +1 -2
- package/dist/lib/session/discover.js +7 -24
- package/dist/lib/session/highlights.d.ts +82 -0
- package/dist/lib/session/highlights.js +251 -0
- package/dist/lib/session/parse.js +23 -1
- package/dist/lib/session/relative-time.d.ts +37 -0
- package/dist/lib/session/relative-time.js +96 -8
- package/dist/lib/session/remote-list.js +5 -2
- package/dist/lib/session/render.d.ts +9 -9
- package/dist/lib/session/render.js +112 -73
- package/dist/lib/session/types.d.ts +4 -1
- package/dist/lib/ssh-exec.d.ts +6 -0
- package/dist/lib/ssh-exec.js +10 -1
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/state.d.ts +11 -0
- package/dist/lib/state.js +16 -0
- package/dist/lib/terminal/backends/index.d.ts +10 -2
- package/dist/lib/terminal/backends/index.js +14 -2
- package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
- package/dist/lib/terminal/backends/terminal-app.js +73 -0
- package/dist/lib/terminal/index.d.ts +2 -1
- package/dist/lib/terminal/index.js +2 -1
- package/dist/lib/terminal/preferred.d.ts +89 -0
- package/dist/lib/terminal/preferred.js +87 -0
- package/dist/lib/terminal/run-surface.d.ts +82 -0
- package/dist/lib/terminal/run-surface.js +146 -0
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/types.d.ts +1 -1
- 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
|
}
|
package/dist/lib/daemon.js
CHANGED
|
@@ -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
|
|
481
|
-
//
|
|
482
|
-
// the
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
497
|
-
|
|
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
|
}
|