@phnx-labs/agents-cli 1.20.76 → 1.20.77

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 (85) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/exec.js +58 -16
  4. package/dist/commands/routines.js +271 -13
  5. package/dist/commands/secrets.js +59 -17
  6. package/dist/commands/sessions-browser.js +11 -1
  7. package/dist/commands/sessions-picker.d.ts +14 -1
  8. package/dist/commands/sessions-picker.js +168 -15
  9. package/dist/commands/sessions.d.ts +23 -4
  10. package/dist/commands/sessions.js +128 -38
  11. package/dist/commands/ssh.d.ts +13 -0
  12. package/dist/commands/ssh.js +39 -2
  13. package/dist/index.js +2 -1
  14. package/dist/lib/activity.d.ts +5 -4
  15. package/dist/lib/activity.js +450 -36
  16. package/dist/lib/cloud/session-index.js +2 -2
  17. package/dist/lib/daemon.d.ts +10 -1
  18. package/dist/lib/daemon.js +99 -12
  19. package/dist/lib/devices/fleet.d.ts +16 -1
  20. package/dist/lib/devices/fleet.js +10 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/exec.d.ts +16 -0
  23. package/dist/lib/exec.js +27 -4
  24. package/dist/lib/feed.d.ts +7 -1
  25. package/dist/lib/feed.js +96 -6
  26. package/dist/lib/heal.d.ts +7 -4
  27. package/dist/lib/heal.js +10 -22
  28. package/dist/lib/hosts/dispatch.d.ts +9 -0
  29. package/dist/lib/hosts/dispatch.js +24 -4
  30. package/dist/lib/hosts/passthrough.js +7 -2
  31. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  32. package/dist/lib/hosts/remote-cmd.js +31 -8
  33. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  34. package/dist/lib/hosts/remote-session-id.js +84 -0
  35. package/dist/lib/hosts/run-target.d.ts +4 -0
  36. package/dist/lib/hosts/run-target.js +5 -1
  37. package/dist/lib/hosts/session-index.js +3 -3
  38. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  39. package/dist/lib/menubar/install-menubar.js +14 -0
  40. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  41. package/dist/lib/menubar/notify-desktop.js +78 -0
  42. package/dist/lib/overdue.d.ts +4 -5
  43. package/dist/lib/overdue.js +8 -41
  44. package/dist/lib/routine-notify.d.ts +76 -0
  45. package/dist/lib/routine-notify.js +190 -0
  46. package/dist/lib/routines-placement.d.ts +38 -0
  47. package/dist/lib/routines-placement.js +79 -0
  48. package/dist/lib/routines-project.d.ts +97 -0
  49. package/dist/lib/routines-project.js +349 -0
  50. package/dist/lib/routines.d.ts +68 -0
  51. package/dist/lib/routines.js +74 -7
  52. package/dist/lib/runner.d.ts +12 -2
  53. package/dist/lib/runner.js +150 -25
  54. package/dist/lib/sandbox.js +10 -0
  55. package/dist/lib/secrets/account-token.d.ts +20 -0
  56. package/dist/lib/secrets/account-token.js +64 -0
  57. package/dist/lib/secrets/agent.d.ts +9 -4
  58. package/dist/lib/secrets/agent.js +30 -20
  59. package/dist/lib/secrets/bundles.d.ts +20 -6
  60. package/dist/lib/secrets/bundles.js +70 -34
  61. package/dist/lib/secrets/index.d.ts +11 -2
  62. package/dist/lib/secrets/index.js +21 -9
  63. package/dist/lib/secrets/session-store.d.ts +6 -2
  64. package/dist/lib/secrets/session-store.js +65 -15
  65. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  66. package/dist/lib/secrets/unlock-hints.js +36 -0
  67. package/dist/lib/session/active.d.ts +10 -0
  68. package/dist/lib/session/active.js +67 -4
  69. package/dist/lib/session/db.d.ts +6 -0
  70. package/dist/lib/session/db.js +83 -6
  71. package/dist/lib/session/discover.d.ts +13 -1
  72. package/dist/lib/session/discover.js +91 -3
  73. package/dist/lib/session/parse.js +11 -2
  74. package/dist/lib/session/prompt.d.ts +7 -0
  75. package/dist/lib/session/prompt.js +37 -0
  76. package/dist/lib/session/provenance.d.ts +7 -0
  77. package/dist/lib/session/render.js +18 -16
  78. package/dist/lib/session/state.d.ts +4 -0
  79. package/dist/lib/session/state.js +93 -11
  80. package/dist/lib/session/types.d.ts +25 -1
  81. package/dist/lib/session/types.js +8 -0
  82. package/dist/lib/state.d.ts +7 -3
  83. package/dist/lib/state.js +20 -5
  84. package/dist/lib/types.d.ts +10 -0
  85. package/package.json +1 -1
