@phnx-labs/agents-cli 1.20.43 → 1.20.45
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 +18 -0
- package/README.md +4 -3
- package/dist/commands/exec.js +84 -13
- package/dist/commands/hosts.js +5 -4
- package/dist/commands/logs.d.ts +4 -0
- package/dist/commands/logs.js +19 -13
- package/dist/commands/routines.d.ts +6 -0
- package/dist/commands/routines.js +70 -12
- package/dist/commands/secrets.d.ts +18 -0
- package/dist/commands/secrets.js +105 -30
- package/dist/commands/sessions.d.ts +6 -5
- package/dist/commands/sessions.js +50 -22
- package/dist/commands/teams.js +104 -8
- package/dist/lib/daemon.js +34 -9
- package/dist/lib/exec.d.ts +8 -0
- package/dist/lib/exec.js +70 -6
- package/dist/lib/hosts/dispatch.d.ts +29 -0
- package/dist/lib/hosts/dispatch.js +46 -1
- package/dist/lib/hosts/logs.d.ts +14 -5
- package/dist/lib/hosts/logs.js +39 -13
- package/dist/lib/hosts/remote-cmd.d.ts +17 -0
- package/dist/lib/hosts/remote-cmd.js +27 -0
- package/dist/lib/hosts/session-index.d.ts +15 -0
- package/dist/lib/hosts/session-index.js +28 -2
- package/dist/lib/redact.js +1 -0
- package/dist/lib/rotate.d.ts +33 -0
- package/dist/lib/rotate.js +37 -0
- package/dist/lib/secrets/remote.d.ts +14 -0
- package/dist/lib/secrets/remote.js +18 -1
- package/dist/lib/session/active.d.ts +10 -0
- package/dist/lib/session/active.js +12 -1
- package/dist/lib/session/db.d.ts +16 -9
- package/dist/lib/session/db.js +66 -44
- package/dist/lib/session/discover.d.ts +4 -0
- package/dist/lib/session/discover.js +84 -13
- package/dist/lib/session/run-names.d.ts +9 -7
- package/dist/lib/session/run-names.js +9 -7
- package/dist/lib/session/state.d.ts +29 -3
- package/dist/lib/session/state.js +84 -5
- package/dist/lib/session/types.d.ts +19 -8
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +17 -3
- package/dist/lib/teams/agents.js +25 -7
- package/dist/lib/tmux/session.d.ts +40 -0
- package/dist/lib/tmux/session.js +92 -0
- package/dist/lib/versions.d.ts +54 -1
- package/dist/lib/versions.js +138 -1
- package/package.json +1 -1
package/dist/lib/exec.js
CHANGED
|
@@ -515,7 +515,13 @@ export function buildExecCommand(options) {
|
|
|
515
515
|
cmd[0] = absPath;
|
|
516
516
|
}
|
|
517
517
|
else {
|
|
518
|
-
|
|
518
|
+
// No versioned shim on disk. Prefer the version's REAL launch binary
|
|
519
|
+
// (node_modules/.bin/<cli>) over the bare `<cli>@<version>` name — that
|
|
520
|
+
// literal is not on PATH and spawns as ENOENT (the `kimi@0.19.2` failure).
|
|
521
|
+
// Fall back to the literal only if the binary is absent (the run path's
|
|
522
|
+
// ensureAgentRunnable normally repairs/creates the alias before we reach here).
|
|
523
|
+
const realBinary = options.agent ? getBinaryPath(options.agent, options.version) : undefined;
|
|
524
|
+
cmd[0] = realBinary && fs.existsSync(realBinary) ? realBinary : versionedName;
|
|
519
525
|
}
|
|
520
526
|
}
|
|
521
527
|
// Add reasoning effort flags (before mode flags for codex -c positioning)
|
|
@@ -814,6 +820,21 @@ export function buildTmuxAgentCommand(executable, args, env) {
|
|
|
814
820
|
const agentCmd = [executable, ...args].map(shellQuote).join(' ');
|
|
815
821
|
return `exec env ${envPrefix} ${agentCmd}`;
|
|
816
822
|
}
|
|
823
|
+
/**
|
|
824
|
+
* Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
|
|
825
|
+
* (right-stripping each). Used by runInTmux to recap a fast-failed agent's
|
|
826
|
+
* output into the caller's shell so a launch crash (e.g. a gutted install that
|
|
827
|
+
* dies with ENOENT the instant it spawns) isn't swallowed by the bare
|
|
828
|
+
* `[detached]` the pane-died hook otherwise leaves behind.
|
|
829
|
+
*/
|
|
830
|
+
export function formatPaneTail(raw, maxLines = 30) {
|
|
831
|
+
return raw
|
|
832
|
+
.split('\n')
|
|
833
|
+
.map(l => l.replace(/\s+$/, ''))
|
|
834
|
+
.filter(l => l.length > 0)
|
|
835
|
+
.slice(-maxLines)
|
|
836
|
+
.join('\n');
|
|
837
|
+
}
|
|
817
838
|
/**
|
|
818
839
|
* Run an interactive agent inside a detached tmux session on the shared socket,
|
|
819
840
|
* attach the current TTY, and propagate the wrapped agent's exit code.
|
|
@@ -834,7 +855,7 @@ export function buildTmuxAgentCommand(executable, args, env) {
|
|
|
834
855
|
* (Ctrl-b d) — return 0 and LEAVE the session for `agents focus` to re-attach.
|
|
835
856
|
*/
|
|
836
857
|
async function runInTmux(options, executable, args) {
|
|
837
|
-
const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName } = await import('./tmux/session.js');
|
|
858
|
+
const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName, agentPaneDiedHook, markSessionHookSchema } = await import('./tmux/session.js');
|
|
838
859
|
const { getDefaultSocketPath } = await import('./tmux/paths.js');
|
|
839
860
|
const { attachTmux, runTmux } = await import('./tmux/binary.js');
|
|
840
861
|
const socket = getDefaultSocketPath();
|
|
@@ -855,7 +876,10 @@ async function runInTmux(options, executable, args) {
|
|
|
855
876
|
// that split in place instead of detaching everyone (the pane-died hook runs
|
|
856
877
|
// in the dead pane's context, so bare `kill-pane` targets it). Without the
|
|
857
878
|
// guard, exiting any split kicked the user clean out of tmux.
|
|
858
|
-
await setSessionHook(name, 'pane-died',
|
|
879
|
+
await setSessionHook(name, 'pane-died', agentPaneDiedHook(name, pane), socket);
|
|
880
|
+
// Stamp the schema marker so the daemon reconcile (which retrofits older
|
|
881
|
+
// sessions) recognizes this one as already current and skips it.
|
|
882
|
+
await markSessionHookSchema(name, socket);
|
|
859
883
|
// Record the agent's OS pid (the pane leaf, thanks to `exec`) WITH its tmux
|
|
860
884
|
// pane so the active-scan attributes it exactly and shows the %pane.
|
|
861
885
|
let panePid = 0;
|
|
@@ -873,14 +897,54 @@ async function runInTmux(options, executable, args) {
|
|
|
873
897
|
startedAtMs: Date.now(),
|
|
874
898
|
});
|
|
875
899
|
}
|
|
900
|
+
// Recap a dead pane's tail into THIS shell's stderr. The pane-died hook
|
|
901
|
+
// detaches the client the instant the agent exits, so a fast failure (a
|
|
902
|
+
// gutted install that dies with ENOENT, a bad flag, a crash on startup) would
|
|
903
|
+
// otherwise leave only a bare `[detached]` with no clue why. Must run BEFORE
|
|
904
|
+
// killSession — capture-pane needs the session still alive (remain-on-exit
|
|
905
|
+
// keeps the dead pane readable until we tear it down). Best-effort throughout.
|
|
906
|
+
const surfacePaneFailure = async (status, headline) => {
|
|
907
|
+
if (!pane)
|
|
908
|
+
return;
|
|
909
|
+
let tail = '';
|
|
910
|
+
try {
|
|
911
|
+
const r = await runTmux({ socket, args: ['capture-pane', '-p', '-t', pane, '-S', '-200'], throwOnError: false });
|
|
912
|
+
if (r.code === 0)
|
|
913
|
+
tail = formatPaneTail(r.stdout);
|
|
914
|
+
}
|
|
915
|
+
catch { /* best-effort — a missing pane just means no recap */ }
|
|
916
|
+
const RED = '\x1b[31m', GRAY = '\x1b[90m', OFF = '\x1b[0m';
|
|
917
|
+
process.stderr.write(`\n${RED}agents: ${headline} (exit ${status ?? 1}).${OFF}\n`);
|
|
918
|
+
if (tail) {
|
|
919
|
+
process.stderr.write(`${GRAY} ── last output from ${options.agent} ──${OFF}\n`);
|
|
920
|
+
process.stderr.write(tail.replace(/^/gm, ' ') + '\n');
|
|
921
|
+
process.stderr.write(`${GRAY} ${'─'.repeat(30)}${OFF}\n`);
|
|
922
|
+
}
|
|
923
|
+
process.stderr.write(`${GRAY} Tip: re-run with --no-tmux to launch the agent directly and see its full output.${OFF}\n\n`);
|
|
924
|
+
};
|
|
876
925
|
// The agent could exit before we attach (fast failure). Don't attach to an
|
|
877
|
-
// already-dead pane —
|
|
926
|
+
// already-dead pane — surface its output + status directly and tear down.
|
|
878
927
|
const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
879
|
-
if (
|
|
880
|
-
|
|
928
|
+
if (before.dead) {
|
|
929
|
+
// Only recap a FAILURE. A clean (0) exit before we attached is a successful
|
|
930
|
+
// quick run, not a crash — a red banner there would be spurious (mirrors the
|
|
931
|
+
// post-attach guard below).
|
|
932
|
+
if ((before.status ?? 0) !== 0) {
|
|
933
|
+
await surfacePaneFailure(before.status, `${options.agent} exited before it could start`);
|
|
934
|
+
}
|
|
935
|
+
await killSession(name, socket).catch(() => { });
|
|
936
|
+
return { exitCode: before.status ?? 0, stderr: '' };
|
|
881
937
|
}
|
|
938
|
+
await attachTmux({ socket, args: ['attach-session', '-t', name] });
|
|
882
939
|
const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
883
940
|
if (after.dead) {
|
|
941
|
+
// Nonzero exit after attach → the agent crashed rather than the user
|
|
942
|
+
// detaching cleanly (a clean detach leaves the pane ALIVE, handled below).
|
|
943
|
+
// The pane-died hook may have yanked the view before the error was readable,
|
|
944
|
+
// so recap it into the shell. A clean (0) exit stays quiet — nothing to say.
|
|
945
|
+
if ((after.status ?? 0) !== 0) {
|
|
946
|
+
await surfacePaneFailure(after.status, `${options.agent} exited`);
|
|
947
|
+
}
|
|
884
948
|
await killSession(name, socket).catch(() => { });
|
|
885
949
|
return { exitCode: after.status ?? 0, stderr: '' };
|
|
886
950
|
}
|
|
@@ -45,6 +45,35 @@ export interface DispatchOptions {
|
|
|
45
45
|
* resume wins when — defensively — both are set.
|
|
46
46
|
*/
|
|
47
47
|
export declare function buildRunForwardedArgs(opts: DispatchOptions): string[];
|
|
48
|
+
export interface InteractiveDispatchOptions {
|
|
49
|
+
agent: string;
|
|
50
|
+
/** Optional prompt — forwarded only when the caller explicitly forced interactive mode. */
|
|
51
|
+
prompt?: string;
|
|
52
|
+
mode?: string;
|
|
53
|
+
model?: string;
|
|
54
|
+
remoteCwd?: string;
|
|
55
|
+
sessionId?: string;
|
|
56
|
+
name?: string;
|
|
57
|
+
resume?: string;
|
|
58
|
+
passthroughArgs?: string[];
|
|
59
|
+
raw?: boolean;
|
|
60
|
+
/** Forward `--interactive` to the remote so a prompt-bearing run still starts the TUI. */
|
|
61
|
+
forceInteractive?: boolean;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build the remote `agents run …` argv for an INTERACTIVE host dispatch. The
|
|
65
|
+
* remote agent sees a TTY, so we omit `--quiet`; the remote CLI will launch its
|
|
66
|
+
* normal interactive TUI / tmux wrapper. A prompt is only included when the
|
|
67
|
+
* caller explicitly forced interactive mode (otherwise the remote CLI would
|
|
68
|
+
* infer headless from the prompt).
|
|
69
|
+
*/
|
|
70
|
+
export declare function buildInteractiveRunForwardedArgs(opts: InteractiveDispatchOptions): string[];
|
|
71
|
+
/**
|
|
72
|
+
* Run an agent interactively on a host, forwarding the local TTY over SSH.
|
|
73
|
+
* Returns the SSH exit code. The remote `agents` CLI is responsible for its own
|
|
74
|
+
* tmux wrapping; the local machine is just the transport.
|
|
75
|
+
*/
|
|
76
|
+
export declare function runInteractiveOnHost(host: Host, opts: InteractiveDispatchOptions): Promise<number>;
|
|
48
77
|
/** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
|
|
49
78
|
export declare function dispatchToHost(host: Host, opts: DispatchOptions): Promise<DispatchResult>;
|
|
50
79
|
export interface CommandDispatchOptions {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* same core so a remote team supervisor keeps running after you disconnect.
|
|
10
10
|
*/
|
|
11
11
|
import { randomUUID } from 'crypto';
|
|
12
|
-
import { sshExec, shellQuote } from '../ssh-exec.js';
|
|
12
|
+
import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
|
|
13
13
|
import { sshTargetFor } from './types.js';
|
|
14
14
|
import { ensureHostReady } from './ready.js';
|
|
15
15
|
import { remoteShellFor } from './remote-cmd.js';
|
|
@@ -105,6 +105,51 @@ export function buildRunForwardedArgs(opts) {
|
|
|
105
105
|
args.push('--session-id', opts.sessionId);
|
|
106
106
|
return args;
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Build the remote `agents run …` argv for an INTERACTIVE host dispatch. The
|
|
110
|
+
* remote agent sees a TTY, so we omit `--quiet`; the remote CLI will launch its
|
|
111
|
+
* normal interactive TUI / tmux wrapper. A prompt is only included when the
|
|
112
|
+
* caller explicitly forced interactive mode (otherwise the remote CLI would
|
|
113
|
+
* infer headless from the prompt).
|
|
114
|
+
*/
|
|
115
|
+
export function buildInteractiveRunForwardedArgs(opts) {
|
|
116
|
+
const args = ['run', opts.agent];
|
|
117
|
+
if (opts.prompt && opts.forceInteractive)
|
|
118
|
+
args.push(opts.prompt);
|
|
119
|
+
if (opts.forceInteractive)
|
|
120
|
+
args.push('--interactive');
|
|
121
|
+
if (opts.mode)
|
|
122
|
+
args.push('--mode', opts.mode);
|
|
123
|
+
if (opts.model)
|
|
124
|
+
args.push('--model', opts.model);
|
|
125
|
+
if (opts.name)
|
|
126
|
+
args.push('--name', opts.name);
|
|
127
|
+
if (opts.resume)
|
|
128
|
+
args.push('--resume', opts.resume);
|
|
129
|
+
else if (opts.sessionId)
|
|
130
|
+
args.push('--session-id', opts.sessionId);
|
|
131
|
+
if (opts.raw)
|
|
132
|
+
args.push('--raw');
|
|
133
|
+
if (opts.passthroughArgs && opts.passthroughArgs.length > 0) {
|
|
134
|
+
args.push('--', ...opts.passthroughArgs);
|
|
135
|
+
}
|
|
136
|
+
return args;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Run an agent interactively on a host, forwarding the local TTY over SSH.
|
|
140
|
+
* Returns the SSH exit code. The remote `agents` CLI is responsible for its own
|
|
141
|
+
* tmux wrapping; the local machine is just the transport.
|
|
142
|
+
*/
|
|
143
|
+
export async function runInteractiveOnHost(host, opts) {
|
|
144
|
+
const target = sshTargetFor(host);
|
|
145
|
+
const { warnings } = ensureHostReady(host, { agent: opts.agent });
|
|
146
|
+
for (const w of warnings)
|
|
147
|
+
process.stderr.write(`[hosts] warning: ${w}\n`);
|
|
148
|
+
const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
|
|
149
|
+
const cwd = opts.remoteCwd ? `cd ${shellQuote(opts.remoteCwd)} && ` : '';
|
|
150
|
+
const remoteCmd = `${cwd}${invocation}`;
|
|
151
|
+
return sshStream(target, remoteCmd, { tty: process.stdin.isTTY, multiplex: true });
|
|
152
|
+
}
|
|
108
153
|
/** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
|
|
109
154
|
export async function dispatchToHost(host, opts) {
|
|
110
155
|
const target = sshTargetFor(host);
|
package/dist/lib/hosts/logs.d.ts
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Shared host-task log viewer — the show-or-follow core behind both
|
|
3
3
|
* `agents hosts logs <id>` and the top-level `agents logs <id>`.
|
|
4
4
|
*
|
|
5
|
-
* A running task with follow re-enters the offset-tail (`followHostTask`)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* A running task with follow re-enters the offset-tail (`followHostTask`).
|
|
6
|
+
* Otherwise the view is **concise by default**: a bounded tail of the captured
|
|
7
|
+
* combined-stdout, so an agent glancing at a dispatched run never pulls the whole
|
|
8
|
+
* log. `full` opts into the entire raw log. (A host run's real transcript lives
|
|
9
|
+
* on the remote, not the local index — surfacing its rich summary needs remote
|
|
10
|
+
* runs to be discoverable there first; until then the bounded tail is the safe
|
|
11
|
+
* concise default.) Kept in one place so the two commands can never drift.
|
|
8
12
|
*/
|
|
9
13
|
export interface HostLogResult {
|
|
10
14
|
/** False when no host task with this id exists (caller may fall through to sessions). */
|
|
@@ -12,5 +16,10 @@ export interface HostLogResult {
|
|
|
12
16
|
/** Process exit code to adopt when the task was shown/followed. */
|
|
13
17
|
exitCode?: number;
|
|
14
18
|
}
|
|
15
|
-
/**
|
|
16
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Show (or follow, when running) a dispatched host task. Bounded-tail summary by
|
|
21
|
+
* default; `full` dumps the entire raw combined-stdout log.
|
|
22
|
+
*/
|
|
23
|
+
export declare function showHostTaskLog(id: string, follow: boolean, full?: boolean): Promise<HostLogResult>;
|
|
24
|
+
/** Last `n` lines of `text`, prefixed with an elision note when truncated. */
|
|
25
|
+
export declare function tailLines(text: string, n: number): string;
|
package/dist/lib/hosts/logs.js
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Shared host-task log viewer — the show-or-follow core behind both
|
|
3
3
|
* `agents hosts logs <id>` and the top-level `agents logs <id>`.
|
|
4
4
|
*
|
|
5
|
-
* A running task with follow re-enters the offset-tail (`followHostTask`)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* A running task with follow re-enters the offset-tail (`followHostTask`).
|
|
6
|
+
* Otherwise the view is **concise by default**: a bounded tail of the captured
|
|
7
|
+
* combined-stdout, so an agent glancing at a dispatched run never pulls the whole
|
|
8
|
+
* log. `full` opts into the entire raw log. (A host run's real transcript lives
|
|
9
|
+
* on the remote, not the local index — surfacing its rich summary needs remote
|
|
10
|
+
* runs to be discoverable there first; until then the bounded tail is the safe
|
|
11
|
+
* concise default.) Kept in one place so the two commands can never drift.
|
|
8
12
|
*/
|
|
9
13
|
import * as fs from 'fs';
|
|
10
14
|
import chalk from 'chalk';
|
|
@@ -12,8 +16,13 @@ import { loadTask, localLogPath, updateTask, terminalPatch } from './tasks.js';
|
|
|
12
16
|
import { followHostTask } from './progress.js';
|
|
13
17
|
import { reconcileTask } from './reconcile.js';
|
|
14
18
|
import { sshExecRaw } from '../ssh-exec.js';
|
|
15
|
-
/**
|
|
16
|
-
|
|
19
|
+
/** Lines of raw combined-stdout to show in the concise (non-`full`) view. */
|
|
20
|
+
const HOST_LOG_TAIL_LINES = 40;
|
|
21
|
+
/**
|
|
22
|
+
* Show (or follow, when running) a dispatched host task. Bounded-tail summary by
|
|
23
|
+
* default; `full` dumps the entire raw combined-stdout log.
|
|
24
|
+
*/
|
|
25
|
+
export async function showHostTaskLog(id, follow, full = false) {
|
|
17
26
|
const task = loadTask(id);
|
|
18
27
|
if (!task)
|
|
19
28
|
return { found: false };
|
|
@@ -36,21 +45,38 @@ export async function showHostTaskLog(id, follow) {
|
|
|
36
45
|
// plain `logs <id>` also unsticks a task whose follower was killed. No-op (no
|
|
37
46
|
// ssh) once the record is already terminal.
|
|
38
47
|
reconcileTask(task);
|
|
48
|
+
// Raw combined-stdout: the whole log with `full`, else a bounded tail.
|
|
49
|
+
const raw = readTaskLog(task);
|
|
50
|
+
if (raw === null) {
|
|
51
|
+
process.stdout.write(chalk.gray('(no local log captured for this task)\n'));
|
|
52
|
+
return { found: true, exitCode: 0 };
|
|
53
|
+
}
|
|
54
|
+
process.stdout.write(full ? raw : tailLines(raw, HOST_LOG_TAIL_LINES));
|
|
55
|
+
return { found: true, exitCode: 0 };
|
|
56
|
+
}
|
|
57
|
+
/** Read the task's combined-stdout — local mirror first, else fetch+cache remote. */
|
|
58
|
+
function readTaskLog(task) {
|
|
39
59
|
try {
|
|
40
|
-
|
|
60
|
+
return fs.readFileSync(localLogPath(task.id), 'utf-8');
|
|
41
61
|
}
|
|
42
62
|
catch {
|
|
43
63
|
// No local log — task was dispatched with --no-follow. Fetch from the remote
|
|
44
64
|
// on demand and cache locally so subsequent calls are instant.
|
|
45
65
|
const remote = fetchAndCacheRemoteLog(task);
|
|
46
|
-
|
|
47
|
-
process.stdout.write(remote);
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
process.stdout.write(chalk.gray('(no local log captured for this task)\n'));
|
|
51
|
-
}
|
|
66
|
+
return remote !== null ? remote.toString('utf-8') : null;
|
|
52
67
|
}
|
|
53
|
-
|
|
68
|
+
}
|
|
69
|
+
/** Last `n` lines of `text`, prefixed with an elision note when truncated. */
|
|
70
|
+
export function tailLines(text, n) {
|
|
71
|
+
const lines = text.split('\n');
|
|
72
|
+
// A trailing newline yields a final empty element — drop it from the count.
|
|
73
|
+
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
74
|
+
lines.pop();
|
|
75
|
+
if (lines.length <= n)
|
|
76
|
+
return lines.join('\n') + '\n';
|
|
77
|
+
const hidden = lines.length - n;
|
|
78
|
+
const note = chalk.gray(`… ${hidden} earlier line${hidden === 1 ? '' : 's'} hidden — pass --full for the whole log\n`);
|
|
79
|
+
return note + lines.slice(-n).join('\n') + '\n';
|
|
54
80
|
}
|
|
55
81
|
/**
|
|
56
82
|
* Fetch a task's remote log over SSH, write it to the local mirror path (for
|
|
@@ -101,3 +101,20 @@ export declare function windowsAgentsScript(cmd: WindowsAgentsCommand): string;
|
|
|
101
101
|
* Windows counterpart of `bash -lc '<...>'`, shared by every `--host` site.
|
|
102
102
|
*/
|
|
103
103
|
export declare function buildWindowsAgentsCommand(cmd: WindowsAgentsCommand): string;
|
|
104
|
+
/**
|
|
105
|
+
* Build the `ssh <target> <cmd>` string for `agents secrets import` on a Windows
|
|
106
|
+
* remote where the `.env` is piped over ssh stdin.
|
|
107
|
+
*
|
|
108
|
+
* We can't just run `agents secrets import <bundle> --from -`: the npm
|
|
109
|
+
* `agents.ps1` shim does NOT forward the ssh-piped stdin down to the underlying
|
|
110
|
+
* node process, so a raw fd-0 read (`--from -`) hangs forever (observed: the
|
|
111
|
+
* push to a Windows host times out). PowerShell ITSELF can read the pipe, so we
|
|
112
|
+
* read stdin into a temp file in PowerShell, import `--from <file>` (a plain
|
|
113
|
+
* file read, which the shim handles fine), and delete the temp file afterwards
|
|
114
|
+
* — success or failure. Backend defaults to the platform native store
|
|
115
|
+
* (Credential Manager, or the headless file store when there's no logon
|
|
116
|
+
* session), matching a local `agents secrets import`.
|
|
117
|
+
*/
|
|
118
|
+
export declare function buildWindowsStdinImportCommand(bundle: string, opts?: {
|
|
119
|
+
force?: boolean;
|
|
120
|
+
}): string;
|
|
@@ -130,3 +130,30 @@ export function windowsAgentsScript(cmd) {
|
|
|
130
130
|
export function buildWindowsAgentsCommand(cmd) {
|
|
131
131
|
return `powershell -NoProfile -EncodedCommand ${encodePowershell(windowsAgentsScript(cmd))}`;
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Build the `ssh <target> <cmd>` string for `agents secrets import` on a Windows
|
|
135
|
+
* remote where the `.env` is piped over ssh stdin.
|
|
136
|
+
*
|
|
137
|
+
* We can't just run `agents secrets import <bundle> --from -`: the npm
|
|
138
|
+
* `agents.ps1` shim does NOT forward the ssh-piped stdin down to the underlying
|
|
139
|
+
* node process, so a raw fd-0 read (`--from -`) hangs forever (observed: the
|
|
140
|
+
* push to a Windows host times out). PowerShell ITSELF can read the pipe, so we
|
|
141
|
+
* read stdin into a temp file in PowerShell, import `--from <file>` (a plain
|
|
142
|
+
* file read, which the shim handles fine), and delete the temp file afterwards
|
|
143
|
+
* — success or failure. Backend defaults to the platform native store
|
|
144
|
+
* (Credential Manager, or the headless file store when there's no logon
|
|
145
|
+
* session), matching a local `agents secrets import`.
|
|
146
|
+
*/
|
|
147
|
+
export function buildWindowsStdinImportCommand(bundle, opts = {}) {
|
|
148
|
+
const force = opts.force ? ' --force' : '';
|
|
149
|
+
const script = [
|
|
150
|
+
'$in = [Console]::In.ReadToEnd()',
|
|
151
|
+
'$tmp = [System.IO.Path]::GetTempFileName()',
|
|
152
|
+
'[System.IO.File]::WriteAllText($tmp, $in)',
|
|
153
|
+
`try { & agents secrets import ${powershellQuote(bundle)} --from $tmp${force}; $code = $LASTEXITCODE } ` +
|
|
154
|
+
`finally { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }`,
|
|
155
|
+
'if ($null -eq $code) { $code = 1 }',
|
|
156
|
+
'exit $code',
|
|
157
|
+
].join('; ');
|
|
158
|
+
return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
|
|
159
|
+
}
|
|
@@ -32,3 +32,18 @@ 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
|
+
export interface InteractiveHostSessionContext {
|
|
36
|
+
cwd: string;
|
|
37
|
+
host: string;
|
|
38
|
+
agent: string;
|
|
39
|
+
sessionId: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
createdAt?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Register an interactive host run (no prompt, TTY forwarded over SSH) in the
|
|
45
|
+
* local session index. Unlike detached host runs, there is no remote log/exit
|
|
46
|
+
* file and no HostTask; we only need the session id so `agents sessions` can
|
|
47
|
+
* surface and resume it by id.
|
|
48
|
+
*/
|
|
49
|
+
export declare function registerInteractiveHostSession(ctx: InteractiveHostSessionContext): void;
|
|
@@ -35,8 +35,10 @@ export function hostSessionMeta(task, ctx) {
|
|
|
35
35
|
// stale-filter treats as "always live" (see module doc).
|
|
36
36
|
filePath: '',
|
|
37
37
|
topic: ctx.prompt.split('\n')[0]?.slice(0, 120) || undefined,
|
|
38
|
-
label
|
|
39
|
-
name
|
|
38
|
+
// The run's `--name` seeds the label (resolves `agents sessions <name>` and
|
|
39
|
+
// `agents hosts logs <name>`); an unnamed host run falls back to the
|
|
40
|
+
// `[host/<name>]` indicator, mirroring the cloud path's `[cloud/<status>]`.
|
|
41
|
+
label: task.name || `[host/${task.host}]`,
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
/**
|
|
@@ -55,3 +57,27 @@ export function registerHostSession(task, ctx) {
|
|
|
55
57
|
/* index write is best-effort; the run is already live on the host */
|
|
56
58
|
}
|
|
57
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Register an interactive host run (no prompt, TTY forwarded over SSH) in the
|
|
62
|
+
* local session index. Unlike detached host runs, there is no remote log/exit
|
|
63
|
+
* file and no HostTask; we only need the session id so `agents sessions` can
|
|
64
|
+
* surface and resume it by id.
|
|
65
|
+
*/
|
|
66
|
+
export function registerInteractiveHostSession(ctx) {
|
|
67
|
+
if (!SESSION_AGENTS.includes(ctx.agent))
|
|
68
|
+
return;
|
|
69
|
+
try {
|
|
70
|
+
upsertSession({
|
|
71
|
+
id: ctx.sessionId,
|
|
72
|
+
shortId: ctx.sessionId.slice(0, 8),
|
|
73
|
+
agent: ctx.agent,
|
|
74
|
+
timestamp: ctx.createdAt ?? new Date().toISOString(),
|
|
75
|
+
cwd: ctx.cwd,
|
|
76
|
+
filePath: '',
|
|
77
|
+
label: ctx.name || `[host/${ctx.host}]`,
|
|
78
|
+
}, '');
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* index write is best-effort; the run is already live on the host */
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/lib/redact.js
CHANGED
|
@@ -7,6 +7,7 @@ const SECRET_PATTERNS = [
|
|
|
7
7
|
[/\bsk-[A-Za-z0-9]{20,}\b/g, '[REDACTED_API_KEY]'],
|
|
8
8
|
[/\bnpm_[A-Za-z0-9]{36}\b/g, '[REDACTED_NPM_TOKEN]'],
|
|
9
9
|
[/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED_JWT]'],
|
|
10
|
+
[/Bearer\s+\S+/gi, 'Bearer [REDACTED]'],
|
|
10
11
|
[/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD)[A-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)/gi, '$1=[REDACTED]'],
|
|
11
12
|
];
|
|
12
13
|
export function redactSecrets(text) {
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -56,6 +56,39 @@ export declare function getProjectRunStrategy(agent: AgentId, startPath: string)
|
|
|
56
56
|
export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: string): RunStrategy;
|
|
57
57
|
/** Persist the global run strategy used by bare `agents run <agent>`. */
|
|
58
58
|
export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrategy): void;
|
|
59
|
+
/**
|
|
60
|
+
* Whether a specific account can serve a run right now, and — when it can't —
|
|
61
|
+
* why. `signed_out` covers no-email / invalid-auth; `rate_limited` and
|
|
62
|
+
* `out_of_credits` name the throttle. Used to pre-warn on a version-pinned
|
|
63
|
+
* teammate whose account rotation won't route around (a pin IS the target).
|
|
64
|
+
*/
|
|
65
|
+
export type AccountReadiness = {
|
|
66
|
+
ready: true;
|
|
67
|
+
} | {
|
|
68
|
+
ready: false;
|
|
69
|
+
reason: 'rate_limited' | 'out_of_credits' | 'signed_out';
|
|
70
|
+
email: string | null;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Pure decision reusing the router's own eligibility gate (`hasUsageAvailable`
|
|
74
|
+
* + email/auth, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
|
|
75
|
+
* disagree with what rotation would actually do. The `reason` combines the two
|
|
76
|
+
* signals `hasUsageAvailable` reads: the live snapshot (session-inclusive
|
|
77
|
+
* rate-limit) and the coarse cached `usageStatus` (out-of-credits, which a
|
|
78
|
+
* snapshot never carries). When a live snapshot exists it wins over the cached
|
|
79
|
+
* status — matching the gate — so a stale `out_of_credits` cache is not
|
|
80
|
+
* reported while the account is actually serving requests.
|
|
81
|
+
*/
|
|
82
|
+
export declare function readinessFromCandidate(candidate: RotateCandidate): AccountReadiness;
|
|
83
|
+
/**
|
|
84
|
+
* Readiness for a specific installed (agent, version). Returns `{ ready: true }`
|
|
85
|
+
* when the version isn't among the collected candidates — absence is the
|
|
86
|
+
* caller's `isVersionInstalled` concern, not ours; don't cry wolf. Only
|
|
87
|
+
* meaningful for a version-pinned target: a bare target rotates to a healthy
|
|
88
|
+
* account on its own, and a profile injects its own auth (a different account
|
|
89
|
+
* than the version home carries), so neither is checkable here.
|
|
90
|
+
*/
|
|
91
|
+
export declare function checkRunAccountReadiness(agent: AgentId, version: string): Promise<AccountReadiness>;
|
|
59
92
|
/**
|
|
60
93
|
* Pick a healthy candidate using weighted random by remaining capacity.
|
|
61
94
|
*
|
package/dist/lib/rotate.js
CHANGED
|
@@ -92,6 +92,43 @@ function hasUsageAvailable(candidate) {
|
|
|
92
92
|
}
|
|
93
93
|
return true;
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Pure decision reusing the router's own eligibility gate (`hasUsageAvailable`
|
|
97
|
+
* + email/auth, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
|
|
98
|
+
* disagree with what rotation would actually do. The `reason` combines the two
|
|
99
|
+
* signals `hasUsageAvailable` reads: the live snapshot (session-inclusive
|
|
100
|
+
* rate-limit) and the coarse cached `usageStatus` (out-of-credits, which a
|
|
101
|
+
* snapshot never carries). When a live snapshot exists it wins over the cached
|
|
102
|
+
* status — matching the gate — so a stale `out_of_credits` cache is not
|
|
103
|
+
* reported while the account is actually serving requests.
|
|
104
|
+
*/
|
|
105
|
+
export function readinessFromCandidate(candidate) {
|
|
106
|
+
if (!candidate.email || !candidate.authValid) {
|
|
107
|
+
return { ready: false, reason: 'signed_out', email: candidate.email };
|
|
108
|
+
}
|
|
109
|
+
if (hasUsageAvailable(candidate)) {
|
|
110
|
+
return { ready: true };
|
|
111
|
+
}
|
|
112
|
+
const snap = candidate.usageSnapshot;
|
|
113
|
+
const snapRateLimited = !!snap && snap.windows.length > 0 && deriveUsageStatusFromSnapshot(snap) === 'rate_limited';
|
|
114
|
+
const reason = !snapRateLimited && candidate.usageStatus === 'out_of_credits' ? 'out_of_credits' : 'rate_limited';
|
|
115
|
+
return { ready: false, reason, email: candidate.email };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Readiness for a specific installed (agent, version). Returns `{ ready: true }`
|
|
119
|
+
* when the version isn't among the collected candidates — absence is the
|
|
120
|
+
* caller's `isVersionInstalled` concern, not ours; don't cry wolf. Only
|
|
121
|
+
* meaningful for a version-pinned target: a bare target rotates to a healthy
|
|
122
|
+
* account on its own, and a profile injects its own auth (a different account
|
|
123
|
+
* than the version home carries), so neither is checkable here.
|
|
124
|
+
*/
|
|
125
|
+
export async function checkRunAccountReadiness(agent, version) {
|
|
126
|
+
const candidates = await collectRunCandidates(agent);
|
|
127
|
+
const candidate = candidates.find((c) => c.version === version);
|
|
128
|
+
if (!candidate)
|
|
129
|
+
return { ready: true };
|
|
130
|
+
return readinessFromCandidate(candidate);
|
|
131
|
+
}
|
|
95
132
|
function getRoutingUsedPercent(snapshot) {
|
|
96
133
|
if (!snapshot || snapshot.windows.length === 0)
|
|
97
134
|
return null;
|
|
@@ -51,6 +51,20 @@ export declare function remoteSecretsRaw(target: string, args: string[], opts?:
|
|
|
51
51
|
tty?: boolean;
|
|
52
52
|
input?: string;
|
|
53
53
|
}): SshExecResult;
|
|
54
|
+
/**
|
|
55
|
+
* Run a remote `agents secrets <args>` FOREGROUND, with the local stdio wired
|
|
56
|
+
* straight through (`stdio: 'inherit'` + `-tt`), and return its exit code.
|
|
57
|
+
*
|
|
58
|
+
* Unlike `remoteSecretsRaw` — which pipes stdin, so even with `-tt` the remote
|
|
59
|
+
* process's `process.stdin.isTTY` is false and a passphrase prompt refuses to
|
|
60
|
+
* appear (the macOS file-store guard then hard-errors "needs
|
|
61
|
+
* AGENTS_SECRETS_PASSPHRASE") — this inherits the caller's real terminal, so the
|
|
62
|
+
* remote sees a genuine TTY and its hidden passphrase prompt surfaces and reads
|
|
63
|
+
* the keystrokes. This is the transport for `unlock --host`: you type the remote
|
|
64
|
+
* bundle's passphrase at your own terminal. Output is NOT captured (it streams
|
|
65
|
+
* to the terminal); only the exit code is returned.
|
|
66
|
+
*/
|
|
67
|
+
export declare function remoteSecretsStream(target: string, args: string[]): number;
|
|
54
68
|
/**
|
|
55
69
|
* Resolve a remote bundle to a plaintext env map by driving the remote's
|
|
56
70
|
* `agents secrets export <bundle> --plaintext --format json`. Values cross over
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* file-backend passphrase travels over ssh stdin (first line) so it never lands
|
|
16
16
|
* in argv / `ps` / remote shell history. Nothing is persisted locally.
|
|
17
17
|
*/
|
|
18
|
-
import { sshExec, assertValidSshTarget } from '../ssh-exec.js';
|
|
18
|
+
import { sshExec, sshStream, assertValidSshTarget } from '../ssh-exec.js';
|
|
19
19
|
import { resolveHost } from '../hosts/registry.js';
|
|
20
20
|
import { emit } from '../events.js';
|
|
21
21
|
import { sshTargetFor } from '../hosts/types.js';
|
|
@@ -93,6 +93,23 @@ export function remoteSecretsRaw(target, args, opts = {}) {
|
|
|
93
93
|
extraSshArgs: opts.tty ? ['-tt'] : undefined,
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Run a remote `agents secrets <args>` FOREGROUND, with the local stdio wired
|
|
98
|
+
* straight through (`stdio: 'inherit'` + `-tt`), and return its exit code.
|
|
99
|
+
*
|
|
100
|
+
* Unlike `remoteSecretsRaw` — which pipes stdin, so even with `-tt` the remote
|
|
101
|
+
* process's `process.stdin.isTTY` is false and a passphrase prompt refuses to
|
|
102
|
+
* appear (the macOS file-store guard then hard-errors "needs
|
|
103
|
+
* AGENTS_SECRETS_PASSPHRASE") — this inherits the caller's real terminal, so the
|
|
104
|
+
* remote sees a genuine TTY and its hidden passphrase prompt surfaces and reads
|
|
105
|
+
* the keystrokes. This is the transport for `unlock --host`: you type the remote
|
|
106
|
+
* bundle's passphrase at your own terminal. Output is NOT captured (it streams
|
|
107
|
+
* to the terminal); only the exit code is returned.
|
|
108
|
+
*/
|
|
109
|
+
export function remoteSecretsStream(target, args) {
|
|
110
|
+
const remoteCmd = buildRemoteAgentsInvocation(['secrets', ...args], undefined, osForTarget(target));
|
|
111
|
+
return sshStream(target, remoteCmd, { tty: true });
|
|
112
|
+
}
|
|
96
113
|
/**
|
|
97
114
|
* Resolve a remote bundle to a plaintext env map by driving the remote's
|
|
98
115
|
* `agents secrets export <bundle> --plaintext --format json`. Values cross over
|
|
@@ -36,6 +36,10 @@ export interface ActiveSession {
|
|
|
36
36
|
worktree?: DetectedWorktree;
|
|
37
37
|
/** Tracker ticket the session is tied to. */
|
|
38
38
|
ticket?: DetectedTicket;
|
|
39
|
+
/** Tracker refs the session CREATED (Linear create_issue / gh issue create). */
|
|
40
|
+
createdTickets?: string[];
|
|
41
|
+
/** Team name the session SPAWNED via `agents teams create/add`. */
|
|
42
|
+
spawnedTeam?: string;
|
|
39
43
|
sessionFile?: string;
|
|
40
44
|
startedAtMs?: number;
|
|
41
45
|
status: ActiveStatus;
|
|
@@ -102,6 +106,12 @@ export interface ActiveQueryOptions {
|
|
|
102
106
|
/** Skip the `ps` scan for ad-hoc headless agents. */
|
|
103
107
|
skipHeadless?: boolean;
|
|
104
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Resolve an agent kind from a process's reported executable. `comm` may be an
|
|
111
|
+
* absolute path (shim-launched agents), and Windows image names carry an
|
|
112
|
+
* `.exe` suffix (`claude.exe`), so basename + suffix-strip before the lookup.
|
|
113
|
+
*/
|
|
114
|
+
export declare function agentKindFromComm(commRaw: string): string | undefined;
|
|
105
115
|
/**
|
|
106
116
|
* Pick a Claude transcript file within a project dir.
|
|
107
117
|
*
|
|
@@ -64,7 +64,16 @@ const AGENT_CLI_NAMES = {
|
|
|
64
64
|
* absolute path (shim-launched agents), and Windows image names carry an
|
|
65
65
|
* `.exe` suffix (`claude.exe`), so basename + suffix-strip before the lookup.
|
|
66
66
|
*/
|
|
67
|
-
function agentKindFromComm(commRaw) {
|
|
67
|
+
export function agentKindFromComm(commRaw) {
|
|
68
|
+
// A GUI desktop app can bundle a binary with the SAME name as an agent CLI: the
|
|
69
|
+
// Codex desktop app ships `/Applications/Codex.app/Contents/Resources/codex` (its
|
|
70
|
+
// `app-server`), whose basename `codex` would otherwise match the codex CLI and
|
|
71
|
+
// surface the app's background server as a phantom agent session — running at cwd
|
|
72
|
+
// '/', so it shows up unattributed in the feed. A real agent CLI is never inside a
|
|
73
|
+
// `.app` bundle, so exclude those. (The Claude desktop app is a separate case,
|
|
74
|
+
// already excluded by name below: its process is 'Claude', not the CLI's 'claude'.)
|
|
75
|
+
if (commRaw.includes('.app/Contents/'))
|
|
76
|
+
return undefined;
|
|
68
77
|
const base = path.basename(commRaw);
|
|
69
78
|
const stripped = base.replace(/\.exe$/i, '');
|
|
70
79
|
// Windows image names compare case-insensitively; POSIX comms stay exact —
|
|
@@ -232,6 +241,8 @@ function applyState(base, state, fallbackFile) {
|
|
|
232
241
|
pr: state.pr,
|
|
233
242
|
worktree: state.worktree,
|
|
234
243
|
ticket: state.ticket,
|
|
244
|
+
createdTickets: state.createdTickets,
|
|
245
|
+
spawnedTeam: state.spawnedTeam,
|
|
235
246
|
};
|
|
236
247
|
}
|
|
237
248
|
/**
|