@phnx-labs/agents-cli 1.20.74 → 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 +1055 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +18 -0
- package/dist/commands/exec.js +83 -13
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +38 -8
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +286 -15
- package/dist/commands/secrets.js +102 -37
- package/dist/commands/sessions-browser.js +12 -2
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +184 -16
- package/dist/commands/sessions.d.ts +56 -14
- package/dist/commands/sessions.js +322 -62
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +110 -37
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +15 -6
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +24 -4
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +59 -7
- package/dist/lib/activity.d.ts +17 -6
- package/dist/lib/activity.js +466 -40
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +21 -0
- package/dist/lib/agents.js +70 -6
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.js +4 -0
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +114 -11
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +37 -1
- package/dist/lib/devices/fleet.js +36 -2
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +82 -0
- package/dist/lib/exec.js +218 -6
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +16 -0
- package/dist/lib/hosts/dispatch.js +26 -4
- package/dist/lib/hosts/passthrough.js +9 -2
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +32 -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 +17 -5
- package/dist/lib/hosts/run-target.js +30 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +40 -5
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +2 -0
- package/dist/lib/import.js +35 -2
- package/dist/lib/menubar/install-menubar.d.ts +29 -0
- package/dist/lib/menubar/install-menubar.js +59 -1
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +6 -2
- package/dist/lib/overdue.js +10 -36
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- 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 +94 -0
- package/dist/lib/routines.js +93 -9
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +242 -20
- 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 +74 -4
- package/dist/lib/secrets/agent.js +181 -107
- package/dist/lib/secrets/bundles.d.ts +54 -6
- package/dist/lib/secrets/bundles.js +230 -34
- package/dist/lib/secrets/index.d.ts +26 -3
- package/dist/lib/secrets/index.js +42 -12
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +124 -7
- package/dist/lib/session/active.js +390 -56
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +54 -0
- package/dist/lib/session/db.js +320 -19
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +478 -3
- package/dist/lib/session/discover.js +1740 -472
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +141 -32
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -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.d.ts +7 -2
- package/dist/lib/session/render.js +40 -26
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +99 -13
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +20 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +8 -4
- package/dist/lib/state.js +21 -37
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +47 -2
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +9 -2
- package/dist/lib/versions.js +37 -8
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- 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,11 +76,21 @@ 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
|
-
* Dispatch a headless prompt run onto a resolved host
|
|
79
|
-
* id
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
82
|
+
* Dispatch a headless prompt run onto a resolved host, then relate the run's
|
|
83
|
+
* session id back to this launcher for EVERY agent — not just Claude.
|
|
84
|
+
*
|
|
85
|
+
* Claude is the only agent that accepts a forced `--session-id`, so we mint one
|
|
86
|
+
* up front and know it immediately. For every other agent the remote run coins
|
|
87
|
+
* its OWN id; we forward `--emit-session-id` so the remote prints that id as a
|
|
88
|
+
* stdout sentinel (hosts/session-marker.ts), and once the follow returns we parse
|
|
89
|
+
* it out of the mirrored local log and stamp it on the task before registering —
|
|
90
|
+
* so `agents sessions`/resume-by-id can map the discovered session home. On
|
|
91
|
+
* resume the remote session keeps its existing id (passed through, no capture).
|
|
92
|
+
*
|
|
93
|
+
* Returns the task record and the remote exit code (`-1` = follow window closed
|
|
94
|
+
* while the run continues).
|
|
83
95
|
*/
|
|
84
96
|
export declare function dispatchPromptToHost(host: Host, opts: HostPromptRun): Promise<DispatchResult>;
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { randomUUID } from 'crypto';
|
|
18
18
|
import { resolveHost, resolveHostByCap } from './registry.js';
|
|
19
19
|
import { dispatchToHost } from './dispatch.js';
|
|
20
|
-
import { registerHostSession } from './session-index.js';
|
|
20
|
+
import { registerHostSession, captureRemoteSessionId } from './session-index.js';
|
|
21
21
|
/**
|
|
22
22
|
* Resolution failed with a user-actionable message the caller should print
|
|
23
23
|
* verbatim. Distinct from `DeviceOffloadUnsupportedError` (which propagates to
|
|
@@ -53,15 +53,31 @@ 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
|
-
* Dispatch a headless prompt run onto a resolved host
|
|
58
|
-
* id
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
61
|
+
* Dispatch a headless prompt run onto a resolved host, then relate the run's
|
|
62
|
+
* session id back to this launcher for EVERY agent — not just Claude.
|
|
63
|
+
*
|
|
64
|
+
* Claude is the only agent that accepts a forced `--session-id`, so we mint one
|
|
65
|
+
* up front and know it immediately. For every other agent the remote run coins
|
|
66
|
+
* its OWN id; we forward `--emit-session-id` so the remote prints that id as a
|
|
67
|
+
* stdout sentinel (hosts/session-marker.ts), and once the follow returns we parse
|
|
68
|
+
* it out of the mirrored local log and stamp it on the task before registering —
|
|
69
|
+
* so `agents sessions`/resume-by-id can map the discovered session home. On
|
|
70
|
+
* resume the remote session keeps its existing id (passed through, no capture).
|
|
71
|
+
*
|
|
72
|
+
* Returns the task record and the remote exit code (`-1` = follow window closed
|
|
73
|
+
* while the run continues).
|
|
62
74
|
*/
|
|
63
75
|
export async function dispatchPromptToHost(host, opts) {
|
|
64
|
-
const
|
|
76
|
+
const forcedSessionId = resolveHostSessionId(opts.agent, opts.resume, opts.sessionId);
|
|
77
|
+
// Ask the remote to print its resolved id whenever we did NOT force one (every
|
|
78
|
+
// non-Claude agent, and Claude-on-resume where the id is already known). No-op
|
|
79
|
+
// when the run isn't followed — nothing tails the log to catch the marker.
|
|
80
|
+
const emitSessionId = !forcedSessionId && !opts.resume && opts.follow !== false;
|
|
65
81
|
const result = await dispatchToHost(host, {
|
|
66
82
|
agent: opts.agent,
|
|
67
83
|
prompt: opts.prompt,
|
|
@@ -69,7 +85,8 @@ export async function dispatchPromptToHost(host, opts) {
|
|
|
69
85
|
mode: opts.mode,
|
|
70
86
|
model: opts.model,
|
|
71
87
|
remoteCwd: opts.remoteCwd,
|
|
72
|
-
sessionId,
|
|
88
|
+
sessionId: forcedSessionId,
|
|
89
|
+
emitSessionId,
|
|
73
90
|
name: opts.name,
|
|
74
91
|
resume: opts.resume,
|
|
75
92
|
follow: opts.follow !== false,
|
|
@@ -94,6 +111,9 @@ export async function dispatchPromptToHost(host, opts) {
|
|
|
94
111
|
passthroughArgs: opts.passthroughArgs,
|
|
95
112
|
copyCreds: opts.copyCreds,
|
|
96
113
|
});
|
|
97
|
-
|
|
98
|
-
|
|
114
|
+
// Capture the remote-coined id from the followed log (non-Claude); harmlessly a
|
|
115
|
+
// no-op when the task already carries a forced/resumed id or no marker landed.
|
|
116
|
+
const task = emitSessionId ? captureRemoteSessionId(result.task) ?? result.task : result.task;
|
|
117
|
+
registerHostSession(task, { cwd: opts.cwd ?? process.cwd(), prompt: opts.prompt });
|
|
118
|
+
return { ...result, task };
|
|
99
119
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `[cloud/<status>]` convention.
|
|
14
14
|
*/
|
|
15
15
|
import type { SessionMeta } from '../session/types.js';
|
|
16
|
-
import type
|
|
16
|
+
import { type HostTask } from './tasks.js';
|
|
17
17
|
export interface HostSessionContext {
|
|
18
18
|
/** Local directory the `agents run --host` was invoked from. */
|
|
19
19
|
cwd: string;
|
|
@@ -32,6 +32,23 @@ export declare function hostSessionMeta(task: HostTask, ctx: HostSessionContext)
|
|
|
32
32
|
* break the dispatch itself, which has already been launched on the host.
|
|
33
33
|
*/
|
|
34
34
|
export declare function registerHostSession(task: HostTask, ctx: HostSessionContext): void;
|
|
35
|
+
/**
|
|
36
|
+
* Relate a remote-created session id back to a followed host dispatch.
|
|
37
|
+
*
|
|
38
|
+
* The remote run (dispatched with `--emit-session-id`) prints its resolved id as
|
|
39
|
+
* a stdout sentinel (see session-marker.ts) that rides the followed log into the
|
|
40
|
+
* task's local mirror. Read that mirror, parse the id, and stamp it on the task
|
|
41
|
+
* so `findTaskBySessionId` and the session-index registration work for agents
|
|
42
|
+
* that never take a forced `--session-id` — closing the gap where every
|
|
43
|
+
* non-Claude host run was orphaned.
|
|
44
|
+
*
|
|
45
|
+
* Returns the updated task when an id was captured (and differs from what's on
|
|
46
|
+
* the record), else null. A task that already carries an id (Claude's forced id,
|
|
47
|
+
* a resume) is left untouched — the marker only fills a genuinely empty slot, so
|
|
48
|
+
* it can never overwrite an authoritative id with a stale echo. Best-effort: a
|
|
49
|
+
* missing/unreadable mirror or absent marker yields null, never an exception.
|
|
50
|
+
*/
|
|
51
|
+
export declare function captureRemoteSessionId(task: HostTask): HostTask | null;
|
|
35
52
|
export interface InteractiveHostSessionContext {
|
|
36
53
|
cwd: string;
|
|
37
54
|
host: string;
|
|
@@ -12,8 +12,12 @@
|
|
|
12
12
|
* local run. The `[host/<name>]` label mirrors the cloud path's
|
|
13
13
|
* `[cloud/<status>]` convention.
|
|
14
14
|
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
15
16
|
import { upsertSession } from '../session/db.js';
|
|
16
|
-
import {
|
|
17
|
+
import { isSessionTrackedAgent } from '../session/types.js';
|
|
18
|
+
import { localLogPath, updateTask } from './tasks.js';
|
|
19
|
+
import { parseSessionIdMarker } from './session-marker.js';
|
|
20
|
+
import { deriveShortId } from '../session/short-id.js';
|
|
17
21
|
/**
|
|
18
22
|
* Build the SessionMeta for a host-dispatched run. Returns null when the run has
|
|
19
23
|
* no captured session id (nothing stable to key/resume on) or its agent isn't a
|
|
@@ -23,11 +27,11 @@ export function hostSessionMeta(task, ctx) {
|
|
|
23
27
|
const id = task.sessionId;
|
|
24
28
|
if (!id)
|
|
25
29
|
return null;
|
|
26
|
-
if (!
|
|
30
|
+
if (!isSessionTrackedAgent(task.agent))
|
|
27
31
|
return null;
|
|
28
32
|
return {
|
|
29
33
|
id,
|
|
30
|
-
shortId: id
|
|
34
|
+
shortId: deriveShortId(id),
|
|
31
35
|
agent: task.agent,
|
|
32
36
|
timestamp: task.createdAt,
|
|
33
37
|
cwd: ctx.cwd,
|
|
@@ -57,6 +61,37 @@ export function registerHostSession(task, ctx) {
|
|
|
57
61
|
/* index write is best-effort; the run is already live on the host */
|
|
58
62
|
}
|
|
59
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Relate a remote-created session id back to a followed host dispatch.
|
|
66
|
+
*
|
|
67
|
+
* The remote run (dispatched with `--emit-session-id`) prints its resolved id as
|
|
68
|
+
* a stdout sentinel (see session-marker.ts) that rides the followed log into the
|
|
69
|
+
* task's local mirror. Read that mirror, parse the id, and stamp it on the task
|
|
70
|
+
* so `findTaskBySessionId` and the session-index registration work for agents
|
|
71
|
+
* that never take a forced `--session-id` — closing the gap where every
|
|
72
|
+
* non-Claude host run was orphaned.
|
|
73
|
+
*
|
|
74
|
+
* Returns the updated task when an id was captured (and differs from what's on
|
|
75
|
+
* the record), else null. A task that already carries an id (Claude's forced id,
|
|
76
|
+
* a resume) is left untouched — the marker only fills a genuinely empty slot, so
|
|
77
|
+
* it can never overwrite an authoritative id with a stale echo. Best-effort: a
|
|
78
|
+
* missing/unreadable mirror or absent marker yields null, never an exception.
|
|
79
|
+
*/
|
|
80
|
+
export function captureRemoteSessionId(task) {
|
|
81
|
+
if (task.sessionId)
|
|
82
|
+
return null;
|
|
83
|
+
let text;
|
|
84
|
+
try {
|
|
85
|
+
text = fs.readFileSync(localLogPath(task.id), 'utf8');
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null; // no local mirror (unfollowed run, or read raced the follow)
|
|
89
|
+
}
|
|
90
|
+
const captured = parseSessionIdMarker(text);
|
|
91
|
+
if (!captured)
|
|
92
|
+
return null;
|
|
93
|
+
return updateTask(task.id, { sessionId: captured });
|
|
94
|
+
}
|
|
60
95
|
/**
|
|
61
96
|
* Register an interactive host run (no prompt, TTY forwarded over SSH) in the
|
|
62
97
|
* local session index. Unlike detached host runs, there is no remote log/exit
|
|
@@ -64,12 +99,12 @@ export function registerHostSession(task, ctx) {
|
|
|
64
99
|
* surface and resume it by id.
|
|
65
100
|
*/
|
|
66
101
|
export function registerInteractiveHostSession(ctx) {
|
|
67
|
-
if (!
|
|
102
|
+
if (!isSessionTrackedAgent(ctx.agent))
|
|
68
103
|
return;
|
|
69
104
|
try {
|
|
70
105
|
upsertSession({
|
|
71
106
|
id: ctx.sessionId,
|
|
72
|
-
shortId: ctx.sessionId
|
|
107
|
+
shortId: deriveShortId(ctx.sessionId),
|
|
73
108
|
agent: ctx.agent,
|
|
74
109
|
timestamp: ctx.createdAt ?? new Date().toISOString(),
|
|
75
110
|
cwd: ctx.cwd,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relate a remote-created session id back to the launching agent.
|
|
3
|
+
*
|
|
4
|
+
* A host dispatch (`agents run --host`) launches `agents run` on the remote box.
|
|
5
|
+
* Only Claude accepts a forced `--session-id`, so for every OTHER agent the
|
|
6
|
+
* remote run coins its OWN session id (via its SessionStart hook) and the
|
|
7
|
+
* launcher never learns it — the run is orphaned in `agents sessions`.
|
|
8
|
+
*
|
|
9
|
+
* This module closes that gap without an extra SSH round-trip: the dispatch
|
|
10
|
+
* forwards `--emit-session-id`, which makes the remote `agents run` print its
|
|
11
|
+
* resolved session id as a stable one-line sentinel to stdout. That line rides
|
|
12
|
+
* the same combined log the follow already mirrors locally, so the launcher
|
|
13
|
+
* parses the id straight out of the streamed output and stamps it on the task.
|
|
14
|
+
*
|
|
15
|
+
* The marker is a fixed ASCII frame with the id on its own line. A real session
|
|
16
|
+
* id (`[A-Za-z0-9._-]`) never contains the marker's own bytes, so the parser can
|
|
17
|
+
* scan for the LAST occurrence and stay robust to an agent echoing the token in
|
|
18
|
+
* its own output.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* The single line the remote run prints to stdout so the launcher can capture
|
|
22
|
+
* the session id the remote coined. Newline-framed on both sides so it lands on
|
|
23
|
+
* its own line regardless of what the agent last wrote.
|
|
24
|
+
*/
|
|
25
|
+
export declare function sessionIdMarkerLine(sessionId: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Extract the session id from a chunk of followed remote output, or null when no
|
|
28
|
+
* marker is present. Scans for the LAST marker (so an id echoed into the log
|
|
29
|
+
* earlier can never mask the real trailing sentinel) and validates the captured
|
|
30
|
+
* token against the session-id charset — a malformed frame yields null rather
|
|
31
|
+
* than a bogus id that could never match a real session.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseSessionIdMarker(text: string): string | null;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relate a remote-created session id back to the launching agent.
|
|
3
|
+
*
|
|
4
|
+
* A host dispatch (`agents run --host`) launches `agents run` on the remote box.
|
|
5
|
+
* Only Claude accepts a forced `--session-id`, so for every OTHER agent the
|
|
6
|
+
* remote run coins its OWN session id (via its SessionStart hook) and the
|
|
7
|
+
* launcher never learns it — the run is orphaned in `agents sessions`.
|
|
8
|
+
*
|
|
9
|
+
* This module closes that gap without an extra SSH round-trip: the dispatch
|
|
10
|
+
* forwards `--emit-session-id`, which makes the remote `agents run` print its
|
|
11
|
+
* resolved session id as a stable one-line sentinel to stdout. That line rides
|
|
12
|
+
* the same combined log the follow already mirrors locally, so the launcher
|
|
13
|
+
* parses the id straight out of the streamed output and stamps it on the task.
|
|
14
|
+
*
|
|
15
|
+
* The marker is a fixed ASCII frame with the id on its own line. A real session
|
|
16
|
+
* id (`[A-Za-z0-9._-]`) never contains the marker's own bytes, so the parser can
|
|
17
|
+
* scan for the LAST occurrence and stay robust to an agent echoing the token in
|
|
18
|
+
* its own output.
|
|
19
|
+
*/
|
|
20
|
+
const MARKER_PREFIX = '@@AGENTS_SESSION_ID ';
|
|
21
|
+
const MARKER_SUFFIX = '@@';
|
|
22
|
+
/** Only characters a real agent session id can hold — no marker bytes, no spaces. */
|
|
23
|
+
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/;
|
|
24
|
+
/**
|
|
25
|
+
* The single line the remote run prints to stdout so the launcher can capture
|
|
26
|
+
* the session id the remote coined. Newline-framed on both sides so it lands on
|
|
27
|
+
* its own line regardless of what the agent last wrote.
|
|
28
|
+
*/
|
|
29
|
+
export function sessionIdMarkerLine(sessionId) {
|
|
30
|
+
return `\n${MARKER_PREFIX}${sessionId}${MARKER_SUFFIX}\n`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Extract the session id from a chunk of followed remote output, or null when no
|
|
34
|
+
* marker is present. Scans for the LAST marker (so an id echoed into the log
|
|
35
|
+
* earlier can never mask the real trailing sentinel) and validates the captured
|
|
36
|
+
* token against the session-id charset — a malformed frame yields null rather
|
|
37
|
+
* than a bogus id that could never match a real session.
|
|
38
|
+
*/
|
|
39
|
+
export function parseSessionIdMarker(text) {
|
|
40
|
+
const last = text.lastIndexOf(MARKER_PREFIX);
|
|
41
|
+
if (last === -1)
|
|
42
|
+
return null;
|
|
43
|
+
const start = last + MARKER_PREFIX.length;
|
|
44
|
+
const end = text.indexOf(MARKER_SUFFIX, start);
|
|
45
|
+
if (end === -1)
|
|
46
|
+
return null;
|
|
47
|
+
const id = text.slice(start, end).trim();
|
|
48
|
+
if (!SESSION_ID_RE.test(id))
|
|
49
|
+
return null;
|
|
50
|
+
return id;
|
|
51
|
+
}
|
|
@@ -24,10 +24,14 @@ export interface HostTask {
|
|
|
24
24
|
*/
|
|
25
25
|
name?: string;
|
|
26
26
|
/**
|
|
27
|
-
* The agent session id
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
* The remote run's agent session id, so `agents sessions`/resume-by-id can map
|
|
28
|
+
* a discovered session back to the host it lives on. Two sources: for Claude
|
|
29
|
+
* (the only agent that accepts `--session-id`) it's the id we FORCED at
|
|
30
|
+
* dispatch; for every other agent it's the id the remote COINED, captured from
|
|
31
|
+
* the run's stdout sentinel (`--emit-session-id`, see session-marker.ts) once
|
|
32
|
+
* the follow returns and stamped on the record via `captureRemoteSessionId`.
|
|
33
|
+
* Absent only until that capture lands — e.g. an unfollowed (`--no-follow`)
|
|
34
|
+
* non-Claude run whose id the reconcile path fills in later.
|
|
31
35
|
*/
|
|
32
36
|
sessionId?: string;
|
|
33
37
|
/** Remote paths (under the host's ~/.agents/.cache/hosts/). */
|
package/dist/lib/import.d.ts
CHANGED
|
@@ -127,10 +127,12 @@ export declare function resolvePackageDirFromBinary(binaryPath: string): string
|
|
|
127
127
|
*/
|
|
128
128
|
export declare function seedIsolatedConfigFromLocal(agentId: AgentId, version: string, opts?: {
|
|
129
129
|
withAuth?: boolean;
|
|
130
|
+
all?: boolean;
|
|
130
131
|
}): {
|
|
131
132
|
seeded: boolean;
|
|
132
133
|
from: string;
|
|
133
134
|
to: string;
|
|
134
135
|
skippedAuth: string[];
|
|
136
|
+
skippedRuntime: string[];
|
|
135
137
|
error?: string;
|
|
136
138
|
};
|
package/dist/lib/import.js
CHANGED
|
@@ -263,13 +263,35 @@ export function seedIsolatedConfigFromLocal(agentId, version, opts = {}) {
|
|
|
263
263
|
const versionHome = path.join(getVersionsDir(), agentId, version, 'home');
|
|
264
264
|
// Mirror importAgentConfig's derivation so nested config dirs (e.g. Antigravity's
|
|
265
265
|
// ~/.gemini/antigravity-cli) land where the shim expects them.
|
|
266
|
-
|
|
267
|
-
|
|
266
|
+
let dest = path.join(versionHome, path.relative(os.homedir(), configDir));
|
|
267
|
+
// Codex uses a SUN_LEN-safe CODEX_HOME: `home/.codex` is a SYMLINK to
|
|
268
|
+
// `~/.agents/.codex-homes/<version>/.codex`, because the real path is too long for a
|
|
269
|
+
// unix socket. cpSync cannot overwrite a symlink with a directory (it fails with
|
|
270
|
+
// "Cannot overwrite non-directory"), and writing beside it would put settings
|
|
271
|
+
// somewhere the agent never reads. Follow the link and seed the actual home.
|
|
272
|
+
try {
|
|
273
|
+
if (fs.lstatSync(dest).isSymbolicLink())
|
|
274
|
+
dest = fs.realpathSync(dest);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
/* not created yet — the plain path is correct */
|
|
278
|
+
}
|
|
279
|
+
const result = { seeded: false, from: configDir, to: dest, skippedAuth: [], skippedRuntime: [] };
|
|
268
280
|
if (!fs.existsSync(configDir))
|
|
269
281
|
return result;
|
|
270
282
|
// Known credential paths, relative to the config dir. `authFiles` covers the agents
|
|
271
283
|
// that declare them for cross-version carry; the rest are verified filenames for
|
|
272
284
|
// agents that do not declare any (codex `auth.json`, claude `.credentials.json`).
|
|
285
|
+
// Runtime state, not settings. Seeding a sandbox with the user's session history,
|
|
286
|
+
// logs and caches duplicated 757MB on a real machine — 349MB of `sessions` alone —
|
|
287
|
+
// for a copy that wants config. These are regenerated by the agent as it runs, and
|
|
288
|
+
// an isolated copy keeps its own; carrying them over also drags conversation history
|
|
289
|
+
// into a sandbox the user may have created precisely to keep separate.
|
|
290
|
+
const RUNTIME_PREFIXES = ['sessions', 'log', 'logs', 'cache', '.tmp', 'tmp', 'generated_images'];
|
|
291
|
+
const RUNTIME_FILES = ['history.jsonl', 'session_index.jsonl'];
|
|
292
|
+
const isRuntime = (rel) => !opts.all && (RUNTIME_FILES.includes(rel) ||
|
|
293
|
+
/\.sqlite(-shm|-wal)?$/.test(rel) ||
|
|
294
|
+
RUNTIME_PREFIXES.some((d) => rel === d || rel.startsWith(d + path.sep)));
|
|
273
295
|
const authRel = new Set([
|
|
274
296
|
...(agent.authFiles ?? []),
|
|
275
297
|
'auth.json',
|
|
@@ -282,12 +304,23 @@ export function seedIsolatedConfigFromLocal(agentId, version, opts = {}) {
|
|
|
282
304
|
const inside = agentsDir + path.sep;
|
|
283
305
|
fs.cpSync(configDir, dest, {
|
|
284
306
|
recursive: true,
|
|
307
|
+
// `force: true` is Node's default, but Bun drops it when a `filter` is supplied —
|
|
308
|
+
// existing files are then silently left alone. `dist/bin/agents` is bun-compiled,
|
|
309
|
+
// so this is a production path, not just a test artifact. State it explicitly.
|
|
310
|
+
force: true,
|
|
285
311
|
filter: (src) => {
|
|
286
312
|
const rel = path.relative(configDir, src);
|
|
287
313
|
if (!opts.withAuth && rel && (authRel.has(rel) || rel.startsWith('credentials' + path.sep))) {
|
|
288
314
|
result.skippedAuth.push(rel);
|
|
289
315
|
return false;
|
|
290
316
|
}
|
|
317
|
+
if (rel && isRuntime(rel)) {
|
|
318
|
+
// Record only the top-level name so the report stays one line per item.
|
|
319
|
+
const top = rel.split(path.sep)[0];
|
|
320
|
+
if (!result.skippedRuntime.includes(top))
|
|
321
|
+
result.skippedRuntime.push(top);
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
291
324
|
// Same rule as the export path: a link into ~/.agents would dangle for a copy
|
|
292
325
|
// that is supposed to stand on its own.
|
|
293
326
|
try {
|
|
@@ -14,10 +14,39 @@
|
|
|
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. */
|
|
20
29
|
export declare function menubarServiceInstalled(): boolean;
|
|
30
|
+
/** True when the bundle carries a signature the kernel will accept at launch. */
|
|
31
|
+
export declare function codesignVerifies(appPath: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Guarantee the installed bundle has a valid signature on THIS machine.
|
|
34
|
+
*
|
|
35
|
+
* npm's pack/extract strips the ad-hoc/linker signature the release baked into
|
|
36
|
+
* the helper, leaving `code object is not signed at all`. On macOS 26+ the
|
|
37
|
+
* kernel's code-signing monitor SIGKILLs an unsigned/invalid binary at launch
|
|
38
|
+
* (`SIGKILL (Code Signature Invalid)`), so under the launchd `KeepAlive` service
|
|
39
|
+
* an ad-hoc release helper crash-loops forever and its unstable identity makes
|
|
40
|
+
* the Accessibility grant (needed for the clip→paste keystroke in Clip.swift)
|
|
41
|
+
* re-prompt every time. A fresh ad-hoc re-sign gives the on-disk bytes a
|
|
42
|
+
* matching cdhash, which the kernel accepts.
|
|
43
|
+
*
|
|
44
|
+
* A Developer-ID-signed helper survives npm untouched — its embedded signature
|
|
45
|
+
* still verifies — so we leave it alone and only re-sign when verification
|
|
46
|
+
* fails. Returns whether the bundle ends up validly signed. No-op cost on the
|
|
47
|
+
* common (already-valid) path is a single `codesign --verify`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function ensureValidSignature(appPath: string): boolean;
|
|
21
50
|
/**
|
|
22
51
|
* Copy the bundled `.app` to the stable user path (idempotent unless forced).
|
|
23
52
|
* Returns the installed executable path, or null if no source bundle ships
|
|
@@ -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`);
|
|
@@ -135,6 +149,37 @@ function copyAppBundle(src, dest) {
|
|
|
135
149
|
throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
|
|
136
150
|
}
|
|
137
151
|
}
|
|
152
|
+
/** True when the bundle carries a signature the kernel will accept at launch. */
|
|
153
|
+
export function codesignVerifies(appPath) {
|
|
154
|
+
const r = spawnSync('codesign', ['--verify', '--strict', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
155
|
+
return r.status === 0;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Guarantee the installed bundle has a valid signature on THIS machine.
|
|
159
|
+
*
|
|
160
|
+
* npm's pack/extract strips the ad-hoc/linker signature the release baked into
|
|
161
|
+
* the helper, leaving `code object is not signed at all`. On macOS 26+ the
|
|
162
|
+
* kernel's code-signing monitor SIGKILLs an unsigned/invalid binary at launch
|
|
163
|
+
* (`SIGKILL (Code Signature Invalid)`), so under the launchd `KeepAlive` service
|
|
164
|
+
* an ad-hoc release helper crash-loops forever and its unstable identity makes
|
|
165
|
+
* the Accessibility grant (needed for the clip→paste keystroke in Clip.swift)
|
|
166
|
+
* re-prompt every time. A fresh ad-hoc re-sign gives the on-disk bytes a
|
|
167
|
+
* matching cdhash, which the kernel accepts.
|
|
168
|
+
*
|
|
169
|
+
* A Developer-ID-signed helper survives npm untouched — its embedded signature
|
|
170
|
+
* still verifies — so we leave it alone and only re-sign when verification
|
|
171
|
+
* fails. Returns whether the bundle ends up validly signed. No-op cost on the
|
|
172
|
+
* common (already-valid) path is a single `codesign --verify`.
|
|
173
|
+
*/
|
|
174
|
+
export function ensureValidSignature(appPath) {
|
|
175
|
+
if (codesignVerifies(appPath))
|
|
176
|
+
return true;
|
|
177
|
+
// Drop any quarantine/xattrs the tarball round-trip added (they can break
|
|
178
|
+
// codesign), then re-sign ad-hoc under the helper's stable bundle identifier.
|
|
179
|
+
spawnSync('xattr', ['-cr', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
180
|
+
spawnSync('codesign', ['--force', '--sign', '-', '--identifier', SERVICE_LABEL, appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
181
|
+
return codesignVerifies(appPath);
|
|
182
|
+
}
|
|
138
183
|
/**
|
|
139
184
|
* Copy the bundled `.app` to the stable user path (idempotent unless forced).
|
|
140
185
|
* Returns the installed executable path, or null if no source bundle ships
|
|
@@ -147,9 +192,14 @@ export function ensureMenubarAppInstalled(opts = {}) {
|
|
|
147
192
|
if (!src)
|
|
148
193
|
return null;
|
|
149
194
|
const dest = installedAppPath();
|
|
150
|
-
if (!opts.forceReinstall && fs.existsSync(dest))
|
|
195
|
+
if (!opts.forceReinstall && fs.existsSync(dest)) {
|
|
196
|
+
// Self-heal an already-installed bundle whose signature npm stripped on a
|
|
197
|
+
// prior upgrade (macOS 26+ SIGKILLs it otherwise) without a forced recopy.
|
|
198
|
+
ensureValidSignature(dest);
|
|
151
199
|
return installedExecutablePath();
|
|
200
|
+
}
|
|
152
201
|
copyAppBundle(src, dest);
|
|
202
|
+
ensureValidSignature(dest);
|
|
153
203
|
return installedExecutablePath();
|
|
154
204
|
}
|
|
155
205
|
function xmlEscape(s) {
|
|
@@ -241,6 +291,14 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
|
|
|
241
291
|
const exec = ensureMenubarAppInstalled({ forceReinstall: true });
|
|
242
292
|
if (!exec)
|
|
243
293
|
return false;
|
|
294
|
+
// Never bootstrap a helper the kernel will kill on launch: an invalid
|
|
295
|
+
// signature under launchd KeepAlive is an infinite crash loop. If the bundle
|
|
296
|
+
// can't be made valid (re-sign already attempted in ensureMenubarAppInstalled),
|
|
297
|
+
// skip the service rather than spin the loop.
|
|
298
|
+
if (!codesignVerifies(installedAppPath())) {
|
|
299
|
+
process.stderr.write('agents: menu-bar helper has no valid code signature; skipping launch to avoid a crash loop.\n');
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
244
302
|
if (opts.clearOptOut) {
|
|
245
303
|
try {
|
|
246
304
|
fs.rmSync(disabledSentinelPath(), { force: true });
|