@phnx-labs/agents-cli 1.20.90 → 1.20.91
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 +121 -0
- package/dist/bin/agents +0 -0
- 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/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.js +70 -1
- package/dist/index.js +18 -3
- package/dist/lib/activity.d.ts +11 -1
- package/dist/lib/activity.js +1 -0
- 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/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 +1 -1
- 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/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- 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/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/digest.d.ts +7 -0
- package/dist/lib/session/digest.js +29 -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 +14 -0
- package/dist/lib/session/relative-time.js +36 -0
- package/dist/lib/session/render.d.ts +7 -0
- package/dist/lib/session/render.js +87 -17
- package/dist/lib/session/types.d.ts +4 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +9 -0
- package/dist/lib/state.js +11 -0
- package/package.json +3 -1
|
@@ -18,6 +18,7 @@ import { extractTodoProgress, WORKTREE_RE } from '../lib/session/state.js';
|
|
|
18
18
|
import { renderMarkdown } from '../lib/markdown.js';
|
|
19
19
|
import { itemPicker } from '../lib/picker.js';
|
|
20
20
|
import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
|
|
21
|
+
import { extractArtifacts, extractHooks, extractLinks, extractRepos, extractSkills } from '../lib/session/highlights.js';
|
|
21
22
|
/** A session whose transcript lives on another machine (folded in over the live
|
|
22
23
|
* cross-machine fan-out): its `filePath` is on that peer's disk, so the preview
|
|
23
24
|
* can't parse it locally — it shows metadata + a "resume there" note instead.
|
|
@@ -417,9 +418,15 @@ function formatCompactPreview(events, session) {
|
|
|
417
418
|
}
|
|
418
419
|
// Recent activity = directories touched (not raw tool calls). Prefer a
|
|
419
420
|
// parser-supplied dirsTouched when present; else derive from event paths.
|
|
421
|
+
// Width-capped: a long Dirs line used to wrap and swamp the whole pane.
|
|
420
422
|
const dirs = directoriesTouched(session, events, changes);
|
|
421
423
|
if (dirs.length) {
|
|
422
|
-
lines.push(chalk.cyan('Dirs: ') +
|
|
424
|
+
lines.push(chalk.cyan('Dirs: ') + joinWidthCapped(dirs, termWidth - 12));
|
|
425
|
+
}
|
|
426
|
+
// Repos worked in (basename of each `.git` root under the touched paths).
|
|
427
|
+
const repos = extractRepos(events, session.cwd);
|
|
428
|
+
if (repos.length) {
|
|
429
|
+
lines.push(chalk.cyan('Repos: ') + chalk.white(repos.slice(0, 4).join(chalk.gray(' · '))));
|
|
423
430
|
}
|
|
424
431
|
const teamLine = formatTeamLineage(session);
|
|
425
432
|
if (teamLine) {
|
|
@@ -442,6 +449,41 @@ function formatCompactPreview(events, session) {
|
|
|
442
449
|
if (activity.length) {
|
|
443
450
|
lines.push(chalk.cyan('Changes: ') + activity.join(chalk.gray(' · ')));
|
|
444
451
|
}
|
|
452
|
+
// Documents the session produced (`.agents/artifacts|plans|reports`, other
|
|
453
|
+
// *.md/*.html creations) — the files a human browses later, named + clickable.
|
|
454
|
+
const artifacts = extractArtifacts(changes);
|
|
455
|
+
if (artifacts.length) {
|
|
456
|
+
const shown = artifacts.slice(0, 5).map(a => linkPath(a.path, a.basename));
|
|
457
|
+
const more = artifacts.length > 5 ? chalk.gray(` · +${artifacts.length - 5} more`) : '';
|
|
458
|
+
lines.push(chalk.cyan('Artifacts: ') + shown.join(chalk.gray(' · ')) + more);
|
|
459
|
+
}
|
|
460
|
+
// Skills invoked (plugin skills included — they ride the same Skill tool).
|
|
461
|
+
const skills = extractSkills(events);
|
|
462
|
+
if (skills.length) {
|
|
463
|
+
const shown = skills.slice(0, 5).map(s => chalk.white(s.name) + (s.count > 1 ? chalk.gray(` ×${s.count}`) : ''));
|
|
464
|
+
const more = skills.length > 5 ? chalk.gray(` · +${skills.length - 5} more`) : '';
|
|
465
|
+
lines.push(chalk.cyan('Skills: ') + shown.join(chalk.gray(' · ')) + more);
|
|
466
|
+
}
|
|
467
|
+
// Hooks fired (Claude transcripts record firings; other harnesses don't).
|
|
468
|
+
const hooks = extractHooks(events);
|
|
469
|
+
if (hooks.length) {
|
|
470
|
+
const shown = hooks.slice(0, 4).map(h => chalk.white(h.name) + (h.count > 1 ? chalk.gray(` ×${h.count}`) : '') + (h.failed ? chalk.red(` (${h.failed} failed)`) : ''));
|
|
471
|
+
const more = hooks.length > 4 ? chalk.gray(` · +${hooks.length - 4} more`) : '';
|
|
472
|
+
lines.push(chalk.cyan('Hooks: ') + shown.join(chalk.gray(' · ')) + more);
|
|
473
|
+
}
|
|
474
|
+
// Links mentioned in the conversation — clickable (OSC 8), tracker-classified.
|
|
475
|
+
const links = extractLinks(events);
|
|
476
|
+
if (links.length) {
|
|
477
|
+
const shown = links.slice(0, 5).map(l => chalk.blue(linkUrl(l.url, l.label)));
|
|
478
|
+
const more = links.length > 5 ? chalk.gray(` · +${links.length - 5} more`) : '';
|
|
479
|
+
lines.push(chalk.cyan('Links: ') + shown.join(chalk.gray(' · ')) + more);
|
|
480
|
+
}
|
|
481
|
+
// Error tally, mirroring the full summary's Errors section in one line.
|
|
482
|
+
const errorEvents = events.filter(e => e.type === 'error');
|
|
483
|
+
if (errorEvents.length) {
|
|
484
|
+
const first = errorEvents[0].tool || 'unknown';
|
|
485
|
+
lines.push(chalk.cyan('Errors: ') + chalk.red(`${errorEvents.length} failure${errorEvents.length === 1 ? '' : 's'}`) + chalk.gray(` — first: ${first}`));
|
|
486
|
+
}
|
|
445
487
|
const metadata = [
|
|
446
488
|
...toolTags,
|
|
447
489
|
subAgentCount ? `${subAgentCount} sub-agent${subAgentCount === 1 ? '' : 's'}` : '',
|
|
@@ -559,12 +601,39 @@ function encodeClaudeSlug(absPath) {
|
|
|
559
601
|
}
|
|
560
602
|
/** The `.agents/worktrees/<name>` marker, Claude-slug-encoded (`/.` → `--`). */
|
|
561
603
|
const SLUG_WORKTREE_RE = /--agents-worktrees-(.+)$/;
|
|
604
|
+
/**
|
|
605
|
+
* Join display tokens with ` · `, stopping before the line exceeds `maxWidth`
|
|
606
|
+
* and appending `… +N more` for the rest. Keeps the Dirs line on one row.
|
|
607
|
+
*/
|
|
608
|
+
function joinWidthCapped(items, maxWidth) {
|
|
609
|
+
const sep = ' · ';
|
|
610
|
+
let out = '';
|
|
611
|
+
let shown = 0;
|
|
612
|
+
for (const item of items) {
|
|
613
|
+
const next = shown === 0 ? item : out + sep + item;
|
|
614
|
+
if (shown > 0 && next.length > maxWidth)
|
|
615
|
+
break;
|
|
616
|
+
out = next;
|
|
617
|
+
shown++;
|
|
618
|
+
}
|
|
619
|
+
const remaining = items.length - shown;
|
|
620
|
+
const suffix = remaining > 0 ? ` … +${remaining} more` : '';
|
|
621
|
+
return chalk.white(out) + (suffix ? chalk.gray(suffix) : '');
|
|
622
|
+
}
|
|
562
623
|
/** Relativize a file path to its parent dir, short enough for one preview line. */
|
|
563
624
|
export function relativizeDir(filePath, cwd) {
|
|
564
625
|
const norm = filePath.replace(/\\/g, '/');
|
|
565
626
|
if (!norm || norm.includes('node_modules') || norm.includes('/.git/') || norm.includes('/plans/')) {
|
|
566
627
|
return undefined;
|
|
567
628
|
}
|
|
629
|
+
// agents-cli internals — version homes, session/run archives, the bare
|
|
630
|
+
// worktree container/root — are never meaningful "directories the user works
|
|
631
|
+
// in". Cwd is exempt: a session running inside such a dir keeps its own paths.
|
|
632
|
+
const normBase = cwd?.replace(/\\/g, '/').replace(/\/$/, '');
|
|
633
|
+
const underCwd = normBase && (norm === normBase || norm.startsWith(normBase + '/'));
|
|
634
|
+
if (!underCwd && (norm.includes('/.agents/.history/') || /\/\.agents\/worktrees(\/[^/]+)?\/?$/.test(norm))) {
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
568
637
|
let dir = path.posix.dirname(norm);
|
|
569
638
|
// Claude project-slug form: a leading `-`-segment (`-home-me-…`) that carries
|
|
570
639
|
// the cwd, lossily encoded. Handle it in SLUG SPACE — never lossy-decode to a
|
package/dist/index.js
CHANGED
|
@@ -94,7 +94,7 @@ if (IS_DEV_BUILD) {
|
|
|
94
94
|
// module on each invocation (which loaded the whole ~50-module tree before the
|
|
95
95
|
// first byte of output), the registry maps a command name to a thunk that
|
|
96
96
|
// imports only what that command needs. See src/lib/startup/command-registry.ts.
|
|
97
|
-
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadHq, loadFeed, loadActivity, loadMailboxes, } from './lib/startup/command-registry.js';
|
|
97
|
+
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadPerf, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadHq, loadFeed, loadActivity, loadMailboxes, } from './lib/startup/command-registry.js';
|
|
98
98
|
import { applyGlobalHelpConventions } from './lib/help.js';
|
|
99
99
|
import { renderWhatsNew } from './lib/whats-new.js';
|
|
100
100
|
import { getCliLaunch } from './lib/cli-entry.js';
|
|
@@ -173,11 +173,24 @@ program.hook('postAction', (_thisCommand, actionCommand) => {
|
|
|
173
173
|
if (parts.length === 0)
|
|
174
174
|
return;
|
|
175
175
|
const started = auditStarts.get(actionCommand);
|
|
176
|
+
const durationMs = started !== undefined ? Date.now() - started : undefined;
|
|
177
|
+
const command = parts.join(' ');
|
|
176
178
|
emit('command.end', {
|
|
177
179
|
module: parts[0],
|
|
178
|
-
command
|
|
179
|
-
...(
|
|
180
|
+
command,
|
|
181
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
180
182
|
});
|
|
183
|
+
// Disposable perf warehouse — fail-soft spool append (no SQLite on this path).
|
|
184
|
+
if (durationMs !== undefined && parts[0] !== 'perf') {
|
|
185
|
+
void import('./lib/perf/spool.js').then(({ recordSample }) => {
|
|
186
|
+
recordSample({
|
|
187
|
+
kind: 'command.end',
|
|
188
|
+
label: command,
|
|
189
|
+
durationMs,
|
|
190
|
+
cwd: process.cwd(),
|
|
191
|
+
});
|
|
192
|
+
}).catch(() => { });
|
|
193
|
+
}
|
|
181
194
|
}
|
|
182
195
|
catch {
|
|
183
196
|
// Best-effort completion record; the start line is the durable audit fact.
|
|
@@ -269,6 +282,7 @@ Credentials and profiles:
|
|
|
269
282
|
Diagnostics:
|
|
270
283
|
doctor [agent[@version]] Diagnose CLI availability, sync status, and resource divergence; --check for the CI drift gate
|
|
271
284
|
usage [agent] Show rate-limit and quota usage per agent
|
|
285
|
+
perf Latency rollups (hooks, commands, runs) from the disposable perf warehouse
|
|
272
286
|
|
|
273
287
|
Config sync:
|
|
274
288
|
drive Sync session history across machines via rsync
|
|
@@ -885,6 +899,7 @@ async function registerAllEagerCommands() {
|
|
|
885
899
|
await reg(loadFactory);
|
|
886
900
|
await reg(loadUsage);
|
|
887
901
|
await reg(loadCost);
|
|
902
|
+
await reg(loadPerf);
|
|
888
903
|
await reg(loadOutput);
|
|
889
904
|
await reg(loadBudget);
|
|
890
905
|
await reg(loadAlias);
|
package/dist/lib/activity.d.ts
CHANGED
|
@@ -2,7 +2,17 @@ import type { EventRecord } from './events.js';
|
|
|
2
2
|
/** Recognizable milestone events, ordered first in any activity lane. */
|
|
3
3
|
export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created'
|
|
4
4
|
/** Deliberate agent-authored progress post (`agents feed post`). */
|
|
5
|
-
| 'status.posted'
|
|
5
|
+
| 'status.posted'
|
|
6
|
+
/**
|
|
7
|
+
* The same post, but the agent is STUCK (`agents feed post --blocked`).
|
|
8
|
+
*
|
|
9
|
+
* A distinct event rather than a flag on `status.posted` because it is a
|
|
10
|
+
* different kind of thing in the stream: a benign update is history the
|
|
11
|
+
* moment it lands, while a blocked post stays open until someone answers it.
|
|
12
|
+
* Readers that show "what needs a human" select on this; readers that show
|
|
13
|
+
* "what happened" get both.
|
|
14
|
+
*/
|
|
15
|
+
| 'status.blocked';
|
|
6
16
|
/** Routine activity events, collapsed to counts by readers. */
|
|
7
17
|
export type ActivityKind = 'file.edited';
|
|
8
18
|
export type ActivityEventKind = MilestoneEvent | ActivityKind;
|
package/dist/lib/activity.js
CHANGED
|
@@ -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
|
}
|