@phnx-labs/agents-cli 1.20.76 → 1.20.78

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 (119) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. package/package.json +1 -1
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Resolve a remote-created session id back to an interactive `--host` launcher.
3
+ *
4
+ * A headless `--host` run captures the remote-coined id from the followed log
5
+ * (the `--emit-session-id` marker — see session-marker.ts). An INTERACTIVE run
6
+ * has no followed log: the local TTY is wired straight through `sshStream`
7
+ * (stdio:'inherit'), so nothing can tap the stream for a marker without breaking
8
+ * the agent's raw-mode TUI. Instead we correlate by AGENT_LAUNCH_ID.
9
+ *
10
+ * The launcher forwards a launch id it controls (`--env AGENT_LAUNCH_ID=<id>`);
11
+ * the remote `agents run` adopts it (exec.ts `resolveLaunchId`), so the remote
12
+ * SessionStart hook records the agent's REAL session id under that exact key in
13
+ * `~/.agents/.cache/terminals/sessions/<pid>.json`. After the stream returns we
14
+ * do ONE ssh read of that dir and pick the record whose `launch_id` matches —
15
+ * the same launch-id join `agents sessions --active` uses locally
16
+ * (session/hook-sessions.ts), just across the SSH hop.
17
+ *
18
+ * This gives the interactive path a real per-agent id to register in the local
19
+ * session index and to reconnect against, for Codex/Kimi/Grok/Gemini — closing
20
+ * the gap RUSH-2033 left for every non-Claude agent (Claude still forces its own
21
+ * id up front and never reaches here).
22
+ */
23
+ import { sshExec } from '../ssh-exec.js';
24
+ /**
25
+ * Pick the real session id for `launchId` from the remote hook records.
26
+ *
27
+ * `recordsJson` is the newline-delimited JSON the remote read emits — one record
28
+ * per line (a `cat` of every `sessions/*.json`). Scan for the record whose
29
+ * `launch_id` matches and return its `session_id`; when several match (pid reuse,
30
+ * a lingering file) the NEWEST by `ts` wins, mirroring the local reader's
31
+ * keep-newest tie-break (session/hook-sessions.ts). Pure so the correlation is
32
+ * unit-testable from fixtures with no SSH. Returns undefined when no record
33
+ * carries the launch id (the hook hasn't landed yet, or a hookless harness).
34
+ */
35
+ export function pickRemoteSessionId(recordsJson, launchId) {
36
+ if (!launchId)
37
+ return undefined;
38
+ let best;
39
+ for (const line of recordsJson.split('\n')) {
40
+ const trimmed = line.trim();
41
+ if (!trimmed)
42
+ continue;
43
+ let rec;
44
+ try {
45
+ rec = JSON.parse(trimmed);
46
+ }
47
+ catch {
48
+ continue; // a partial/garbled line — skip, never throw
49
+ }
50
+ if (rec.launch_id !== launchId)
51
+ continue;
52
+ if (typeof rec.session_id !== 'string' || !rec.session_id)
53
+ continue;
54
+ const ts = typeof rec.ts === 'number' ? rec.ts : 0;
55
+ // Strict `>` so the FIRST record wins a tie — matches keepNewest (hook-sessions.ts).
56
+ if (!best || ts > best.ts)
57
+ best = { sid: rec.session_id, ts };
58
+ }
59
+ return best?.sid;
60
+ }
61
+ /**
62
+ * Read the remote hook-session dir over ssh and resolve the real session id the
63
+ * remote run recorded under `launchId`. One round-trip, best-effort: any ssh
64
+ * failure, a missing dir, or an absent record yields undefined — the caller then
65
+ * keeps the un-mapped run rather than a fabricated id.
66
+ *
67
+ * `$HOME` expands on the REMOTE login shell (never the local box). The glob is a
68
+ * literal path with no user input, so it is injection-safe; a `2>/dev/null` on a
69
+ * globless dir keeps the command quiet when the dir is empty or absent.
70
+ */
71
+ export function resolveRemoteSessionId(target, launchId, timeoutMs = 6000) {
72
+ if (!launchId)
73
+ return undefined;
74
+ // `cat` every record onto its own line. `head -c` caps a runaway file; the
75
+ // trailing `true` keeps the pipeline's exit 0 even when the glob matches nothing.
76
+ const cmd = 'cat "$HOME"/.agents/.cache/terminals/sessions/*.json 2>/dev/null | head -c 1048576 || true';
77
+ const res = sshExec(target, cmd, { timeoutMs, multiplex: true });
78
+ if (res.code !== 0 && !res.stdout)
79
+ return undefined;
80
+ // Each record is a single-line JSON object; `cat` may run them together, so
81
+ // normalise `}{` boundaries onto separate lines before scanning.
82
+ const normalised = res.stdout.replace(/\}\s*\{/g, '}\n{');
83
+ return pickRemoteSessionId(normalised, launchId);
84
+ }
@@ -45,6 +45,8 @@ export interface HostPromptRun {
45
45
  name?: string;
46
46
  /** Resume an existing session on the host by concrete id. */
47
47
  resume?: string;
48
+ /** Explicit id for a new Claude session. */
49
+ sessionId?: string;
48
50
  /** Working directory on the host, already made remote-portable by the caller. */
49
51
  remoteCwd?: string;
50
52
  /** Stream progress and block until completion (default true). */
@@ -74,6 +76,8 @@ export interface HostPromptRun {
74
76
  /** Copy runtime credentials to the host before the run and shred them after. */
75
77
  copyCreds?: HostCredentials;
76
78
  }
79
+ /** Resolve the id the remote host will adopt for a fresh Claude session. */
80
+ export declare function resolveHostSessionId(agent: string, resume?: string, sessionId?: string): string | undefined;
77
81
  /**
78
82
  * Dispatch a headless prompt run onto a resolved host, then relate the run's
79
83
  * session id back to this launcher for EVERY agent — not just Claude.
@@ -53,6 +53,10 @@ export async function resolveHostRunTarget(name, opts = {}) {
53
53
  throw new HostResolutionError(`Unknown host "${name}". List hosts: agents hosts list`);
54
54
  return host;
55
55
  }
56
+ /** Resolve the id the remote host will adopt for a fresh Claude session. */
57
+ export function resolveHostSessionId(agent, resume, sessionId) {
58
+ return agent === 'claude' && !resume ? sessionId ?? randomUUID() : undefined;
59
+ }
56
60
  /**
57
61
  * Dispatch a headless prompt run onto a resolved host, then relate the run's
58
62
  * session id back to this launcher for EVERY agent — not just Claude.
@@ -69,7 +73,7 @@ export async function resolveHostRunTarget(name, opts = {}) {
69
73
  * while the run continues).
70
74
  */
71
75
  export async function dispatchPromptToHost(host, opts) {
72
- const forcedSessionId = opts.agent === 'claude' && !opts.resume ? randomUUID() : undefined;
76
+ const forcedSessionId = resolveHostSessionId(opts.agent, opts.resume, opts.sessionId);
73
77
  // Ask the remote to print its resolved id whenever we did NOT force one (every
74
78
  // non-Claude agent, and Claude-on-resume where the id is already known). No-op
75
79
  // when the run isn't followed — nothing tails the log to catch the marker.
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import * as fs from 'fs';
16
16
  import { upsertSession } from '../session/db.js';
17
- import { SESSION_AGENTS } from '../session/types.js';
17
+ import { isSessionTrackedAgent } from '../session/types.js';
18
18
  import { localLogPath, updateTask } from './tasks.js';
19
19
  import { parseSessionIdMarker } from './session-marker.js';
20
20
  import { deriveShortId } from '../session/short-id.js';
@@ -27,7 +27,7 @@ export function hostSessionMeta(task, ctx) {
27
27
  const id = task.sessionId;
28
28
  if (!id)
29
29
  return null;
30
- if (!SESSION_AGENTS.includes(task.agent))
30
+ if (!isSessionTrackedAgent(task.agent))
31
31
  return null;
32
32
  return {
33
33
  id,
@@ -99,7 +99,7 @@ export function captureRemoteSessionId(task) {
99
99
  * surface and resume it by id.
100
100
  */
101
101
  export function registerInteractiveHostSession(ctx) {
102
- if (!SESSION_AGENTS.includes(ctx.agent))
102
+ if (!isSessionTrackedAgent(ctx.agent))
103
103
  return;
104
104
  try {
105
105
  upsertSession({
@@ -8,6 +8,8 @@
8
8
  <string>com.phnx-labs.agents-menubar</string>
9
9
  <key>CFBundleName</key>
10
10
  <string>Agents Menu Bar</string>
11
+ <key>CFBundleIconFile</key>
12
+ <string>AppIcon</string>
11
13
  <key>CFBundlePackageType</key>
12
14
  <string>APPL</string>
13
15
  <key>CFBundleShortVersionString</key>
@@ -3,9 +3,22 @@
3
3
  <plist version="1.0">
4
4
  <dict>
5
5
  <key>files</key>
6
- <dict/>
6
+ <dict>
7
+ <key>Resources/AppIcon.icns</key>
8
+ <data>
9
+ Vd5nfogg74zSRNspluv4uvDecUA=
10
+ </data>
11
+ </dict>
7
12
  <key>files2</key>
8
- <dict/>
13
+ <dict>
14
+ <key>Resources/AppIcon.icns</key>
15
+ <dict>
16
+ <key>hash2</key>
17
+ <data>
18
+ DznVe0VgYOux7+B/aiHNgGYLCvSJKzgXDTPJn2jNkNg=
19
+ </data>
20
+ </dict>
21
+ </dict>
9
22
  <key>rules</key>
10
23
  <dict>
11
24
  <key>^Resources/</key>
@@ -14,6 +14,15 @@
14
14
  * migration (`installMenubarLaunchAgent` in migrate.ts) honors, so a disabled
15
15
  * menu bar never silently comes back on the next release.
16
16
  */
17
+ /**
18
+ * Absolute path to the installed MenubarHelper executable if it exists on disk,
19
+ * else null. The desktop notifier (notify-desktop.ts) routes daemon
20
+ * notifications through this one-shot (`MenubarHelper --notify ...`) so they
21
+ * carry the agents-cli mark rather than the generic osascript icon. Null on
22
+ * non-darwin or when the helper is not installed (menu bar disabled, a Linux
23
+ * package, or a dev checkout without a built bundle).
24
+ */
25
+ export declare function resolveInstalledMenubarExecutable(): string | null;
17
26
  /** True if the user explicitly disabled the menu bar (don't auto-enable on upgrade). */
18
27
  export declare function menubarDisabledByUser(): boolean;
19
28
  /** True if the launchd plist for the menu-bar service is installed. */
@@ -57,6 +57,20 @@ function readInstalledMenubarVersion() {
57
57
  function installedExecutablePath() {
58
58
  return path.join(installedAppPath(), 'Contents', 'MacOS', 'MenubarHelper');
59
59
  }
60
+ /**
61
+ * Absolute path to the installed MenubarHelper executable if it exists on disk,
62
+ * else null. The desktop notifier (notify-desktop.ts) routes daemon
63
+ * notifications through this one-shot (`MenubarHelper --notify ...`) so they
64
+ * carry the agents-cli mark rather than the generic osascript icon. Null on
65
+ * non-darwin or when the helper is not installed (menu bar disabled, a Linux
66
+ * package, or a dev checkout without a built bundle).
67
+ */
68
+ export function resolveInstalledMenubarExecutable() {
69
+ if (!onDarwin())
70
+ return null;
71
+ const exec = installedExecutablePath();
72
+ return fs.existsSync(exec) ? exec : null;
73
+ }
60
74
  /** ~/Library/LaunchAgents/com.phnx-labs.agents-menubar.plist */
61
75
  function servicePlistPath() {
62
76
  return path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Branded desktop notifications for daemon-originated events (RUSH-2030).
3
+ *
4
+ * The one place the daemon (overdue routines, heal, routine start/finish/output)
5
+ * emits a native desktop notification. On macOS it routes through the installed
6
+ * `MenubarHelper.app` companion — a one-shot `MenubarHelper --notify` invocation —
7
+ * so the notification is attributed to that bundle and carries the agents-cli /
8
+ * Agentialized mark instead of the generic AppleScript icon. When the companion
9
+ * app is not installed (menu bar disabled, a Linux package, a dev checkout), it
10
+ * degrades to `osascript` so an overdue/heal notice is never silently lost — the
11
+ * generic icon is the acceptable cost of preserving delivery, not a bug hidden by
12
+ * a fallback. On Linux it uses `notify-send`; every other platform is a no-op.
13
+ *
14
+ * All delivery is best-effort and fully detached: a missing binary surfaces as an
15
+ * async 'error' event (not a synchronous throw), so every spawn attaches an
16
+ * 'error' listener — without it Node re-throws ENOENT as an uncaught exception and
17
+ * takes the whole daemon down (the exact crash overdue.test.ts guards).
18
+ */
19
+ export interface DesktopNotification {
20
+ /** Bold first line. */
21
+ title: string;
22
+ /** Notification body text. */
23
+ body: string;
24
+ /** Secondary line under the title (macOS only). */
25
+ subtitle?: string;
26
+ /**
27
+ * Deep-link the companion app runs when the notification is clicked. Encoded
28
+ * as `<verb>:<arg>` — `open:/abs/path` opens a file (a run report or log) in the
29
+ * default app; `routines:list` opens the runs folder (~/.agents/.history/runs)
30
+ * in Finder. Best-effort and
31
+ * macOS-only (osascript / notify-send have no click target). See routine-notify.ts.
32
+ */
33
+ action?: string;
34
+ }
35
+ /** Argv for the MenubarHelper one-shot notify mode. Exported for tests. */
36
+ export declare function buildMenubarNotifyArgs(n: DesktopNotification): string[];
37
+ /** AppleScript for the osascript degradation path. Exported for tests. */
38
+ export declare function buildOsascriptNotifyArgs(n: DesktopNotification): string[];
39
+ /**
40
+ * Fire a native desktop notification, branded via the MenubarHelper companion on
41
+ * macOS. Best-effort — any failure is swallowed so a notification hiccup never
42
+ * blocks or crashes the daemon.
43
+ */
44
+ export declare function notifyDesktop(n: DesktopNotification): void;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Branded desktop notifications for daemon-originated events (RUSH-2030).
3
+ *
4
+ * The one place the daemon (overdue routines, heal, routine start/finish/output)
5
+ * emits a native desktop notification. On macOS it routes through the installed
6
+ * `MenubarHelper.app` companion — a one-shot `MenubarHelper --notify` invocation —
7
+ * so the notification is attributed to that bundle and carries the agents-cli /
8
+ * Agentialized mark instead of the generic AppleScript icon. When the companion
9
+ * app is not installed (menu bar disabled, a Linux package, a dev checkout), it
10
+ * degrades to `osascript` so an overdue/heal notice is never silently lost — the
11
+ * generic icon is the acceptable cost of preserving delivery, not a bug hidden by
12
+ * a fallback. On Linux it uses `notify-send`; every other platform is a no-op.
13
+ *
14
+ * All delivery is best-effort and fully detached: a missing binary surfaces as an
15
+ * async 'error' event (not a synchronous throw), so every spawn attaches an
16
+ * 'error' listener — without it Node re-throws ENOENT as an uncaught exception and
17
+ * takes the whole daemon down (the exact crash overdue.test.ts guards).
18
+ */
19
+ import { spawn } from 'child_process';
20
+ import * as os from 'os';
21
+ import { resolveInstalledMenubarExecutable } from './install-menubar.js';
22
+ /** Argv for the MenubarHelper one-shot notify mode. Exported for tests. */
23
+ export function buildMenubarNotifyArgs(n) {
24
+ const args = ['--notify', '--title', n.title, '--body', n.body];
25
+ if (n.subtitle)
26
+ args.push('--subtitle', n.subtitle);
27
+ if (n.action)
28
+ args.push('--action', n.action);
29
+ return args;
30
+ }
31
+ /** AppleScript for the osascript degradation path. Exported for tests. */
32
+ export function buildOsascriptNotifyArgs(n) {
33
+ const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
34
+ let script = `display notification "${esc(n.body)}" with title "${esc(n.title)}"`;
35
+ if (n.subtitle)
36
+ script += ` subtitle "${esc(n.subtitle)}"`;
37
+ return ['-e', script];
38
+ }
39
+ /** Spawn one detached, best-effort notifier process; swallow async ENOENT. */
40
+ function spawnDetachedQuiet(command, args) {
41
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
42
+ // A missing binary (headless box with no osascript/notify-send, or a helper
43
+ // that vanished) arrives as an async 'error' event. Without this listener Node
44
+ // re-throws it as an uncaught exception and crashes the daemon — swallow it.
45
+ child.on('error', () => { });
46
+ child.unref();
47
+ }
48
+ /**
49
+ * Fire a native desktop notification, branded via the MenubarHelper companion on
50
+ * macOS. Best-effort — any failure is swallowed so a notification hiccup never
51
+ * blocks or crashes the daemon.
52
+ */
53
+ export function notifyDesktop(n) {
54
+ const platform = os.platform();
55
+ try {
56
+ if (platform === 'darwin') {
57
+ const exec = resolveInstalledMenubarExecutable();
58
+ if (exec) {
59
+ // Branded path: the notification is attributed to MenubarHelper.app, so it
60
+ // shows the agents-cli mark and its click action is handled by the running
61
+ // helper's UNUserNotificationCenter delegate.
62
+ spawnDetachedQuiet(exec, buildMenubarNotifyArgs(n));
63
+ return;
64
+ }
65
+ // Companion app absent — degrade to osascript so delivery is preserved
66
+ // (generic icon; no click action). See the module header.
67
+ spawnDetachedQuiet('osascript', buildOsascriptNotifyArgs(n));
68
+ return;
69
+ }
70
+ if (platform === 'linux') {
71
+ spawnDetachedQuiet('notify-send', [n.title, n.body]);
72
+ }
73
+ // Other platforms: no native desktop notifier wired — no-op.
74
+ }
75
+ catch {
76
+ // Notification is best-effort; nothing to do.
77
+ }
78
+ }
@@ -22,10 +22,9 @@ export interface OverdueJob {
22
22
  * missed. One-shot jobs are excluded — they fire at most once. */
23
23
  export declare function detectOverdueJobs(now?: Date): OverdueJob[];
24
24
  /**
25
- * Fire a native desktop notification. Best-effort failures (missing
26
- * `osascript`/`notify-send`, no display, headless box) are swallowed so a
27
- * notification attempt can never take the daemon down.
25
+ * Fire a branded desktop notification listing the overdue jobs. Routed through
26
+ * the MenubarHelper companion (notify-desktop.ts) so it carries the agents-cli
27
+ * mark; clicking opens the runs folder (~/.agents/.history/runs). Best-effort —
28
+ * a missing notifier or absent display is swallowed and never crashes the daemon.
28
29
  */
29
- export declare function notifyDesktop(title: string, body: string): void;
30
- /** Fire a native desktop notification listing the overdue jobs. Best-effort. */
31
30
  export declare function notifyOverdue(jobs: OverdueJob[]): void;
@@ -12,9 +12,8 @@
12
12
  * `agents routines catchup` command that runs them on demand.
13
13
  */
14
14
  import { Cron } from 'croner';
15
- import * as os from 'os';
16
- import { spawn } from 'child_process';
17
15
  import { listJobs, getLatestRun, jobRunsOnThisDevice } from './routines.js';
16
+ import { notifyDesktop } from './menubar/notify-desktop.js';
18
17
  // Tolerance between "expected fire" and "recorded run start" — accounts for
19
18
  // the small gap between the cron tick and when the runner writes meta.json.
20
19
  const GRACE_MS = 60_000;
@@ -77,50 +76,18 @@ export function detectOverdueJobs(now = new Date()) {
77
76
  return overdue;
78
77
  }
79
78
  /**
80
- * Fire a native desktop notification. Best-effort failures (missing
81
- * `osascript`/`notify-send`, no display, headless box) are swallowed so a
82
- * notification attempt can never take the daemon down.
79
+ * Fire a branded desktop notification listing the overdue jobs. Routed through
80
+ * the MenubarHelper companion (notify-desktop.ts) so it carries the agents-cli
81
+ * mark; clicking opens the runs folder (~/.agents/.history/runs). Best-effort —
82
+ * a missing notifier or absent display is swallowed and never crashes the daemon.
83
83
  */
84
- export function notifyDesktop(title, body) {
85
- const platform = os.platform();
86
- try {
87
- if (platform === 'darwin') {
88
- const safeTitle = title.replace(/"/g, '\\"');
89
- const safeBody = body.replace(/"/g, '\\"');
90
- const child = spawn('osascript', ['-e', `display notification "${safeBody}" with title "${safeTitle}"`], { detached: true, stdio: 'ignore' });
91
- // A missing binary surfaces as an async 'error' event, NOT a synchronous
92
- // throw the try/catch would catch. Without a listener Node re-throws it as
93
- // an uncaught exception and takes the whole daemon down. Swallow it — the
94
- // notification is best-effort.
95
- child.on('error', () => { });
96
- child.unref();
97
- }
98
- else if (platform === 'linux') {
99
- const child = spawn('notify-send', [title, body], {
100
- detached: true,
101
- stdio: 'ignore',
102
- });
103
- // Headless Linux boxes have no `notify-send` (libnotify-bin); its ENOENT
104
- // arrives as an async 'error' event, not a throw. Without this listener
105
- // the daemon crashes on every overdue routine and systemd restart-loops it
106
- // (which also tears down the browser IPC socket). Swallow — best-effort.
107
- child.on('error', () => { });
108
- child.unref();
109
- }
110
- }
111
- catch {
112
- // Notification is best-effort; nothing to do.
113
- }
114
- }
115
- /** Fire a native desktop notification listing the overdue jobs. Best-effort. */
116
84
  export function notifyOverdue(jobs) {
117
85
  if (jobs.length === 0)
118
86
  return;
119
- const title = jobs.length === 1
120
- ? `Routine overdue: ${jobs[0].name}`
121
- : `${jobs.length} routines overdue`;
87
+ const title = jobs.length === 1 ? 'Routine overdue' : `${jobs.length} routines overdue`;
88
+ const subtitle = jobs.length === 1 ? jobs[0].name : undefined;
122
89
  const body = jobs.length === 1
123
90
  ? `Missed ${jobs[0].expectedAt.toLocaleString()}. Run: agents routines catchup`
124
91
  : `${jobs.map((j) => j.name).join(', ')} — agents routines catchup`;
125
- notifyDesktop(title, body);
92
+ notifyDesktop({ title, subtitle, body, action: 'routines:list' });
126
93
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Routine lifecycle desktop notifications (RUSH-2030).
3
+ *
4
+ * The daemon fires a branded notification when a scheduled routine starts and
5
+ * when it finishes (success or failure), routed through the MenubarHelper
6
+ * companion (notify-desktop.ts) so it carries the agents-cli mark.
7
+ *
8
+ * Anti-spam threshold (Acceptance Criteria #4 — "define a sensible threshold so
9
+ * users are not spammed"):
10
+ * - Agent / workflow routines: notify on BOTH start and finish. These are the
11
+ * runs whose output a user actually wants surfaced.
12
+ * - Command routines (deterministic housekeeping — version checks, `git pull`,
13
+ * notify shims that can fire every minute): notify only on FAILURE. A green
14
+ * housekeeping run is noise; a broken one is worth a ping. No start ping.
15
+ * - "Notable output" is folded into the single finish notification rather than
16
+ * sent as a third message: on failure the error reason, on success the first
17
+ * line of the run's report (the user-facing result) when the routine produced
18
+ * one. One start + one finish per run — never a stream.
19
+ *
20
+ * The pure builders (`routineStartNotification` / `routineFinishNotification`)
21
+ * return `null` when the threshold says "don't notify", and are unit-tested; the
22
+ * `notifyRoutine*` wrappers do the filesystem read + dispatch for the daemon.
23
+ */
24
+ import type { JobConfig, RunMeta } from './routines.js';
25
+ import { type DesktopNotification } from './menubar/notify-desktop.js';
26
+ type RoutineKind = 'agent' | 'workflow' | 'command';
27
+ /** Which flavor of routine a config/meta describes — drives the notify threshold. */
28
+ export declare function routineKind(r: Pick<JobConfig, 'agent' | 'workflow' | 'command'>): RoutineKind;
29
+ /** "1m 20s" / "45s" / "2h 3m" from a millisecond duration, or null when unknown. */
30
+ export declare function formatDuration(ms: number | undefined): string | null;
31
+ /**
32
+ * First non-empty line of a run report, trimmed to a notification-sized snippet.
33
+ * This is the "notable output" surfaced on a successful finish — the routine's
34
+ * own user-facing result. Returns null for an empty/whitespace report so the
35
+ * caller falls back to a plain "Completed" body.
36
+ */
37
+ export declare function notableSnippet(report: string | null | undefined, maxLen?: number): string | null;
38
+ /**
39
+ * Notification for a routine START, or null when the threshold suppresses it
40
+ * (command-mode housekeeping). Clicking opens the runs folder
41
+ * (~/.agents/.history/runs).
42
+ */
43
+ export declare function routineStartNotification(config: Pick<JobConfig, 'name' | 'agent' | 'workflow' | 'command'>): DesktopNotification | null;
44
+ /**
45
+ * Notification for a routine that failed to even START — `executeJobDetached`
46
+ * threw before the child was spawned, so no run record and no finish will ever
47
+ * exist. The daemon fires the START notification unconditionally, so this closes
48
+ * the "exactly one start + one finish" invariant: the orphaned start gets its
49
+ * matching failure banner (RUSH-2030). Unlike a green finish this is never
50
+ * suppressed — a broken start is worth a ping for every routine kind, including
51
+ * command housekeeping. Clicking opens the runs folder (~/.agents/.history/runs)
52
+ * since there is no run report to open.
53
+ */
54
+ export declare function routineStartFailedNotification(config: Pick<JobConfig, 'name' | 'agent' | 'workflow' | 'command'>, error: string): DesktopNotification;
55
+ /**
56
+ * Notification for a routine FINISH, or null when the threshold suppresses it
57
+ * (a successful command-mode housekeeping run). Success carries the report's
58
+ * first line when present (the notable output); failure carries the reason.
59
+ * Clicking opens the run report/log when one is available, else the runs folder
60
+ * (~/.agents/.history/runs).
61
+ */
62
+ export declare function routineFinishNotification(meta: Pick<RunMeta, 'jobName' | 'status' | 'exitCode' | 'errorMessage' | 'duration' | 'agent' | 'workflow' | 'command'>, opts?: {
63
+ report?: string | null;
64
+ artifactPath?: string | null;
65
+ }): DesktopNotification | null;
66
+ /** Daemon glue: fire the START notification for a triggered routine. Best-effort. */
67
+ export declare function notifyRoutineStart(config: JobConfig): void;
68
+ /**
69
+ * Daemon glue: fire the "failed to start" notification when a routine trigger
70
+ * threw before spawning a child. Pairs with the unconditional START ping so a
71
+ * pre-spawn failure never leaves an orphaned "Routine started". Best-effort.
72
+ */
73
+ export declare function notifyRoutineStartFailed(config: JobConfig, error: string): void;
74
+ /** Daemon glue: fire the FINISH notification for a completed run. Best-effort. */
75
+ export declare function notifyRoutineFinish(meta: RunMeta): void;
76
+ export {};