@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/feed.js +77 -4
  4. package/dist/commands/hooks.js +22 -6
  5. package/dist/commands/perf.d.ts +14 -0
  6. package/dist/commands/perf.js +221 -0
  7. package/dist/commands/routines.js +30 -24
  8. package/dist/commands/secrets.d.ts +43 -4
  9. package/dist/commands/secrets.js +217 -32
  10. package/dist/commands/send.d.ts +5 -1
  11. package/dist/commands/send.js +1 -1
  12. package/dist/commands/sessions-picker.js +70 -1
  13. package/dist/index.js +18 -3
  14. package/dist/lib/activity.d.ts +11 -1
  15. package/dist/lib/activity.js +1 -0
  16. package/dist/lib/catchup.d.ts +105 -0
  17. package/dist/lib/catchup.js +160 -0
  18. package/dist/lib/channels/providers/desktop.d.ts +49 -0
  19. package/dist/lib/channels/providers/desktop.js +132 -0
  20. package/dist/lib/channels/providers/index.js +2 -0
  21. package/dist/lib/daemon.js +74 -13
  22. package/dist/lib/events.d.ts +12 -0
  23. package/dist/lib/events.js +122 -9
  24. package/dist/lib/exec.js +10 -0
  25. package/dist/lib/feed-broadcast.d.ts +47 -0
  26. package/dist/lib/feed-broadcast.js +65 -1
  27. package/dist/lib/feed-post.d.ts +10 -0
  28. package/dist/lib/feed-post.js +1 -1
  29. package/dist/lib/feed.d.ts +47 -1
  30. package/dist/lib/feed.js +38 -0
  31. package/dist/lib/hooks/cache.d.ts +2 -0
  32. package/dist/lib/hooks/cache.js +24 -4
  33. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  34. package/dist/lib/overdue.d.ts +14 -0
  35. package/dist/lib/overdue.js +37 -1
  36. package/dist/lib/perf/db.d.ts +25 -0
  37. package/dist/lib/perf/db.js +290 -0
  38. package/dist/lib/perf/spool.d.ts +18 -0
  39. package/dist/lib/perf/spool.js +79 -0
  40. package/dist/lib/perf/types.d.ts +45 -0
  41. package/dist/lib/perf/types.js +2 -0
  42. package/dist/lib/routines-project.js +6 -0
  43. package/dist/lib/routines.d.ts +30 -1
  44. package/dist/lib/routines.js +11 -0
  45. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  46. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  47. package/dist/lib/secrets/list-filter.d.ts +94 -0
  48. package/dist/lib/secrets/list-filter.js +245 -0
  49. package/dist/lib/session/digest.d.ts +7 -0
  50. package/dist/lib/session/digest.js +29 -1
  51. package/dist/lib/session/discover.d.ts +1 -2
  52. package/dist/lib/session/discover.js +7 -24
  53. package/dist/lib/session/highlights.d.ts +82 -0
  54. package/dist/lib/session/highlights.js +251 -0
  55. package/dist/lib/session/parse.js +23 -1
  56. package/dist/lib/session/relative-time.d.ts +14 -0
  57. package/dist/lib/session/relative-time.js +36 -0
  58. package/dist/lib/session/render.d.ts +7 -0
  59. package/dist/lib/session/render.js +87 -17
  60. package/dist/lib/session/types.d.ts +4 -1
  61. package/dist/lib/startup/command-registry.d.ts +1 -0
  62. package/dist/lib/startup/command-registry.js +2 -0
  63. package/dist/lib/state.d.ts +9 -0
  64. package/dist/lib/state.js +11 -0
  65. 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: ') + chalk.white(dirs.join(chalk.gray(' · '))));
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: parts.join(' '),
179
- ...(started !== undefined ? { durationMs: Date.now() - started } : {}),
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);
@@ -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;
@@ -38,6 +38,7 @@ export const MILESTONE_EVENTS = [
38
38
  'task.completed',
39
39
  'checklist.created',
40
40
  'status.posted',
41
+ 'status.blocked',
41
42
  ];
42
43
  const MILESTONE_SET = new Set(MILESTONE_EVENTS);
43
44
  export function tierForEvent(event) {
@@ -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
  }