package/dist/lib/heal.js CHANGED
@@ -30,7 +30,7 @@ import { repairPluginManifestFile } from './plugin-marketplace.js';
30
30
  import * as fs from 'fs';
31
31
  import * as path from 'path';
32
32
  import * as os from 'os';
33
- import { spawn } from 'child_process';
33
+ import { notifyDesktop } from './menubar/notify-desktop.js';
34
34
  // ─── diff → selection mapping ────────────────────────────────────────────────
35
35
  // Which ResourceSelection key each healable diff kind writes through. `rules`
36
36
  // re-syncs via the whole-memory channel (not name-scoped); `promptcuts` is not
@@ -68,10 +68,13 @@ export function summarizeHeal(r) {
68
68
  return parts.length > 0 ? parts.join(', ') : 'nothing to heal';
69
69
  }
70
70
  /**
71
- * Fire a native desktop notification when a background heal did something
72
- * noteworthy. Best-effort missing `osascript`/`notify-send` or no display is
73
- * swallowed. Silent when the pass auto-fixed everything and nothing needs the
74
- * operator (no point pinging them for routine self-healing).
71
+ * Fire a branded desktop notification when a background heal did something
72
+ * noteworthy. Routed through the MenubarHelper companion (notify-desktop.ts) so
73
+ * it carries the agents-cli mark; clicking opens the runs folder
74
+ * (~/.agents/.history/runs, via the `routines:list` action the companion
75
+ * understands). Best-effort — a missing
76
+ * notifier or no display is swallowed. Silent when the pass auto-fixed everything
77
+ * and nothing needs the operator (no point pinging them for routine self-healing).
75
78
  */
