@phnx-labs/agents-cli 1.20.41 → 1.20.43
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 +14 -0
- package/dist/commands/computer-actions.js +1 -1
- package/dist/commands/computer.d.ts +2 -2
- package/dist/commands/computer.js +4 -4
- package/dist/commands/exec.js +18 -6
- package/dist/commands/go.js +6 -1
- package/dist/commands/hosts.js +10 -6
- package/dist/commands/secrets.js +26 -0
- package/dist/commands/sessions.js +30 -12
- package/dist/lib/browser/chrome.d.ts +22 -0
- package/dist/lib/browser/chrome.js +53 -13
- package/dist/lib/browser/service.js +13 -0
- package/dist/lib/computer-rpc.js +3 -3
- package/dist/lib/exec.d.ts +59 -0
- package/dist/lib/exec.js +163 -0
- package/dist/lib/hosts/dispatch.d.ts +5 -0
- package/dist/lib/hosts/dispatch.js +4 -0
- package/dist/lib/hosts/session-index.js +1 -0
- package/dist/lib/hosts/tasks.d.ts +15 -0
- package/dist/lib/hosts/tasks.js +16 -0
- package/dist/lib/menubar/install-menubar.js +2 -2
- package/dist/lib/rotate.d.ts +11 -6
- package/dist/lib/rotate.js +25 -11
- package/dist/lib/secrets/bundles.js +5 -3
- package/dist/lib/secrets/remote.js +14 -0
- package/dist/lib/secrets/sync.js +13 -0
- package/dist/lib/session/active.d.ts +49 -3
- package/dist/lib/session/active.js +139 -10
- package/dist/lib/session/db.d.ts +11 -0
- package/dist/lib/session/db.js +129 -50
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +14 -3
- package/dist/lib/session/remote.d.ts +4 -6
- package/dist/lib/session/remote.js +5 -12
- package/dist/lib/session/run-names.d.ts +32 -0
- package/dist/lib/session/run-names.js +63 -0
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/session/viewing-in.d.ts +54 -0
- package/dist/lib/session/viewing-in.js +155 -0
- package/dist/lib/ssh-tunnel.d.ts +1 -1
- package/dist/lib/ssh-tunnel.js +3 -3
- package/dist/lib/tmux/session.d.ts +46 -0
- package/dist/lib/tmux/session.js +84 -2
- package/dist/lib/usage.d.ts +5 -3
- package/dist/lib/usage.js +5 -3
- package/package.json +1 -1
package/dist/lib/exec.js
CHANGED
|
@@ -17,8 +17,11 @@ import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js'
|
|
|
17
17
|
import { sanitizeProcessEnv } from './secrets/bundles.js';
|
|
18
18
|
import { getShimsDir } from './state.js';
|
|
19
19
|
import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
|
|
20
|
+
import { recordRunName } from './session/run-names.js';
|
|
20
21
|
import { mailboxDir, isValidMailboxId } from './mailbox.js';
|
|
21
22
|
import { composeWin32CommandLine } from './platform/index.js';
|
|
23
|
+
import { isTmuxInstalled } from './tmux/binary.js';
|
|
24
|
+
import { shellQuote } from './ssh-exec.js';
|
|
22
25
|
/**
|
|
23
26
|
* Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
|
|
24
27
|
*
|
|
@@ -255,6 +258,12 @@ export function buildExecEnv(options) {
|
|
|
255
258
|
if (options.sessionId && isValidMailboxId(options.sessionId)) {
|
|
256
259
|
result.AGENTS_MAILBOX_DIR = mailboxDir(options.sessionId);
|
|
257
260
|
}
|
|
261
|
+
// Export the run's durable name (companion to AGENT_SESSION_ID) so a
|
|
262
|
+
// SessionStart hook / the agent can associate its transcript with the handle
|
|
263
|
+
// the user gave the run. Only set when --name was passed.
|
|
264
|
+
if (options.name) {
|
|
265
|
+
result.AGENT_SESSION_NAME = options.name;
|
|
266
|
+
}
|
|
258
267
|
return {
|
|
259
268
|
...result,
|
|
260
269
|
...options.env,
|
|
@@ -753,6 +762,131 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
|
|
|
753
762
|
});
|
|
754
763
|
});
|
|
755
764
|
}
|
|
765
|
+
/**
|
|
766
|
+
* Decide whether to run an interactive agent INSIDE a detached tmux session on
|
|
767
|
+
* the shared socket (then attach the current TTY) instead of a bare spawn.
|
|
768
|
+
*
|
|
769
|
+
* tmux-wrapping gives every interactive agent an exact, unique `%pane` handle so
|
|
770
|
+
* `agents sessions --active` can tell co-located agents apart, and lets `agents
|
|
771
|
+
* focus` re-attach a live session without forking it. Pure so the gate is unit-
|
|
772
|
+
* tested independently of the (side-effecting) spawn.
|
|
773
|
+
*
|
|
774
|
+
* All five guards must pass:
|
|
775
|
+
* - interactive — a headless `-p` run has no TTY to attach; keep bare spawn.
|
|
776
|
+
* - not Windows — no tmux path on win32.
|
|
777
|
+
* - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
|
|
778
|
+
* - not --raw — explicit opt-out.
|
|
779
|
+
* - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
|
|
780
|
+
* - tmux installed — otherwise there is nothing to wrap with.
|
|
781
|
+
*/
|
|
782
|
+
export function shouldWrapInTmux(ctx) {
|
|
783
|
+
if (!ctx.interactive)
|
|
784
|
+
return false;
|
|
785
|
+
if (ctx.platform === 'win32')
|
|
786
|
+
return false;
|
|
787
|
+
if (ctx.inTmux)
|
|
788
|
+
return false;
|
|
789
|
+
if (ctx.raw)
|
|
790
|
+
return false;
|
|
791
|
+
if (ctx.noTmuxEnv)
|
|
792
|
+
return false;
|
|
793
|
+
if (!ctx.tmuxAvailable)
|
|
794
|
+
return false;
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Build the shell command that runs an agent inside a tmux pane with the exact
|
|
799
|
+
* env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
|
|
800
|
+
* K=V … <agent> <args…>` so:
|
|
801
|
+
* - `env` materializes the full agent env INTO the pane, independent of the
|
|
802
|
+
* (possibly stale, shared) tmux server environment — additive, so tmux's own
|
|
803
|
+
* $TMUX / $TMUX_PANE still reach the agent for provenance detection;
|
|
804
|
+
* - `exec` replaces the shell so the agent is the pane's leaf process (clean
|
|
805
|
+
* `#{pane_pid}`, clean signal delivery on detach/kill).
|
|
806
|
+
* Keys are filtered to valid identifiers so exported shell functions
|
|
807
|
+
* (`BASH_FUNC_*%%`) can't make `env` choke.
|
|
808
|
+
*/
|
|
809
|
+
export function buildTmuxAgentCommand(executable, args, env) {
|
|
810
|
+
const envPrefix = Object.entries(env)
|
|
811
|
+
.filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
|
|
812
|
+
.map(([k, v]) => `${k}=${shellQuote(String(v))}`)
|
|
813
|
+
.join(' ');
|
|
814
|
+
const agentCmd = [executable, ...args].map(shellQuote).join(' ');
|
|
815
|
+
return `exec env ${envPrefix} ${agentCmd}`;
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Run an interactive agent inside a detached tmux session on the shared socket,
|
|
819
|
+
* attach the current TTY, and propagate the wrapped agent's exit code.
|
|
820
|
+
*
|
|
821
|
+
* Lifecycle:
|
|
822
|
+
* 1. createSession() launches `sh -c 'exec env … agent'` detached, remain-on-exit
|
|
823
|
+
* on (global), and returns the pane id.
|
|
824
|
+
* 2. A per-session `pane-died` hook detaches the attach client the instant the
|
|
825
|
+
* AGENT pane exits, so attach returns instead of parking on a dead pane. The
|
|
826
|
+
* hook is guarded on `#{hook_pane}` so it fires ONLY for the agent pane —
|
|
827
|
+
* user-created splits (Ctrl-b " / %) that the user exits are closed in place
|
|
828
|
+
* (`kill-pane`) instead of tearing down the whole client, so exiting one
|
|
829
|
+
* split leaves the agent running full-window rather than kicking you out.
|
|
830
|
+
* 3. We record the agent pane's pid → session mapping (WITH the tmux pane) so the
|
|
831
|
+
* headless active-scan attributes it, then attach the TTY (blocking).
|
|
832
|
+
* 4. On return: if the pane is dead the agent exited — read its status, tear the
|
|
833
|
+
* session down, return that code. If the pane is still alive the user detached
|
|
834
|
+
* (Ctrl-b d) — return 0 and LEAVE the session for `agents focus` to re-attach.
|
|
835
|
+
*/
|
|
836
|
+
async function runInTmux(options, executable, args) {
|
|
837
|
+
const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName } = await import('./tmux/session.js');
|
|
838
|
+
const { getDefaultSocketPath } = await import('./tmux/paths.js');
|
|
839
|
+
const { attachTmux, runTmux } = await import('./tmux/binary.js');
|
|
840
|
+
const socket = getDefaultSocketPath();
|
|
841
|
+
const cwd = options.cwd || process.cwd();
|
|
842
|
+
const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8);
|
|
843
|
+
const name = slugifyName(`ag-${options.agent}-${idSeed}`);
|
|
844
|
+
const cmd = buildTmuxAgentCommand(executable, args, buildExecEnv(options));
|
|
845
|
+
const labels = { agent: options.agent };
|
|
846
|
+
if (options.sessionId)
|
|
847
|
+
labels.sessionId = options.sessionId;
|
|
848
|
+
const meta = await createSession({ name, cmd, cwd, socket, source: 'cli', labels });
|
|
849
|
+
const pane = meta.pane;
|
|
850
|
+
if (pane) {
|
|
851
|
+
// When the AGENT pane dies, detach the client (don't kill) so the session
|
|
852
|
+
// survives just long enough to read the dead pane's exit status below. The
|
|
853
|
+
// `#{hook_pane}` guard scopes this to the agent pane only: if the user splits
|
|
854
|
+
// the window and exits one of THEIR panes, the else-branch `kill-pane` closes
|
|
855
|
+
// that split in place instead of detaching everyone (the pane-died hook runs
|
|
856
|
+
// in the dead pane's context, so bare `kill-pane` targets it). Without the
|
|
857
|
+
// guard, exiting any split kicked the user clean out of tmux.
|
|
858
|
+
await setSessionHook(name, 'pane-died', `if -F '#{==:#{hook_pane},${pane}}' 'detach-client -s =${name}' 'kill-pane'`, socket);
|
|
859
|
+
// Record the agent's OS pid (the pane leaf, thanks to `exec`) WITH its tmux
|
|
860
|
+
// pane so the active-scan attributes it exactly and shows the %pane.
|
|
861
|
+
let panePid = 0;
|
|
862
|
+
try {
|
|
863
|
+
const r = await runTmux({ socket, args: ['display-message', '-pt', pane, '-p', '#{pane_pid}'], throwOnError: false });
|
|
864
|
+
panePid = parseInt(r.stdout.trim(), 10) || 0;
|
|
865
|
+
}
|
|
866
|
+
catch { /* best-effort */ }
|
|
867
|
+
writePidSessionEntry({
|
|
868
|
+
pid: panePid,
|
|
869
|
+
agent: options.agent,
|
|
870
|
+
sessionId: options.sessionId,
|
|
871
|
+
cwd,
|
|
872
|
+
tmuxPane: pane,
|
|
873
|
+
startedAtMs: Date.now(),
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
// The agent could exit before we attach (fast failure). Don't attach to an
|
|
877
|
+
// already-dead pane — read its status directly and tear down.
|
|
878
|
+
const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
879
|
+
if (!before.dead) {
|
|
880
|
+
await attachTmux({ socket, args: ['attach-session', '-t', name] });
|
|
881
|
+
}
|
|
882
|
+
const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
883
|
+
if (after.dead) {
|
|
884
|
+
await killSession(name, socket).catch(() => { });
|
|
885
|
+
return { exitCode: after.status ?? 0, stderr: '' };
|
|
886
|
+
}
|
|
887
|
+
// Pane still alive → the user detached; keep the session for `agents focus`.
|
|
888
|
+
return { exitCode: 0, stderr: '' };
|
|
889
|
+
}
|
|
756
890
|
/**
|
|
757
891
|
* Spawn an agent process and return its exit code plus a tee'd copy of stderr.
|
|
758
892
|
*
|
|
@@ -773,6 +907,12 @@ async function spawnAgent(options) {
|
|
|
773
907
|
if (options.agent === 'claude' && !options.resume && !options.sessionId) {
|
|
774
908
|
options = { ...options, sessionId: randomUUID() };
|
|
775
909
|
}
|
|
910
|
+
// Record the run's --name against its session id (when both are known at
|
|
911
|
+
// launch) so `agents sessions <name>` resolves it. Best-effort; unnamed runs
|
|
912
|
+
// and agents whose id isn't known up front simply skip this.
|
|
913
|
+
if (options.name && options.sessionId) {
|
|
914
|
+
recordRunName({ sessionId: options.sessionId, name: options.name, agent: options.agent, cwd: options.cwd });
|
|
915
|
+
}
|
|
776
916
|
const cmd = buildExecCommand(options);
|
|
777
917
|
const [executable, ...args] = cmd;
|
|
778
918
|
const timeoutMs = options.timeout ? parseTimeout(options.timeout) : undefined;
|
|
@@ -800,6 +940,29 @@ async function spawnAgent(options) {
|
|
|
800
940
|
command: executable,
|
|
801
941
|
args: redactArgs(args.slice(0, 10)),
|
|
802
942
|
});
|
|
943
|
+
// Interactive spawn-wrap: on macOS/Linux, run the agent INSIDE a shared-socket
|
|
944
|
+
// tmux session (then attach this TTY) so it gets a unique, addressable %pane.
|
|
945
|
+
// Headless runs, Windows, already-in-tmux, --raw, and AGENTS_NO_TMUX=1 keep the
|
|
946
|
+
// bare spawn below. See shouldWrapInTmux / runInTmux.
|
|
947
|
+
if (shouldWrapInTmux({
|
|
948
|
+
interactive,
|
|
949
|
+
platform: process.platform,
|
|
950
|
+
inTmux: !!process.env.TMUX,
|
|
951
|
+
raw: options.raw === true,
|
|
952
|
+
noTmuxEnv: process.env.AGENTS_NO_TMUX === '1',
|
|
953
|
+
tmuxAvailable: isTmuxInstalled(),
|
|
954
|
+
})) {
|
|
955
|
+
timer.mark('startup');
|
|
956
|
+
try {
|
|
957
|
+
const result = await runInTmux(options, executable, args);
|
|
958
|
+
timer.end({ exitCode: result.exitCode, status: result.exitCode === 0 ? 'success' : 'failed' });
|
|
959
|
+
return result;
|
|
960
|
+
}
|
|
961
|
+
catch (err) {
|
|
962
|
+
timer.end({ error: err.message, exitCode: -1, status: 'error' });
|
|
963
|
+
throw err;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
803
966
|
return new Promise((resolve, reject) => {
|
|
804
967
|
// Interactive mode inherits all stdio so the CLI owns the TTY (TUI
|
|
805
968
|
// rendering, raw-mode keystrokes, colored output). Headless mode pipes
|
|
@@ -27,6 +27,11 @@ export interface DispatchOptions {
|
|
|
27
27
|
* resumable by id. Mutually exclusive with `resume`.
|
|
28
28
|
*/
|
|
29
29
|
sessionId?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Durable `--name <slug>` handle, forwarded to the remote `agents run` and
|
|
32
|
+
* recorded on the local task so `agents hosts logs/ps <name>` resolve it.
|
|
33
|
+
*/
|
|
34
|
+
name?: string;
|
|
30
35
|
/** Resume an existing session on the host by id (via `agents run --resume`). */
|
|
31
36
|
resume?: string;
|
|
32
37
|
/** Stream progress and block until completion (default true). */
|
|
@@ -62,6 +62,7 @@ async function launchDetached(host, target, opts) {
|
|
|
62
62
|
prompt: opts.promptLabel,
|
|
63
63
|
pid: Number.isFinite(pid) ? pid : undefined,
|
|
64
64
|
sessionId: opts.sessionId,
|
|
65
|
+
name: opts.name,
|
|
65
66
|
remoteLog,
|
|
66
67
|
remoteExit,
|
|
67
68
|
status: 'running',
|
|
@@ -96,6 +97,8 @@ export function buildRunForwardedArgs(opts) {
|
|
|
96
97
|
args.push('--mode', opts.mode);
|
|
97
98
|
if (opts.model)
|
|
98
99
|
args.push('--model', opts.model);
|
|
100
|
+
if (opts.name)
|
|
101
|
+
args.push('--name', opts.name);
|
|
99
102
|
if (opts.resume)
|
|
100
103
|
args.push('--resume', opts.resume);
|
|
101
104
|
else if (opts.sessionId)
|
|
@@ -115,6 +118,7 @@ export async function dispatchToHost(host, opts) {
|
|
|
115
118
|
timeoutMs: opts.timeoutMs,
|
|
116
119
|
agentLabel: opts.agent,
|
|
117
120
|
promptLabel: opts.prompt,
|
|
121
|
+
name: opts.name,
|
|
118
122
|
// On resume the remote session keeps its existing id; record that id so the
|
|
119
123
|
// task stays mapped to the same session.
|
|
120
124
|
sessionId: opts.resume ?? opts.sessionId,
|
|
@@ -15,6 +15,14 @@ export interface HostTask {
|
|
|
15
15
|
agent: string;
|
|
16
16
|
prompt: string;
|
|
17
17
|
pid?: number;
|
|
18
|
+
/**
|
|
19
|
+
* The durable `agents run --name <slug>` handle for this dispatch, if given.
|
|
20
|
+
* Chosen at launch and agent-agnostic (unlike sessionId), so `agents hosts
|
|
21
|
+
* ps/logs <name>` and the dispatch tip can reference the run by a stable name
|
|
22
|
+
* even for agents that never expose a session id up front. Absent when the
|
|
23
|
+
* run was launched without `--name`.
|
|
24
|
+
*/
|
|
25
|
+
name?: string;
|
|
18
26
|
/**
|
|
19
27
|
* The agent session id the remote run was launched with (Claude only — the
|
|
20
28
|
* only agent that accepts `--session-id` to force a NEW session's id). Lets
|
|
@@ -52,3 +60,10 @@ export declare function listTasks(): HostTask[];
|
|
|
52
60
|
* with the same forced id resolves to the most recent dispatch.
|
|
53
61
|
*/
|
|
54
62
|
export declare function findTaskBySessionId(sessionId: string): HostTask | null;
|
|
63
|
+
/**
|
|
64
|
+
* Find the newest host task launched with `--name <name>`, so `agents hosts
|
|
65
|
+
* logs/ps <name>` and resolve-by-handle can address a run by its durable name.
|
|
66
|
+
* Case-insensitive; newest wins (listTasks is createdAt-desc) when a name was
|
|
67
|
+
* reused across dispatches.
|
|
68
|
+
*/
|
|
69
|
+
export declare function findTaskByName(name: string): HostTask | null;
|
package/dist/lib/hosts/tasks.js
CHANGED
|
@@ -85,3 +85,19 @@ export function findTaskBySessionId(sessionId) {
|
|
|
85
85
|
}
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Find the newest host task launched with `--name <name>`, so `agents hosts
|
|
90
|
+
* logs/ps <name>` and resolve-by-handle can address a run by its durable name.
|
|
91
|
+
* Case-insensitive; newest wins (listTasks is createdAt-desc) when a name was
|
|
92
|
+
* reused across dispatches.
|
|
93
|
+
*/
|
|
94
|
+
export function findTaskByName(name) {
|
|
95
|
+
if (!name)
|
|
96
|
+
return null;
|
|
97
|
+
const wanted = name.toLowerCase();
|
|
98
|
+
for (const task of listTasks()) {
|
|
99
|
+
if (task.name && task.name.toLowerCase() === wanted)
|
|
100
|
+
return task;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
@@ -77,7 +77,7 @@ export function menubarServiceInstalled() {
|
|
|
77
77
|
* Locate the source `.app` shipped alongside the compiled JS.
|
|
78
78
|
* 1. dist/lib/menubar/MenubarHelper.app — npm install layout (sibling of this file)
|
|
79
79
|
* 2. <repo>/bin/MenubarHelper.app — raw working tree (tsx/dev)
|
|
80
|
-
* 3.
|
|
80
|
+
* 3. apps/cli/menubar/dist/MenubarHelper.app — fresh local build
|
|
81
81
|
*/
|
|
82
82
|
function sourceAppPath() {
|
|
83
83
|
const candidates = [];
|
|
@@ -85,7 +85,7 @@ function sourceAppPath() {
|
|
|
85
85
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
86
86
|
candidates.push(path.join(here, APP_BUNDLE_NAME));
|
|
87
87
|
candidates.push(path.resolve(here, '..', '..', '..', 'bin', APP_BUNDLE_NAME));
|
|
88
|
-
candidates.push(path.resolve(here, '..', '..', '..', '
|
|
88
|
+
candidates.push(path.resolve(here, '..', '..', '..', 'menubar', 'dist', APP_BUNDLE_NAME));
|
|
89
89
|
}
|
|
90
90
|
catch {
|
|
91
91
|
/* import.meta.url unavailable */
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -47,9 +47,11 @@ export declare function getProjectRunStrategy(agent: AgentId, startPath: string)
|
|
|
47
47
|
* Resolve the configured strategy. Lookup order:
|
|
48
48
|
* 1. project-local agents.yaml (nearest to `startPath`)
|
|
49
49
|
* 2. ~/.agents/.system/agents.yaml
|
|
50
|
-
* 3. default: `
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* 3. default: `balanced` (weighted-random across all healthy accounts by
|
|
51
|
+
* remaining headroom, skipping any that are currently rate-limited). A
|
|
52
|
+
* bare `agents run <agent>` — e.g. every new terminal the extension spawns
|
|
53
|
+
* — should spread load and never launch into a throttled account, rather
|
|
54
|
+
* than stick to the pinned default even when it's maxed.
|
|
53
55
|
*/
|
|
54
56
|
export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: string): RunStrategy;
|
|
55
57
|
/** Persist the global run strategy used by bare `agents run <agent>`. */
|
|
@@ -65,9 +67,12 @@ export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrate
|
|
|
65
67
|
* headroom, with no stampede on the lowest-usage one. Stateless — parallel
|
|
66
68
|
* callers naturally fan out via the random roll.
|
|
67
69
|
*
|
|
68
|
-
* Eligibility: signed in (email present), auth valid, and
|
|
69
|
-
*
|
|
70
|
-
* when no live
|
|
70
|
+
* Eligibility: signed in (email present), auth valid, and not currently
|
|
71
|
+
* rate-limited — no blocking window (session OR weekly) at 100%, matching the
|
|
72
|
+
* `agents view` badge; or the local cached status is usable when no live
|
|
73
|
+
* snapshot exists. Note the split: eligibility considers the session window
|
|
74
|
+
* (a session-maxed account can't run now), but the capacity *weight* above is
|
|
75
|
+
* driven by weekly headroom so a brief session spike doesn't distort routing.
|
|
71
76
|
*
|
|
72
77
|
* Dedupe: when multiple versions share an email, collapse to one candidate
|
|
73
78
|
* per email (the least-recently-active version). Prevents two parallel pods
|
package/dist/lib/rotate.js
CHANGED
|
@@ -10,7 +10,7 @@ import { getAccountInfo } from './agents.js';
|
|
|
10
10
|
import { readMeta, writeMeta, getHelpersDir } from './state.js';
|
|
11
11
|
import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
|
|
12
12
|
import { getProjectRunConfigs } from './run-config.js';
|
|
13
|
-
import { getUsageInfoByIdentity, getUsageLookupKey, } from './usage.js';
|
|
13
|
+
import { getUsageInfoByIdentity, getUsageLookupKey, deriveUsageStatusFromSnapshot, } from './usage.js';
|
|
14
14
|
function getRotateDir() {
|
|
15
15
|
const dir = path.join(getHelpersDir(), 'rotate');
|
|
16
16
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -44,14 +44,16 @@ export function getProjectRunStrategy(agent, startPath) {
|
|
|
44
44
|
* Resolve the configured strategy. Lookup order:
|
|
45
45
|
* 1. project-local agents.yaml (nearest to `startPath`)
|
|
46
46
|
* 2. ~/.agents/.system/agents.yaml
|
|
47
|
-
* 3. default: `
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* 3. default: `balanced` (weighted-random across all healthy accounts by
|
|
48
|
+
* remaining headroom, skipping any that are currently rate-limited). A
|
|
49
|
+
* bare `agents run <agent>` — e.g. every new terminal the extension spawns
|
|
50
|
+
* — should spread load and never launch into a throttled account, rather
|
|
51
|
+
* than stick to the pinned default even when it's maxed.
|
|
50
52
|
*/
|
|
51
53
|
export function getConfiguredRunStrategy(agent, startPath = process.cwd()) {
|
|
52
54
|
return getProjectRunStrategy(agent, startPath)
|
|
53
55
|
?? normalizeRunStrategy(readMeta().run?.[agent]?.strategy)
|
|
54
|
-
?? '
|
|
56
|
+
?? 'balanced';
|
|
55
57
|
}
|
|
56
58
|
/** Persist the global run strategy used by bare `agents run <agent>`. */
|
|
57
59
|
export function setGlobalRunStrategy(agent, strategy) {
|
|
@@ -72,10 +74,19 @@ function isAvailableEligible(candidate) {
|
|
|
72
74
|
&& hasUsageAvailable(candidate);
|
|
73
75
|
}
|
|
74
76
|
function hasUsageAvailable(candidate) {
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
|
|
77
|
+
const snapshot = candidate.usageSnapshot;
|
|
78
|
+
if (snapshot && snapshot.windows.length > 0) {
|
|
79
|
+
// Eligibility mirrors the `agents view` throttle badge exactly
|
|
80
|
+
// (deriveUsageStatusFromSnapshot): an account maxed on ANY blocking window —
|
|
81
|
+
// including the 5-hour session window — cannot serve the next request, so it
|
|
82
|
+
// must not be picked. Previously this checked only non-session windows
|
|
83
|
+
// (getRoutingUsedPercent), so a session-maxed account with weekly headroom
|
|
84
|
+
// stayed "eligible" and the router kept launching into it while `ag view`
|
|
85
|
+
// showed it rate-limited. Capacity *weighting* still ranks eligible accounts
|
|
86
|
+
// by weekly headroom; this gate only decides can-it-run-right-now.
|
|
87
|
+
return deriveUsageStatusFromSnapshot(snapshot) !== 'rate_limited';
|
|
78
88
|
}
|
|
89
|
+
// No live snapshot: fall back to the coarse cached status.
|
|
79
90
|
if (candidate.usageStatus === 'out_of_credits' || candidate.usageStatus === 'rate_limited') {
|
|
80
91
|
return false;
|
|
81
92
|
}
|
|
@@ -140,9 +151,12 @@ function dedupeAndSortCandidates(candidates) {
|
|
|
140
151
|
* headroom, with no stampede on the lowest-usage one. Stateless — parallel
|
|
141
152
|
* callers naturally fan out via the random roll.
|
|
142
153
|
*
|
|
143
|
-
* Eligibility: signed in (email present), auth valid, and
|
|
144
|
-
*
|
|
145
|
-
* when no live
|
|
154
|
+
* Eligibility: signed in (email present), auth valid, and not currently
|
|
155
|
+
* rate-limited — no blocking window (session OR weekly) at 100%, matching the
|
|
156
|
+
* `agents view` badge; or the local cached status is usable when no live
|
|
157
|
+
* snapshot exists. Note the split: eligibility considers the session window
|
|
158
|
+
* (a session-maxed account can't run now), but the capacity *weight* above is
|
|
159
|
+
* driven by weekly headroom so a brief session spike doesn't distort routing.
|
|
146
160
|
*
|
|
147
161
|
* Dedupe: when multiple versions share an email, collapse to one candidate
|
|
148
162
|
* per email (the least-recently-active version). Prevents two parallel pods
|
|
@@ -336,13 +336,13 @@ export function writeBundle(bundle) {
|
|
|
336
336
|
// of the tier. On an un-updated pinned helper this write fails loudly (the
|
|
337
337
|
// no-ACL command is missing) rather than silently landing an ACL'd item.
|
|
338
338
|
itemStore(backend).set(bundleMetaItem(bundle.name), json, { noAcl: bundle.policy === 'never' });
|
|
339
|
-
emit('secrets.set', { bundle: bundle.name });
|
|
339
|
+
emit('secrets.set', { module: 'secrets', bundle: bundle.name });
|
|
340
340
|
}
|
|
341
341
|
export function deleteBundle(name) {
|
|
342
342
|
validateBundleName(name);
|
|
343
343
|
const deleted = itemStore(bundleBackend(name)).delete(bundleMetaItem(name));
|
|
344
344
|
if (deleted) {
|
|
345
|
-
emit('secrets.delete', { bundle: name });
|
|
345
|
+
emit('secrets.delete', { module: 'secrets', bundle: name });
|
|
346
346
|
}
|
|
347
347
|
return deleted;
|
|
348
348
|
}
|
|
@@ -692,6 +692,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
692
692
|
const filtered = filterAgentHitBySubsetAndExpiry(hit, opts);
|
|
693
693
|
stampLastUsed(filtered.bundle);
|
|
694
694
|
emit('secrets.get', {
|
|
695
|
+
module: 'secrets',
|
|
695
696
|
bundle: name,
|
|
696
697
|
caller: opts.caller,
|
|
697
698
|
status: 'success',
|
|
@@ -781,6 +782,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
781
782
|
keychainKeys.sort();
|
|
782
783
|
const emitReadAudit = (status, err) => {
|
|
783
784
|
emit('secrets.get', {
|
|
785
|
+
module: 'secrets',
|
|
784
786
|
bundle: bundle.name,
|
|
785
787
|
caller: opts.caller,
|
|
786
788
|
status,
|
|
@@ -943,7 +945,7 @@ export function renameBundle(oldName, newName, opts = {}) {
|
|
|
943
945
|
store.delete(oldItem);
|
|
944
946
|
}
|
|
945
947
|
deleteBundle(oldName);
|
|
946
|
-
emit('secrets.rename', { from: oldName, to: newName });
|
|
948
|
+
emit('secrets.rename', { module: 'secrets', from: oldName, to: newName });
|
|
947
949
|
}
|
|
948
950
|
/**
|
|
949
951
|
* The store (keychain or encrypted file) that carries a bundle's items. The
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { sshExec, assertValidSshTarget } from '../ssh-exec.js';
|
|
19
19
|
import { resolveHost } from '../hosts/registry.js';
|
|
20
|
+
import { emit } from '../events.js';
|
|
20
21
|
import { sshTargetFor } from '../hosts/types.js';
|
|
21
22
|
import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
|
|
22
23
|
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
@@ -136,5 +137,18 @@ export async function remoteResolveEnv(target, bundle) {
|
|
|
136
137
|
for (const [k, v] of Object.entries(parsed)) {
|
|
137
138
|
env[k] = typeof v === 'string' ? v : String(v);
|
|
138
139
|
}
|
|
140
|
+
// The remote host audits its own `secrets export` read; this emit records the
|
|
141
|
+
// event on the INITIATING host too (values were pulled into this process and
|
|
142
|
+
// injected locally). Covers `secrets exec --host` and `run --secrets b@host`.
|
|
143
|
+
// Values never enter the payload — only the bundle, target host, and count.
|
|
144
|
+
emit('secrets.get', {
|
|
145
|
+
module: 'secrets',
|
|
146
|
+
bundle,
|
|
147
|
+
caller: 'remote resolve',
|
|
148
|
+
source: 'remote',
|
|
149
|
+
host: target,
|
|
150
|
+
status: 'success',
|
|
151
|
+
keyCount: Object.keys(env).length,
|
|
152
|
+
});
|
|
139
153
|
return env;
|
|
140
154
|
}
|
package/dist/lib/secrets/sync.js
CHANGED
|
@@ -14,6 +14,7 @@ import * as crypto from 'crypto';
|
|
|
14
14
|
import { deleteKeychainToken, getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from './index.js';
|
|
15
15
|
import { readBundle, writeBundle, keychainItemsForBundle, validateBundleName, } from './bundles.js';
|
|
16
16
|
import { rushSyncBackend } from './drivers/rush.js';
|
|
17
|
+
import { emit } from '../events.js';
|
|
17
18
|
// PBKDF2 cost. 600k SHA-256 iters matches OWASP 2023+ guidance and keeps a
|
|
18
19
|
// passphrase prompt under a second on the hardware the CLI targets.
|
|
19
20
|
const PBKDF2_ITER = 600_000;
|
|
@@ -185,6 +186,18 @@ function rollbackFailureMessage(name, phase, err, dirty) {
|
|
|
185
186
|
export async function pushBundle(name, opts) {
|
|
186
187
|
validateBundleName(name);
|
|
187
188
|
const snap = snapshotBundle(name);
|
|
189
|
+
// Push reads every plaintext value and uploads the (client-side-encrypted)
|
|
190
|
+
// bundle off-machine — the most sensitive read there is. It bypasses
|
|
191
|
+
// readAndResolveBundleEnv, so audit it explicitly. Values never enter the
|
|
192
|
+
// payload; only the bundle name and how many keys were read.
|
|
193
|
+
emit('secrets.get', {
|
|
194
|
+
module: 'secrets',
|
|
195
|
+
bundle: name,
|
|
196
|
+
caller: 'sync push',
|
|
197
|
+
source: 'sync-push',
|
|
198
|
+
status: 'success',
|
|
199
|
+
keyCount: Object.keys(snap.secrets).length,
|
|
200
|
+
});
|
|
188
201
|
const envelope = encryptBlob(JSON.stringify(snap), opts.passphrase);
|
|
189
202
|
const updated_at = new Date().toISOString();
|
|
190
203
|
const payload = { envelope, updated_at };
|
|
@@ -20,6 +20,8 @@ export interface ActiveSession {
|
|
|
20
20
|
cwd?: string;
|
|
21
21
|
/** User-given name from /rename command. */
|
|
22
22
|
label?: string;
|
|
23
|
+
/** Durable `agents run --name` launch handle, when the run was named. */
|
|
24
|
+
name?: string;
|
|
23
25
|
/** First meaningful line of the initial prompt (extracted topic). */
|
|
24
26
|
topic?: string;
|
|
25
27
|
/** Live preview: the latest turn (agent message or tool action), from the state engine. */
|
|
@@ -84,11 +86,35 @@ export interface ActiveSession {
|
|
|
84
86
|
* (after the --json/--waiting gates) — NOT emitted on the discovery path.
|
|
85
87
|
*/
|
|
86
88
|
tmuxTarget?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Which host app + tab a tmux-hosted session is currently being VIEWED in,
|
|
91
|
+
* resolved from the attached tmux client (its terminal PID -> app via
|
|
92
|
+
* HOST_MATCHERS, its tab via the per-app resolver). `undefined` means no
|
|
93
|
+
* client is attached — the session is running detached. Transient,
|
|
94
|
+
* renderer-set (see src/lib/session/viewing-in.ts) — NOT on the discovery path.
|
|
95
|
+
*/
|
|
96
|
+
viewingIn?: {
|
|
97
|
+
app: string;
|
|
98
|
+
tab?: number;
|
|
99
|
+
};
|
|
87
100
|
}
|
|
88
101
|
export interface ActiveQueryOptions {
|
|
89
102
|
/** Skip the `ps` scan for ad-hoc headless agents. */
|
|
90
103
|
skipHeadless?: boolean;
|
|
91
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Pick a Claude transcript file within a project dir.
|
|
107
|
+
*
|
|
108
|
+
* With a CONCRETE session id: return that id's `<id>.jsonl` or undefined — NEVER a
|
|
109
|
+
* sibling's. Falling back to the newest file here is the bug that made N distinct
|
|
110
|
+
* co-located sessions (e.g. several editor tabs in one cwd, or two worktree siblings)
|
|
111
|
+
* all collapse onto ONE file and render identical preview + topic (they look like
|
|
112
|
+
* duplicate cards). The mtime fallback is only sound when NO id is known.
|
|
113
|
+
*
|
|
114
|
+
* With NO id: return the newest `.jsonl` by mtime (the legitimate single-session
|
|
115
|
+
* heuristic for a directly-launched agent with no registry entry).
|
|
116
|
+
*/
|
|
117
|
+
export declare function pickSessionFile(projectDir: string, sessionId?: string): string | undefined;
|
|
92
118
|
/**
|
|
93
119
|
* Locate the live transcript for an agent process. Claude files are keyed by
|
|
94
120
|
* cwd (+ optional session uuid); Codex files are date-partitioned, so we resolve
|
|
@@ -117,6 +143,14 @@ export declare function parseWin32ProcessCsv(out: string): ProcRow[];
|
|
|
117
143
|
* for testing the bound; production always uses the real `lsof`-backed probe.
|
|
118
144
|
*/
|
|
119
145
|
export declare function resolveCwds(pids: number[], probe?: (pid: number) => Promise<string | undefined>): Promise<(string | undefined)[]>;
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the host app for a single pid by walking its process ancestry with the
|
|
148
|
+
* same HOST_MATCHERS logic `detectHost` uses. Reads the whole process table per
|
|
149
|
+
* call, so it's for the low-cardinality renderer path (one tmux client per
|
|
150
|
+
* session), not a hot loop. Returns undefined when nothing above the pid is a
|
|
151
|
+
* recognised UI. Exported for the "viewing in <app>" resolver.
|
|
152
|
+
*/
|
|
153
|
+
export declare function hostFromPid(pid: number): Promise<string | undefined>;
|
|
120
154
|
export interface AgentCandidate {
|
|
121
155
|
pid: number;
|
|
122
156
|
kind: string;
|
|
@@ -155,9 +189,21 @@ export declare function foldSubordinateAgents(candidates: AgentCandidate[], ppid
|
|
|
155
189
|
*/
|
|
156
190
|
export declare function listUnattributedActive(attributed: Set<number>): Promise<ActiveSession[]>;
|
|
157
191
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
192
|
+
* Agents hosted in the shared-socket tmux server — the authoritative source for
|
|
193
|
+
* tmux-wrapped interactive spawns (see src/lib/exec.ts `runInTmux`). Enumerates
|
|
194
|
+
* every pane on the shared socket and keeps those whose session meta was stamped
|
|
195
|
+
* with `labels.agent` + `labels.sessionId` by the spawn-wrap. Because tmux (not a
|
|
196
|
+
* per-window `live-terminals.json`) is the source of truth, a tmux-hosted agent is
|
|
197
|
+
* ALWAYS captured with its exact `%pane` even when the extension registry is stale
|
|
198
|
+
* or absent. `source: 'teams'` is skipped — teammates are surfaced by listTeamsActive.
|
|
199
|
+
*/
|
|
200
|
+
export declare function listTmuxAgentSessions(): Promise<ActiveSession[]>;
|
|
201
|
+
/**
|
|
202
|
+
* Union of all sources. Teams and terminals spawn actual CLI processes that
|
|
203
|
+
* also show up in `ps`, so headless attribution runs last with the already-
|
|
204
|
+
* attributed PIDs removed. The tmux source goes FIRST into the dedupe so a
|
|
205
|
+
* tmux-hosted agent's row (which carries the exact `%pane`) wins over a staler
|
|
206
|
+
* terminal/headless row for the same session id.
|
|
161
207
|
*/
|
|
162
208
|
export declare function getActiveSessions(opts?: ActiveQueryOptions): Promise<ActiveSession[]>;
|
|
163
209
|
export {};
|