@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.
- package/CHANGELOG.md +162 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +58 -16
- package/dist/commands/routines.js +271 -13
- package/dist/commands/secrets.js +59 -17
- package/dist/commands/sessions-browser.js +11 -1
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +168 -15
- package/dist/commands/sessions.d.ts +23 -4
- package/dist/commands/sessions.js +128 -38
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +39 -2
- package/dist/index.js +2 -1
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- package/dist/lib/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +99 -12
- package/dist/lib/devices/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +27 -4
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hosts/dispatch.d.ts +9 -0
- package/dist/lib/hosts/dispatch.js +24 -4
- package/dist/lib/hosts/passthrough.js +7 -2
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +31 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +4 -0
- package/dist/lib/hosts/run-target.js +5 -1
- package/dist/lib/hosts/session-index.js +3 -3
- package/dist/lib/menubar/install-menubar.d.ts +9 -0
- package/dist/lib/menubar/install-menubar.js +14 -0
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/overdue.d.ts +4 -5
- package/dist/lib/overdue.js +8 -41
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +68 -0
- package/dist/lib/routines.js +74 -7
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +150 -25
- package/dist/lib/sandbox.js +10 -0
- package/dist/lib/secrets/account-token.d.ts +20 -0
- package/dist/lib/secrets/account-token.js +64 -0
- package/dist/lib/secrets/agent.d.ts +9 -4
- package/dist/lib/secrets/agent.js +30 -20
- package/dist/lib/secrets/bundles.d.ts +20 -6
- package/dist/lib/secrets/bundles.js +70 -34
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/session/active.d.ts +10 -0
- package/dist/lib/session/active.js +67 -4
- package/dist/lib/session/db.d.ts +6 -0
- package/dist/lib/session/db.js +83 -6
- package/dist/lib/session/discover.d.ts +13 -1
- package/dist/lib/session/discover.js +91 -3
- package/dist/lib/session/parse.js +11 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.js +18 -16
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +93 -11
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/state.d.ts +7 -3
- package/dist/lib/state.js +20 -5
- package/dist/lib/types.d.ts +10 -0
- 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 {
|
|
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
|
|
72
|
-
* noteworthy.
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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 {
|
|
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 (!
|
|
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 (!
|
|
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
|
+
}
|
package/dist/lib/overdue.d.ts
CHANGED
|
@@ -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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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;
|