76
79
  export function notifyHeal(r) {
77
80
  const needsAttention = r.skippedPlugins.length;
@@ -79,27 +82,12 @@ export function notifyHeal(r) {
79
82
  if (needsAttention === 0 && healed === 0)
80
83
  return;
81
84
  const title = needsAttention > 0
82
- ? `agents: ${needsAttention} plugin${needsAttention === 1 ? '' : 's'} need attention`
85
+ ? `${needsAttention} plugin${needsAttention === 1 ? '' : 's'} need attention`
83
86
  : 'agents: auto-healed config gaps';
84
87
  const body = needsAttention > 0
85
88
  ? `${summarizeHeal(r)}. Run: agents doctor --fix`
86
89
  : summarizeHeal(r);
87
- const platform = os.platform();
88
- try {
89
- if (platform === 'darwin') {
90
- const safeTitle = title.replace(/"/g, '\\"');
91
- const safeBody = body.replace(/"/g, '\\"');
92
- const child = spawn('osascript', ['-e', `display notification "${safeBody}" with title "${safeTitle}"`], { detached: true, stdio: 'ignore' });
93
- child.unref();
94
- }
95
- else if (platform === 'linux') {
96
- const child = spawn('notify-send', [title, body], { detached: true, stdio: 'ignore' });
97
- child.unref();
98
- }
99
- }
100
- catch {
101
- // Notification is best-effort; nothing to do.
102
- }
90
+ notifyDesktop({ title, body, action: 'routines:list' });
103
91
  }
104
92
  // ─── central plugin layer (version-independent, runs once per heal) ──────────
105
93
  /**
@@ -20,6 +20,15 @@ import { type HostCredentials } from './credentials.js';
20
20
  * remainder. Any other path (absolute or relative) is quoted verbatim.
21
21
  */
22
22
  export declare function remoteCdPrefix(remoteCwd?: string): string;
23
+ /**
24
+ * Merge the resolved actor's provenance env UNDER a caller-supplied env, so every
25
+ * remote `agents …` invocation forwards `AGENTS_ACTOR*` / `GIT_*` across the SSH
26
+ * hop. Without it the remote re-resolves the actor from the ORIGINATING box's
27
+ * `SSH_CONNECTION` (the wrong IP) and mis-credits the run to the shared machine or
28
+ * `UNRESOLVED@<host>` (RUSH-2028). A caller-supplied value wins on any key
29
+ * collision, mirroring `buildExecEnv`'s `...options.env` precedence (exec.ts).
30
+ */
31
+ export declare function withActorEnv(env?: Record<string, string>): Record<string, string>;
23
32
  /**
24
33
  * Launch a detached login-shell command in its own Unix session/process group.
25
34
  *
@@ -12,8 +12,9 @@ import { randomUUID } from 'crypto';
12
12
  import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
13
13
  import { sshTargetFor } from './types.js';
14
14
  import { ensureHostReady } from './ready.js';
15
- import { remoteShellFor } from './remote-cmd.js';
15
+ import { remoteShellFor, posixEnvExports } from './remote-cmd.js';
16
16
  import { resolveRemoteOsSync } from './remote-os.js';
17
+ import { resolveActor, actorEnv } from '../actor.js';
17
18
  import { saveTask, updateTask, terminalPatch } from './tasks.js';
18
19
  import { followHostTask } from './progress.js';
19
20
  import { wrapHostCommandWithCredentials } from './credentials.js';
@@ -56,6 +57,17 @@ export function remoteCdPrefix(remoteCwd) {
56
57
  return `cd "$HOME"/${shellQuote(rest)} && `;
57
58
  return `cd ${shellQuote(remoteCwd)} && `;
58
59
  }
60
+ /**
61
+ * Merge the resolved actor's provenance env UNDER a caller-supplied env, so every
62
+ * remote `agents …` invocation forwards `AGENTS_ACTOR*` / `GIT_*` across the SSH
63
+ * hop. Without it the remote re-resolves the actor from the ORIGINATING box's
64
+ * `SSH_CONNECTION` (the wrong IP) and mis-credits the run to the shared machine or
65
+ * `UNRESOLVED@<host>` (RUSH-2028). A caller-supplied value wins on any key
66
+ * collision, mirroring `buildExecEnv`'s `...options.env` precedence (exec.ts).
67
+ */
68
+ export function withActorEnv(env) {
69
+ return { ...actorEnv(resolveActor()), ...(env ?? {}) };
70
+ }
59
71
  /**
60
72
  * Launch a detached login-shell command in its own Unix session/process group.
61
73
  *
@@ -174,10 +186,14 @@ async function launchDetached(host, target, opts) {
174
186
  const id = randomUUID().slice(0, 8);
175
187
  const remoteLog = `${REMOTE_DIR}/${id}.log`;
176
188
  const remoteExit = `${REMOTE_DIR}/${id}.exit`;
177
- // Inner command run under a login shell so PATH resolves `agents`.
189
+ // Inner command run under a login shell so PATH resolves `agents`. Export the
190
+ // resolved actor provenance first so the detached remote run inherits it
191
+ // instead of re-resolving from this box's SSH_CONNECTION (RUSH-2028).
178
192
  const invocation = ['agents', ...opts.forwardedArgs].map(shellQuote).join(' ');
179
193
  const cwd = remoteCdPrefix(opts.remoteCwd);
180
- let inner = `${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
194
+ const actorExports = posixEnvExports(withActorEnv());
195
+ const prelude = actorExports ? `${actorExports}; ` : '';
196
+ let inner = `${prelude}${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
181
197
  if (opts.copyCreds) {
182
198
  inner = wrapHostCommandWithCredentials(inner, opts.copyCreds);
183
199
  }
@@ -370,7 +386,11 @@ export async function runInteractiveOnHost(host, opts) {
370
386
  process.stderr.write(`[hosts] warning: ${w}\n`);
371
387
  const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
372
388
  const cwd = remoteCdPrefix(opts.remoteCwd);
373
- let remoteCmd = `${cwd}${invocation}`;
389
+ // Forward actor provenance so the interactive remote run inherits it rather
390
+ // than re-resolving from this box's SSH_CONNECTION (RUSH-2028).
391
+ const actorExports = posixEnvExports(withActorEnv());
392
+ const prelude = actorExports ? `${actorExports}; ` : '';
393
+ let remoteCmd = `${prelude}${cwd}${invocation}`;
374
394
  if (opts.copyCreds) {
375
395
  remoteCmd = wrapHostCommandWithCredentials(remoteCmd, opts.copyCreds);
376
396
  }
@@ -20,7 +20,7 @@ import chalk from 'chalk';
20
20
  import { assertValidSshTarget, sshStream } from '../ssh-exec.js';
21
21
  import { resolveHost, resolveHostByCap } from './registry.js';
22
22
  import { sshTargetFor } from './types.js';
23
- import { dispatchAgentsCommand } from './dispatch.js';
23
+ import { dispatchAgentsCommand, withActorEnv } from './dispatch.js';
24
24
  import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
25
25
  import { resolveRemoteOsSync } from './remote-os.js';
26
26
  import { machineId } from '../session/sync/config.js';
@@ -267,9 +267,14 @@ export async function maybeRunOnHost(command, allArgs) {
267
267
  // profile, and single-quoted env values would not expand $HOME/$PATH.
268
268
  const isDoctorCommand = command === 'doctor' || (command === 'teams' && forwarded[1] === 'doctor');
269
269
  const remoteOs = resolveRemoteOsSync(host.name);
270
- const env = isDoctorCommand && !/^win/i.test((remoteOs ?? '').trim())
270
+ const doctorPath = isDoctorCommand && !/^win/i.test((remoteOs ?? '').trim())
271
271
  ? { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' }
272
272
  : undefined;
273
+ // Forward actor provenance (AGENTS_ACTOR*/GIT_*) across the SSH hop, merged
274
+ // UNDER the doctor PATH so that PATH still wins — without this the remote
275
+ // re-resolves the actor from THIS box's SSH_CONNECTION and mis-credits it
276
+ // (RUSH-2028). Flows to both POSIX (export) and Windows ($env:) dialects.
277
+ const env = withActorEnv(doctorPath);
273
278
  const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, remoteOs, env);
274
279
  const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
275
280
  if (code === 255) {
@@ -68,6 +68,17 @@ export declare const RUN_OPTION_REJECT_MESSAGES: Record<string, string>;
68
68
  * POSIX form, so linux/macos are byte-for-byte unchanged.
69
69
  */
70
70
  export declare function buildRemoteAgentsInvocation(forwardedArgs: string[], remoteCwd?: string, os?: string, env?: Record<string, string>): string;
71
+ /**
72
+ * Build a POSIX `export K=V; …` prefix from an env map — empty string when the
73
+ * map is missing or empty. Values are rendered as shell LITERALS by default
74
+ * (single-quoted via {@link shellQuote}), so a `$(...)` or backtick in a value
75
+ * can never execute on the SSH target. Only {@link EXPAND_KEYS} (`PATH`) keep the
76
+ * expanding double-quote form (`\` and `"` escaped) so the remote `$HOME`/`$PATH`
77
+ * still resolve. Shared by {@link buildRemoteAgentsInvocation} and the
78
+ * detached/interactive dispatch builders (dispatch.ts) so every remote path
79
+ * exports env identically.
80
+ */
81
+ export declare function posixEnvExports(env?: Record<string, string>): string;
71
82
  /** The two remote shell dialects we build commands for. */
72
83
  export type RemoteShell = 'posix' | 'powershell';
73
84
  /**
@@ -144,18 +144,41 @@ export function buildRemoteAgentsInvocation(forwardedArgs, remoteCwd, os, env) {
144
144
  }
145
145
  const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
146
146
  const withCwd = remoteCwd ? `cd ${shellQuote(remoteCwd)} && ${inner}` : inner;
147
- if (!env || Object.keys(env).length === 0) {
148
- return `bash -lc ${shellQuote(withCwd)}`;
149
- }
150
147
  // Prepend env exports so the remote command sees the shims dir even when the
151
148
  // login shell hasn't sourced the interactive rc files that usually add it.
152
- // Values are double-quoted (not single-quoted) so remote variables like
153
- // $HOME and $PATH are expanded by the login shell.
154
- const exports = Object.entries(env)
155
- .map(([k, v]) => `export ${shellQuote(k)}="${v.replace(/[\\"]/g, '\\$&')}"`)
156
- .join('; ');
149
+ const exports = posixEnvExports(env);
150
+ if (!exports) {
151
+ return `bash -lc ${shellQuote(withCwd)}`;
152
+ }
157
153
  return `bash -lc ${shellQuote(`${exports}; ${withCwd}`)}`;
158
154
  }
155
+ /**
156
+ * Keys whose values are trusted-static and legitimately need remote shell
157
+ * expansion — `PATH` references the remote `$HOME`/`$PATH`. Every other key is
158
+ * rendered as a shell literal, so an attacker-influenceable value (notably actor
159
+ * provenance, whose name/email can come from a tailnet peer's whois or an
160
+ * unvalidated `AGENTS_ACTOR_*` env var) can never inject shell into a dispatch.
161
+ */
162
+ const EXPAND_KEYS = new Set(['PATH']);
163
+ /**
164
+ * Build a POSIX `export K=V; …` prefix from an env map — empty string when the
165
+ * map is missing or empty. Values are rendered as shell LITERALS by default
166
+ * (single-quoted via {@link shellQuote}), so a `$(...)` or backtick in a value
167
+ * can never execute on the SSH target. Only {@link EXPAND_KEYS} (`PATH`) keep the
168
+ * expanding double-quote form (`\` and `"` escaped) so the remote `$HOME`/`$PATH`
169
+ * still resolve. Shared by {@link buildRemoteAgentsInvocation} and the
170
+ * detached/interactive dispatch builders (dispatch.ts) so every remote path
171
+ * exports env identically.
172
+ */
173
+ export function posixEnvExports(env) {
174
+ if (!env || Object.keys(env).length === 0)
175
+ return '';
176
+ return Object.entries(env)
177
+ .map(([k, v]) => EXPAND_KEYS.has(k)
178
+ ? `export ${shellQuote(k)}="${v.replace(/[\\"]/g, '\\$&')}"`
179
+ : `export ${shellQuote(k)}=${shellQuote(v)}`)
180
+ .join('; ');
181
+ }
159
182
  /**
160
183
  * Pick the remote shell dialect from a recorded OS/platform string. A Windows
161
184
  * host (device-registry `platform: 'windows'`, or an enrolled `HostEntry.os`
@@ -0,0 +1,45 @@
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
+ /**
24
+ * Pick the real session id for `launchId` from the remote hook records.
25
+ *
26
+ * `recordsJson` is the newline-delimited JSON the remote read emits — one record
27
+ * per line (a `cat` of every `sessions/*.json`). Scan for the record whose
28
+ * `launch_id` matches and return its `session_id`; when several match (pid reuse,
29
+ * a lingering file) the NEWEST by `ts` wins, mirroring the local reader's
30
+ * keep-newest tie-break (session/hook-sessions.ts). Pure so the correlation is
31
+ * unit-testable from fixtures with no SSH. Returns undefined when no record
32
+ * carries the launch id (the hook hasn't landed yet, or a hookless harness).
33
+ */
34
+ export declare function pickRemoteSessionId(recordsJson: string, launchId: string): string | undefined;
35
+ /**
36
+ * Read the remote hook-session dir over ssh and resolve the real session id the
37
+ * remote run recorded under `launchId`. One round-trip, best-effort: any ssh
38
+ * failure, a missing dir, or an absent record yields undefined — the caller then
39
+ * keeps the un-mapped run rather than a fabricated id.
40
+ *
41
+ * `$HOME` expands on the REMOTE login shell (never the local box). The glob is a
42
+ * literal path with no user input, so it is injection-safe; a `2>/dev/null` on a
43
+ * globless dir keeps the command quiet when the dir is empty or absent.
44
+ */
45
+ export declare function resolveRemoteSessionId(target: string, launchId: string, timeoutMs?: number): string | undefined;
@@ -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({
@@ -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;