@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +4 -3
  3. package/dist/commands/exec.js +84 -13
  4. package/dist/commands/hosts.js +5 -4
  5. package/dist/commands/logs.d.ts +4 -0
  6. package/dist/commands/logs.js +19 -13
  7. package/dist/commands/routines.d.ts +6 -0
  8. package/dist/commands/routines.js +70 -12
  9. package/dist/commands/secrets.d.ts +18 -0
  10. package/dist/commands/secrets.js +105 -30
  11. package/dist/commands/sessions.d.ts +6 -5
  12. package/dist/commands/sessions.js +50 -22
  13. package/dist/commands/teams.js +104 -8
  14. package/dist/lib/daemon.js +34 -9
  15. package/dist/lib/exec.d.ts +8 -0
  16. package/dist/lib/exec.js +70 -6
  17. package/dist/lib/hosts/dispatch.d.ts +29 -0
  18. package/dist/lib/hosts/dispatch.js +46 -1
  19. package/dist/lib/hosts/logs.d.ts +14 -5
  20. package/dist/lib/hosts/logs.js +39 -13
  21. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  22. package/dist/lib/hosts/remote-cmd.js +27 -0
  23. package/dist/lib/hosts/session-index.d.ts +15 -0
  24. package/dist/lib/hosts/session-index.js +28 -2
  25. package/dist/lib/redact.js +1 -0
  26. package/dist/lib/rotate.d.ts +33 -0
  27. package/dist/lib/rotate.js +37 -0
  28. package/dist/lib/secrets/remote.d.ts +14 -0
  29. package/dist/lib/secrets/remote.js +18 -1
  30. package/dist/lib/session/active.d.ts +10 -0
  31. package/dist/lib/session/active.js +12 -1
  32. package/dist/lib/session/db.d.ts +16 -9
  33. package/dist/lib/session/db.js +66 -44
  34. package/dist/lib/session/discover.d.ts +4 -0
  35. package/dist/lib/session/discover.js +84 -13
  36. package/dist/lib/session/run-names.d.ts +9 -7
  37. package/dist/lib/session/run-names.js +9 -7
  38. package/dist/lib/session/state.d.ts +29 -3
  39. package/dist/lib/session/state.js +84 -5
  40. package/dist/lib/session/types.d.ts +19 -8
  41. package/dist/lib/shims.d.ts +1 -1
  42. package/dist/lib/shims.js +17 -3
  43. package/dist/lib/teams/agents.js +25 -7
  44. package/dist/lib/tmux/session.d.ts +40 -0
  45. package/dist/lib/tmux/session.js +92 -0
  46. package/dist/lib/versions.d.ts +54 -1
  47. package/dist/lib/versions.js +138 -1
  48. 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
- cmd[0] = versionedName;
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', `if -F '#{==:#{hook_pane},${pane}}' 'detach-client -s =${name}' 'kill-pane'`, socket);
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 — read its status directly and tear down.
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 (!before.dead) {
880
- await attachTmux({ socket, args: ['attach-session', '-t', name] });
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);
@@ -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
- * otherwise the captured local mirror (`localLogPath`) is printed. Kept in one
7
- * place so the two commands can never drift.
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
- /** Show (or follow, when running) a dispatched host task's combined-stdout log. */
16
- export declare function showHostTaskLog(id: string, follow: boolean): Promise<HostLogResult>;
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;
@@ -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
- * otherwise the captured local mirror (`localLogPath`) is printed. Kept in one
7
- * place so the two commands can never drift.
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
- /** Show (or follow, when running) a dispatched host task's combined-stdout log. */
16
- export async function showHostTaskLog(id, follow) {
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
- process.stdout.write(fs.readFileSync(localLogPath(id), 'utf-8'));
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
- if (remote !== null) {
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
- return { found: true, exitCode: 0 };
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: `[host/${task.host}]`,
39
- name: task.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
+ }
@@ -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) {
@@ -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
  *
@@ -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
  /**