@phnx-labs/agents-cli 1.20.35 → 1.20.36

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 (225) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/LICENSE +185 -21
  3. package/README.md +8 -4
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer.js +81 -0
  11. package/dist/commands/daemon.js +4 -1
  12. package/dist/commands/doctor.js +1 -89
  13. package/dist/commands/events.js +3 -3
  14. package/dist/commands/exec.d.ts +19 -0
  15. package/dist/commands/exec.js +277 -11
  16. package/dist/commands/hosts.js +10 -6
  17. package/dist/commands/inspect.js +8 -9
  18. package/dist/commands/lock.d.ts +12 -0
  19. package/dist/commands/lock.js +70 -0
  20. package/dist/commands/message.d.ts +15 -0
  21. package/dist/commands/message.js +56 -0
  22. package/dist/commands/routines.js +101 -5
  23. package/dist/commands/secrets-migrate.js +106 -57
  24. package/dist/commands/secrets.d.ts +31 -18
  25. package/dist/commands/secrets.js +156 -75
  26. package/dist/commands/serve.d.ts +10 -0
  27. package/dist/commands/serve.js +37 -0
  28. package/dist/commands/sessions-inject.d.ts +14 -0
  29. package/dist/commands/sessions-inject.js +111 -0
  30. package/dist/commands/sessions-picker.d.ts +2 -0
  31. package/dist/commands/sessions-picker.js +24 -3
  32. package/dist/commands/sessions-resume.js +20 -8
  33. package/dist/commands/sessions.d.ts +71 -1
  34. package/dist/commands/sessions.js +365 -37
  35. package/dist/commands/setup.js +4 -2
  36. package/dist/commands/sync.d.ts +3 -1
  37. package/dist/commands/sync.js +156 -4
  38. package/dist/commands/teams.js +217 -0
  39. package/dist/commands/versions.js +2 -4
  40. package/dist/commands/watchdog.d.ts +18 -0
  41. package/dist/commands/watchdog.js +238 -0
  42. package/dist/index.js +25 -2
  43. package/dist/lib/audit/log.d.ts +92 -0
  44. package/dist/lib/audit/log.js +177 -0
  45. package/dist/lib/auto-pull.js +2 -1
  46. package/dist/lib/browser/chrome.d.ts +10 -0
  47. package/dist/lib/browser/chrome.js +18 -7
  48. package/dist/lib/browser/drivers/ssh.js +2 -1
  49. package/dist/lib/browser/har.d.ts +84 -0
  50. package/dist/lib/browser/har.js +77 -0
  51. package/dist/lib/browser/ipc.js +24 -3
  52. package/dist/lib/browser/profiles.d.ts +1 -1
  53. package/dist/lib/browser/profiles.js +8 -10
  54. package/dist/lib/browser/refs.d.ts +65 -0
  55. package/dist/lib/browser/refs.js +73 -1
  56. package/dist/lib/browser/runtime-state.js +1 -0
  57. package/dist/lib/browser/service.d.ts +38 -2
  58. package/dist/lib/browser/service.js +112 -8
  59. package/dist/lib/browser/types.d.ts +14 -1
  60. package/dist/lib/budget/live-cloud.d.ts +42 -0
  61. package/dist/lib/budget/live-cloud.js +79 -0
  62. package/dist/lib/budget/live-team.d.ts +31 -0
  63. package/dist/lib/budget/live-team.js +115 -0
  64. package/dist/lib/cloud/codex.js +4 -0
  65. package/dist/lib/cloud/rush.d.ts +12 -1
  66. package/dist/lib/cloud/rush.js +13 -3
  67. package/dist/lib/cloud/types.d.ts +9 -0
  68. package/dist/lib/computer/dispatch.d.ts +8 -0
  69. package/dist/lib/computer/dispatch.js +125 -0
  70. package/dist/lib/computer/loop.d.ts +62 -0
  71. package/dist/lib/computer/loop.js +98 -0
  72. package/dist/lib/computer/model.d.ts +44 -0
  73. package/dist/lib/computer/model.js +157 -0
  74. package/dist/lib/concurrency.d.ts +19 -0
  75. package/dist/lib/concurrency.js +33 -0
  76. package/dist/lib/daemon.d.ts +57 -0
  77. package/dist/lib/daemon.js +192 -16
  78. package/dist/lib/devices/registry.d.ts +7 -0
  79. package/dist/lib/devices/registry.js +24 -0
  80. package/dist/lib/devices/tailscale.js +1 -1
  81. package/dist/lib/drift.d.ts +52 -0
  82. package/dist/lib/drift.js +112 -0
  83. package/dist/lib/events.d.ts +1 -1
  84. package/dist/lib/events.js +31 -13
  85. package/dist/lib/exec.d.ts +17 -0
  86. package/dist/lib/exec.js +79 -13
  87. package/dist/lib/git.d.ts +27 -0
  88. package/dist/lib/git.js +56 -1
  89. package/dist/lib/hooks/cache.d.ts +6 -0
  90. package/dist/lib/hooks/cache.js +54 -12
  91. package/dist/lib/hooks.d.ts +27 -0
  92. package/dist/lib/hooks.js +127 -8
  93. package/dist/lib/hosts/dispatch.d.ts +15 -0
  94. package/dist/lib/hosts/dispatch.js +39 -6
  95. package/dist/lib/hosts/logs.js +30 -1
  96. package/dist/lib/hosts/option.js +1 -1
  97. package/dist/lib/hosts/passthrough.js +3 -1
  98. package/dist/lib/hosts/ready.d.ts +29 -6
  99. package/dist/lib/hosts/ready.js +66 -15
  100. package/dist/lib/hosts/registry.d.ts +19 -2
  101. package/dist/lib/hosts/registry.js +58 -2
  102. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  103. package/dist/lib/hosts/remote-cmd.js +70 -1
  104. package/dist/lib/hosts/remote-os.d.ts +17 -0
  105. package/dist/lib/hosts/remote-os.js +30 -0
  106. package/dist/lib/hosts/session-index.d.ts +34 -0
  107. package/dist/lib/hosts/session-index.js +56 -0
  108. package/dist/lib/hosts/tasks.d.ts +14 -0
  109. package/dist/lib/hosts/tasks.js +15 -0
  110. package/dist/lib/lock.d.ts +93 -0
  111. package/dist/lib/lock.js +207 -0
  112. package/dist/lib/loop.js +16 -1
  113. package/dist/lib/machine-id.d.ts +21 -0
  114. package/dist/lib/machine-id.js +26 -0
  115. package/dist/lib/mailbox-target.d.ts +36 -0
  116. package/dist/lib/mailbox-target.js +45 -0
  117. package/dist/lib/mailbox.d.ts +47 -0
  118. package/dist/lib/mailbox.js +194 -0
  119. package/dist/lib/mcp.d.ts +5 -0
  120. package/dist/lib/mcp.js +24 -8
  121. package/dist/lib/migrate.d.ts +19 -0
  122. package/dist/lib/migrate.js +134 -26
  123. package/dist/lib/overdue.js +3 -0
  124. package/dist/lib/picker.d.ts +2 -0
  125. package/dist/lib/picker.js +4 -1
  126. package/dist/lib/platform/exec.d.ts +46 -0
  127. package/dist/lib/platform/exec.js +74 -0
  128. package/dist/lib/platform/process.d.ts +31 -0
  129. package/dist/lib/platform/process.js +34 -1
  130. package/dist/lib/platform/winpath.js +2 -0
  131. package/dist/lib/plugins.js +16 -6
  132. package/dist/lib/profiles.d.ts +25 -0
  133. package/dist/lib/profiles.js +22 -6
  134. package/dist/lib/pty-client.js +2 -1
  135. package/dist/lib/rotate.d.ts +61 -0
  136. package/dist/lib/rotate.js +52 -0
  137. package/dist/lib/routines.d.ts +40 -2
  138. package/dist/lib/routines.js +66 -8
  139. package/dist/lib/runner.d.ts +11 -2
  140. package/dist/lib/runner.js +49 -7
  141. package/dist/lib/scheduler.js +6 -1
  142. package/dist/lib/secrets/bundles.d.ts +60 -4
  143. package/dist/lib/secrets/bundles.js +131 -12
  144. package/dist/lib/secrets/filestore.d.ts +3 -0
  145. package/dist/lib/secrets/filestore.js +42 -16
  146. package/dist/lib/secrets/index.d.ts +43 -2
  147. package/dist/lib/secrets/index.js +102 -3
  148. package/dist/lib/secrets/mcp.d.ts +93 -0
  149. package/dist/lib/secrets/mcp.js +205 -0
  150. package/dist/lib/secrets/remote.js +12 -5
  151. package/dist/lib/secrets/sync.js +83 -4
  152. package/dist/lib/secrets/windows.js +14 -3
  153. package/dist/lib/serve/data.d.ts +81 -0
  154. package/dist/lib/serve/data.js +91 -0
  155. package/dist/lib/serve/page.d.ts +7 -0
  156. package/dist/lib/serve/page.js +140 -0
  157. package/dist/lib/serve/server.d.ts +46 -0
  158. package/dist/lib/serve/server.js +115 -0
  159. package/dist/lib/session/active.d.ts +54 -0
  160. package/dist/lib/session/active.js +190 -19
  161. package/dist/lib/session/discover.d.ts +37 -0
  162. package/dist/lib/session/discover.js +111 -28
  163. package/dist/lib/session/inject.d.ts +18 -0
  164. package/dist/lib/session/inject.js +21 -0
  165. package/dist/lib/session/parse.js +23 -20
  166. package/dist/lib/session/pid-registry.d.ts +1 -0
  167. package/dist/lib/session/pid-registry.js +24 -0
  168. package/dist/lib/session/provenance.d.ts +14 -2
  169. package/dist/lib/session/provenance.js +39 -8
  170. package/dist/lib/session/remote-active.js +19 -7
  171. package/dist/lib/session/remote-list.d.ts +51 -0
  172. package/dist/lib/session/remote-list.js +213 -0
  173. package/dist/lib/session/remote.d.ts +7 -1
  174. package/dist/lib/session/remote.js +16 -2
  175. package/dist/lib/session/sync/config.d.ts +1 -15
  176. package/dist/lib/session/sync/config.js +4 -20
  177. package/dist/lib/session/types.d.ts +17 -0
  178. package/dist/lib/shims.d.ts +36 -6
  179. package/dist/lib/shims.js +91 -29
  180. package/dist/lib/ssh-exec.js +2 -0
  181. package/dist/lib/ssh-tunnel.js +2 -1
  182. package/dist/lib/startup/command-registry.d.ts +6 -0
  183. package/dist/lib/startup/command-registry.js +13 -1
  184. package/dist/lib/state.d.ts +13 -0
  185. package/dist/lib/state.js +103 -9
  186. package/dist/lib/sync-umbrella.d.ts +14 -7
  187. package/dist/lib/sync-umbrella.js +17 -9
  188. package/dist/lib/teams/forEach.d.ts +110 -0
  189. package/dist/lib/teams/forEach.js +186 -0
  190. package/dist/lib/teams/index.d.ts +1 -0
  191. package/dist/lib/teams/index.js +1 -0
  192. package/dist/lib/teams/pr-watch.d.ts +226 -0
  193. package/dist/lib/teams/pr-watch.js +371 -0
  194. package/dist/lib/teams/supervisor.d.ts +14 -1
  195. package/dist/lib/teams/supervisor.js +19 -0
  196. package/dist/lib/teams/worktree.d.ts +9 -0
  197. package/dist/lib/teams/worktree.js +32 -0
  198. package/dist/lib/terminal/backends/index.d.ts +2 -1
  199. package/dist/lib/terminal/backends/index.js +3 -1
  200. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  201. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  202. package/dist/lib/terminal/index.d.ts +4 -1
  203. package/dist/lib/terminal/index.js +4 -1
  204. package/dist/lib/terminal/inject.d.ts +204 -0
  205. package/dist/lib/terminal/inject.js +247 -0
  206. package/dist/lib/terminal/resolve.d.ts +64 -0
  207. package/dist/lib/terminal/resolve.js +90 -0
  208. package/dist/lib/terminal/types.d.ts +1 -1
  209. package/dist/lib/triggers/webhook.d.ts +85 -0
  210. package/dist/lib/triggers/webhook.js +141 -0
  211. package/dist/lib/versions.d.ts +23 -0
  212. package/dist/lib/versions.js +119 -13
  213. package/dist/lib/watchdog/index.d.ts +3 -0
  214. package/dist/lib/watchdog/index.js +5 -0
  215. package/dist/lib/watchdog/read.d.ts +35 -0
  216. package/dist/lib/watchdog/read.js +149 -0
  217. package/dist/lib/watchdog/runner.d.ts +127 -0
  218. package/dist/lib/watchdog/runner.js +322 -0
  219. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  220. package/dist/lib/watchdog/watchdog.js +166 -0
  221. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  222. package/dist/lib/watchdog/watchdogTail.js +154 -0
  223. package/dist/lib/workflows.d.ts +166 -0
  224. package/dist/lib/workflows.js +193 -0
  225. package/package.json +5 -4
@@ -7,7 +7,16 @@
7
7
  */
8
8
  import { type Command } from 'commander';
9
9
  import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
10
+ import { quoteWin32ExecArg } from '../lib/platform/index.js';
11
+ import { type SecretsBundle, type SecretsPolicy } from '../lib/secrets/bundles.js';
10
12
  export { SSH_TARGET_RE, assertValidSshTarget };
13
+ /**
14
+ * Build the child environment for `agents secrets exec`. Strips
15
+ * loader/interpreter hijack vars (matching agent spawns in exec.ts) and never
16
+ * forwards AGENTS_SECRETS_PASSPHRASE — the master decryption key must not reach
17
+ * the executed command.
18
+ */
19
+ export declare function buildSecretsExecEnv(parentEnv: NodeJS.ProcessEnv, secretEnv: Record<string, string>): NodeJS.ProcessEnv;
11
20
  /**
12
21
  * Serialize a resolved env map to `.env` lines that round-trip losslessly through
13
22
  * `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
@@ -16,25 +25,29 @@ export { SSH_TARGET_RE, assertValidSshTarget };
16
25
  * parse, so multi-line values are rejected rather than silently corrupted.
17
26
  */
18
27
  export declare function bundleEnvToDotenv(env: Record<string, string>): string;
28
+ /** The POLICY column for `secrets list`: the prompt policy, plus a "Nh left"
29
+ * hint when a `daily` bundle is currently held by the secrets-agent. `held`
30
+ * maps bundle name → expiry epoch-ms (from agentStatus()). */
31
+ export declare function renderPolicyCol(b: SecretsBundle, held?: Map<string, number>): string;
19
32
  /** Register the `agents secrets` command tree. */
20
33
  export declare function registerSecretsCommands(program: Command): void;
34
+ /** Validate a prompt-policy value, throwing a clear message on a bad one (the
35
+ * caller's try/catch renders it and exits). Accepts the legacy `biometry` /
36
+ * `session` / `none` tokens as aliases for `always` / `daily` / `never` so older
37
+ * flags and scripts keep working. `never`/`none` is the no-biometry-ACL tier —
38
+ * accepted here, then gated behind a loud confirmation in the command layer. */
39
+ export declare function parsePolicyOpt(raw: string | undefined): SecretsPolicy;
21
40
  /**
22
- * Quote one argument for a Windows `cmd.exe` command line, as built by Node's
23
- * `spawn(..., { shell: true })` on win32 (`agents secrets exec`). cmd.exe does
24
- * NO quoting of its own, so an unquoted arg with a space is split into several
25
- * args, and a cmd metacharacter (`&|<>()^`) would be interpreted by the shell.
26
- * We wrap any arg with whitespace, a quote, or a metacharacter in double quotes
27
- * and escape embedded quotes / trailing backslashes per the CommandLineToArgvW
28
- * rules, so the *child's* argv parse reconstructs the original argument.
29
- *
30
- * CAVEAT: cmd.exe expands `%VAR%` (always) and `!VAR!` (under delayed expansion)
31
- * BEFORE argv parsing, and double-quoting does NOT suppress `%`/`!` (the
32
- * "BatBadBut" / CVE-2024-1874 class). We deliberately do not escape `%`/`!`:
33
- * `agents secrets exec` runs a caller-supplied command against a bundle the
34
- * caller owns, so caller-controlled `%`/`!` is not a privilege boundary. If that
35
- * ever changes (exec'ing an untrusted command line), route through a shell that
36
- * disables expansion rather than relying on this quoter. An empty arg becomes
37
- * `""`. Exported for tests. No-ops on non-Windows (the caller only invokes it
38
- * under `process.platform === 'win32'`).
41
+ * Gate a create/switch to the `never` prompt-policy behind an explicit,
42
+ * deliberate acknowledgement it is the least-safe tier (no user-presence
43
+ * check on any read). Returns:
44
+ * - `'ok'` — not `never`, or already acknowledged via `--i-understand`.
45
+ * - `'prompt'` interactive shell: caller must run a loud confirm prompt.
46
+ * Throws in a non-interactive shell when the flag is absent, so a headless
47
+ * `create --policy never` can't silently downgrade a bundle's protection.
39
48
  */
40
- export declare function quoteWin32ExecArg(arg: string): string;
49
+ export declare function assertNeverPolicyAcknowledged(policy: SecretsPolicy | undefined, opts: {
50
+ iUnderstand?: boolean;
51
+ interactive: boolean;
52
+ }): 'ok' | 'prompt';
53
+ export { quoteWin32ExecArg };
@@ -10,8 +10,10 @@ import chalk from 'chalk';
10
10
  import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
11
11
  import * as fs from 'fs';
12
12
  import { SSH_TARGET_RE, assertValidSshTarget, sshExec } from '../lib/ssh-exec.js';
13
+ import { quoteWin32ExecArg, composeWin32CommandLine } from '../lib/platform/index.js';
14
+ import { ensureDaemonStarted } from '../lib/daemon.js';
13
15
  import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, resolveSshTarget, } from '../lib/secrets/remote.js';
14
- import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
16
+ import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
15
17
  import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
16
18
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
17
19
  import { DEFAULT_TTL_MS, agentLoad, agentLock, agentStatus, ensureAgentRunning, installSecretsAgentService, runAgentLoadFromStdin, runSecretsAgent, secretsAgentServiceInstalled, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
@@ -139,6 +141,31 @@ function readStdinSync() {
139
141
  // SSH target validation is defined canonically in src/lib/ssh-exec.ts and
140
142
  // re-exported here for back-compat with existing importers of these symbols.
141
143
  export { SSH_TARGET_RE, assertValidSshTarget };
144
+ /**
145
+ * Build the child environment for `agents secrets exec`. Strips
146
+ * loader/interpreter hijack vars (matching agent spawns in exec.ts) and never
147
+ * forwards AGENTS_SECRETS_PASSPHRASE — the master decryption key must not reach
148
+ * the executed command.
149
+ */
150
+ export function buildSecretsExecEnv(parentEnv, secretEnv) {
151
+ const env = { ...sanitizeProcessEnv(parentEnv), ...secretEnv };
152
+ delete env.AGENTS_SECRETS_PASSPHRASE;
153
+ return env;
154
+ }
155
+ /**
156
+ * Resolve the CLI version for the MCP server's `serverInfo.version`. package.json
157
+ * sits at the repo root — two levels up from both `src/commands/` (bun/tsx dev)
158
+ * and `dist/commands/` (built). Cosmetic only, so any failure falls back cleanly.
159
+ */
160
+ function getCliVersion() {
161
+ try {
162
+ const pkgPath = new URL('../../package.json', import.meta.url);
163
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version ?? '0.0.0';
164
+ }
165
+ catch {
166
+ return '0.0.0';
167
+ }
168
+ }
142
169
  /** POSIX single-quote a string for safe interpolation into a remote shell command. */
143
170
  function shellQuote(s) {
144
171
  return `'${s.replace(/'/g, `'\\''`)}'`;
@@ -264,7 +291,10 @@ function compactRemaining(expiresAt) {
264
291
  /** The POLICY column for `secrets list`: the prompt policy, plus a "Nh left"
265
292
  * hint when a `daily` bundle is currently held by the secrets-agent. `held`
266
293
  * maps bundle name → expiry epoch-ms (from agentStatus()). */
267
- function renderPolicyCol(b, held) {
294
+ export function renderPolicyCol(b, held) {
295
+ // `never` is loud on purpose — it's the only tier with no user-presence gate.
296
+ if (bundlePolicy(b) === 'never')
297
+ return chalk.red.bold('never · NO ACL');
268
298
  if (bundlePolicy(b) === 'always')
269
299
  return chalk.yellow('always ask');
270
300
  const exp = held?.get(b.name);
@@ -505,7 +535,7 @@ export function registerSecretsCommands(program) {
505
535
  { title: 'Agent commands', names: ['start', 'stop', 'unlock', 'lock', 'status', 'policy'] },
506
536
  { title: 'Raw item commands', names: ['get', 'set'] },
507
537
  { title: 'Sync commands', names: ['push', 'pull', 'remote-list'] },
508
- { title: 'Utilities', names: ['exec', 'generate', 'migrate-acl'] },
538
+ { title: 'Utilities', names: ['exec', 'mcp', 'generate', 'migrate-acl'] },
509
539
  ]);
510
540
  cmd
511
541
  .command('list')
@@ -588,9 +618,14 @@ export function registerSecretsCommands(program) {
588
618
  console.log(chalk.yellow('allow_exec: true'));
589
619
  if (bundle.backend === 'file')
590
620
  console.log(chalk.gray('backend: file (passphrase-encrypted; reads need AGENTS_SECRETS_PASSPHRASE, no Touch ID)'));
591
- console.log(bundlePolicy(bundle) === 'daily'
592
- ? chalk.gray('policy: daily (ask once, then held ~24h until screen-lock / sleep / logout)')
593
- : chalk.gray('policy: always (asks for Touch ID every time — never auto-held)'));
621
+ if (bundlePolicy(bundle) === 'never') {
622
+ console.log(chalk.red.bold('policy: never NO biometry ACL; reads are silent (no Touch ID, no user-presence check). Automation-only.'));
623
+ }
624
+ else {
625
+ console.log(bundlePolicy(bundle) === 'daily'
626
+ ? chalk.gray('policy: daily (ask once, then held ~24h until screen-lock / sleep / logout)')
627
+ : chalk.gray('policy: always (asks for Touch ID every time — never auto-held)'));
628
+ }
594
629
  if (bundle.created_at)
595
630
  console.log(chalk.gray(`created_at: ${bundle.created_at} (${humanAge(bundle.created_at)})`));
596
631
  if (bundle.updated_at)
@@ -712,8 +747,9 @@ export function registerSecretsCommands(program) {
712
747
  .description('Create an empty bundle')
713
748
  .option('--description <text>', 'Free-form description')
714
749
  .option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
715
- .option('--policy <policy>', 'prompt policy: daily (default, ask once a day) or always (ask every time)')
750
+ .option('--policy <policy>', 'prompt policy: daily (default, ask once a day), always (ask every time), or never (silent, NO biometry ACL — needs --i-understand)')
716
751
  .addOption(new Option('--tier <policy>', 'deprecated alias for --policy').hideHelp())
752
+ .option('--i-understand', 'Confirm creating a "never"-policy bundle (no biometry ACL) without an interactive prompt')
717
753
  .option('--backend <backend>', 'storage backend: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
718
754
  .option('--force', 'Overwrite an existing bundle')
719
755
  .action(async (name, opts) => {
@@ -729,6 +765,13 @@ export function registerSecretsCommands(program) {
729
765
  console.error(chalk.red(`Bundle '${resolvedName}' already exists. Use --force to overwrite.`));
730
766
  process.exit(1);
731
767
  }
768
+ // The `never` tier is the least-safe option — gate it loudly. Throws in a
769
+ // headless shell without --i-understand; prompts otherwise.
770
+ const ack = assertNeverPolicyAcknowledged(policy, { iUnderstand: opts.iUnderstand, interactive: isInteractiveTerminal() });
771
+ if (ack === 'prompt' && !(await confirmNeverPolicyInteractive(resolvedName))) {
772
+ console.error(chalk.yellow('Aborted.'));
773
+ return;
774
+ }
732
775
  const bundle = {
733
776
  name: resolvedName,
734
777
  description: opts.description,
@@ -738,11 +781,16 @@ export function registerSecretsCommands(program) {
738
781
  vars: {},
739
782
  };
740
783
  writeBundle(bundle);
741
- const tags = [
742
- bundlePolicy(bundle) === 'daily' ? 'policy: daily' : 'policy: always ask',
743
- backend === 'file' ? 'backend: file' : null,
744
- ].filter(Boolean);
784
+ const policyTag = bundlePolicy(bundle) === 'daily'
785
+ ? 'policy: daily'
786
+ : bundlePolicy(bundle) === 'always'
787
+ ? 'policy: always ask'
788
+ : 'policy: never (NO biometry ACL)';
789
+ const tags = [policyTag, backend === 'file' ? 'backend: file' : null].filter(Boolean);
745
790
  console.log(chalk.green(`Bundle '${resolvedName}' created (${tags.join(', ')}).`));
791
+ if (bundlePolicy(bundle) === 'never') {
792
+ console.log(chalk.red('Stored without biometry protection — reads are silent. Automation-only; rotate anything sensitive out of it.'));
793
+ }
746
794
  if (backend === 'file') {
747
795
  console.log(chalk.gray('File-backed: items are AES-256-GCM encrypted under AGENTS_SECRETS_PASSPHRASE (no Touch ID).'));
748
796
  }
@@ -876,7 +924,7 @@ export function registerSecretsCommands(program) {
876
924
  secretValue = await promptForSecret(`Enter value for ${resolvedBundleName}.${resolvedKey}`);
877
925
  }
878
926
  const item = secretsKeychainItem(resolvedBundleName, resolvedKey);
879
- bundleItemStore(bundle.backend).set(item, secretValue);
927
+ bundleItemStore(bundle.backend, { noAcl: bundlePolicy(bundle) === 'never' }).set(item, secretValue);
880
928
  bundle.vars[resolvedKey] = keychainRef(resolvedKey);
881
929
  applyMeta();
882
930
  writeBundle(bundle);
@@ -1122,7 +1170,7 @@ Examples:
1122
1170
  vars: {},
1123
1171
  };
1124
1172
  }
1125
- const store = bundleItemStore(bundle.backend);
1173
+ const store = bundleItemStore(bundle.backend, { noAcl: bundlePolicy(bundle) === 'never' });
1126
1174
  let added = 0;
1127
1175
  let skipped = 0;
1128
1176
  if (opts.from1password) {
@@ -1332,6 +1380,8 @@ Examples:
1332
1380
  .command('exec <bundle> [command...]')
1333
1381
  .description('Run a command with the bundle\'s secrets injected into the environment (use --host to resolve the bundle from a remote machine, ephemerally)')
1334
1382
  .option('--host <target>', 'Resolve <bundle> on a remote host over SSH and inject it (ephemeral — never stored on this machine)')
1383
+ .option('--keys <keys>', 'Inject only this comma-separated subset of keys (e.g. KEY1,KEY2). Missing keys are an error.')
1384
+ .option('--allow-expired', 'Inject keys even if their expiry date has passed (overrides the pre-run expiry abort).')
1335
1385
  .allowUnknownOption()
1336
1386
  .action(async (bundleName, commandParts, execOpts) => {
1337
1387
  try {
@@ -1340,28 +1390,42 @@ Examples:
1340
1390
  process.exit(1);
1341
1391
  }
1342
1392
  const [cmd, ...args] = commandParts;
1393
+ const keysSubset = execOpts.keys
1394
+ ? execOpts.keys.split(',').map((k) => k.trim()).filter(Boolean)
1395
+ : undefined;
1343
1396
  let secretEnv;
1344
1397
  if (execOpts.host) {
1398
+ // Least-privilege flags do not yet cross the SSH resolver —
1399
+ // silently applying them would inject the full remote env or an
1400
+ // expired key. Fail loud so the user can drop the flag or run
1401
+ // locally instead.
1402
+ const { assertRemoteBundleFlagsUnsupported } = await import('../lib/secrets/bundles.js');
1403
+ assertRemoteBundleFlagsUnsupported(bundleName, execOpts.host, { keys: keysSubset, allowExpired: execOpts.allowExpired }, { keysFlag: '--keys', allowExpiredFlag: '--allow-expired' });
1345
1404
  secretEnv = await remoteResolveEnv(await resolveSshTarget(execOpts.host), bundleName);
1346
1405
  }
1347
1406
  else {
1348
1407
  const { readAndResolveBundleEnv } = await import('../lib/secrets/bundles.js');
1349
- secretEnv = readAndResolveBundleEnv(bundleName, { caller: `command ${cmd}` }).env;
1408
+ secretEnv = readAndResolveBundleEnv(bundleName, {
1409
+ caller: `command ${cmd}`,
1410
+ keys: keysSubset,
1411
+ allowExpired: execOpts.allowExpired,
1412
+ }).env;
1350
1413
  }
1351
1414
  const { spawn } = await import('child_process');
1352
1415
  // On Windows, spawn without a shell ENOENTs for `.cmd`/`.bat` launchers
1353
1416
  // (npm, yarn, most JS CLIs) and shell built-ins, so we set shell:true.
1354
1417
  // With shell:true Node hands cmd.exe a single command line with NO quoting
1355
- // of its own, so args containing spaces or cmd metacharacters must be
1356
- // quoted here (quoteWin32ExecArg) or they'd be split. See that helper for
1418
+ // of its own. Compose that line ourselves (composeWin32CommandLine quotes
1419
+ // every token) and pass an EMPTY args array so Node never concatenates the
1420
+ // user-supplied args unescaped (DEP0190 + injection). See that helper for
1357
1421
  // the cmd.exe %VAR%/!VAR! expansion caveat.
1358
1422
  const useShell = process.platform === 'win32';
1359
- const spawnCmd = useShell ? quoteWin32ExecArg(cmd) : cmd;
1360
- const spawnArgs = useShell ? args.map(quoteWin32ExecArg) : args;
1423
+ const spawnCmd = useShell ? composeWin32CommandLine(cmd, args) : cmd;
1424
+ const spawnArgs = useShell ? [] : args;
1361
1425
  const proc = spawn(spawnCmd, spawnArgs, {
1362
1426
  stdio: 'inherit',
1363
1427
  shell: useShell,
1364
- env: { ...process.env, ...secretEnv },
1428
+ env: buildSecretsExecEnv(process.env, secretEnv),
1365
1429
  });
1366
1430
  proc.on('close', (code) => process.exit(code ?? 0));
1367
1431
  proc.on('error', (err) => {
@@ -1376,6 +1440,17 @@ Examples:
1376
1440
  process.exit(1);
1377
1441
  }
1378
1442
  });
1443
+ cmd
1444
+ .command('mcp')
1445
+ .description('Run a stdio MCP server exposing get_secret(bundle, key) — hand credentials to an MCP-speaking agent by name at call time, never through the child process environment')
1446
+ .action(async () => {
1447
+ // JIT credential delivery (#333): unlike `secrets exec`, which bakes every
1448
+ // resolved value into the child's env, this speaks MCP over stdio so a
1449
+ // framework requests one secret at a time and the raw value never enters
1450
+ // process.env. stdout is the JSON-RPC channel — nothing else may print there.
1451
+ const { runSecretsMcpServer } = await import('../lib/secrets/mcp.js');
1452
+ await runSecretsMcpServer({ version: getCliVersion() });
1453
+ });
1379
1454
  cmd
1380
1455
  .command('generate [length]')
1381
1456
  .description('Generate a random password')
@@ -1478,6 +1553,12 @@ Examples:
1478
1553
  console.error(chalk.red('Could not start the secrets-agent.'));
1479
1554
  process.exit(1);
1480
1555
  }
1556
+ // #415: the daemon should be always-on for any background need, not only
1557
+ // after `routines add`. A user who only ever unlocks secrets still gets
1558
+ // the daemon installed + running here. `ensureAgentRunning` above only
1559
+ // brings up the standalone secrets broker, not the daemon. Idempotent
1560
+ // (single-instance start lock, #414) and best-effort — never blocks unlock.
1561
+ ensureDaemonStarted();
1481
1562
  let loaded = 0;
1482
1563
  for (const name of targets) {
1483
1564
  try {
@@ -1547,8 +1628,9 @@ Examples:
1547
1628
  cmd
1548
1629
  .command('policy <bundle> [policy]')
1549
1630
  .alias('tier')
1550
- .description("Show or set a bundle's prompt policy: daily (default, ask once a day) or always (ask every time).")
1551
- .action((bundleName, policyArg) => {
1631
+ .description("Show or set a bundle's prompt policy: daily (default, ask once a day), always (ask every time), or never (silent, NO biometry ACL).")
1632
+ .option('--i-understand', 'Confirm switching to the "never" policy (no biometry ACL) without an interactive prompt')
1633
+ .action(async (bundleName, policyArg, opts) => {
1552
1634
  try {
1553
1635
  const bundle = readBundle(bundleName);
1554
1636
  if (policyArg === undefined) {
@@ -1556,15 +1638,24 @@ Examples:
1556
1638
  return;
1557
1639
  }
1558
1640
  const next = parsePolicyOpt(policyArg);
1641
+ // Switching to `never` drops the biometry ACL — gate it exactly like create.
1642
+ const ack = assertNeverPolicyAcknowledged(next, { iUnderstand: opts.iUnderstand, interactive: isInteractiveTerminal() });
1643
+ if (ack === 'prompt' && !(await confirmNeverPolicyInteractive(bundle.name))) {
1644
+ console.error(chalk.yellow('Aborted.'));
1645
+ return;
1646
+ }
1559
1647
  bundle.policy = next;
1560
1648
  writeBundle(bundle);
1561
1649
  console.log(chalk.green(`${bundle.name} policy set to ${next}.`));
1562
1650
  if (next === 'daily') {
1563
1651
  console.log(chalk.gray('Held by the secrets-agent for ~24h after one unlock (auto-cache is on by default; disable with `secrets.agent.auto: false` in agents.yaml).'));
1564
1652
  }
1565
- else {
1653
+ else if (next === 'always') {
1566
1654
  console.log(chalk.gray('Asks for Touch ID every time — never auto-held.'));
1567
1655
  }
1656
+ else {
1657
+ console.log(chalk.red('Stored without biometry protection — reads are silent. Automation-only.'));
1658
+ }
1568
1659
  }
1569
1660
  catch (err) {
1570
1661
  console.error(chalk.red(err.message));
@@ -1613,23 +1704,52 @@ Examples:
1613
1704
  registerSecretsSyncCommands(cmd);
1614
1705
  registerSecretsMigrateAclCommand(cmd);
1615
1706
  }
1616
- /** Validate a prompt-policy value, exiting with a clear message on a bad one.
1617
- * Accepts the legacy `biometry`/`session` tokens as aliases for `always`/`daily`
1618
- * so older flags and scripts keep working. `never`/`none` (no biometry ACL) is
1619
- * rejected explicitly it needs a separate signed-helper change (see
1620
- * https://github.com/phnx-labs/agents-cli/issues/421). */
1621
- function parsePolicyOpt(raw) {
1707
+ /** Validate a prompt-policy value, throwing a clear message on a bad one (the
1708
+ * caller's try/catch renders it and exits). Accepts the legacy `biometry` /
1709
+ * `session` / `none` tokens as aliases for `always` / `daily` / `never` so older
1710
+ * flags and scripts keep working. `never`/`none` is the no-biometry-ACL tier
1711
+ * accepted here, then gated behind a loud confirmation in the command layer. */
1712
+ export function parsePolicyOpt(raw) {
1622
1713
  const v = (raw ?? 'always').toLowerCase();
1623
1714
  if (v === 'always' || v === 'biometry')
1624
1715
  return 'always';
1625
1716
  if (v === 'daily' || v === 'session')
1626
1717
  return 'daily';
1627
- if (v === 'never' || v === 'none') {
1628
- console.error(chalk.red("policy 'never' (no biometry ACL) is not available yet — see https://github.com/phnx-labs/agents-cli/issues/421. Use 'always' or 'daily'."));
1629
- process.exit(1);
1718
+ if (v === 'never' || v === 'none')
1719
+ return 'never';
1720
+ throw new Error(`Invalid policy '${raw}'. Use 'always', 'daily', or 'never'.`);
1721
+ }
1722
+ /**
1723
+ * Gate a create/switch to the `never` prompt-policy behind an explicit,
1724
+ * deliberate acknowledgement — it is the least-safe tier (no user-presence
1725
+ * check on any read). Returns:
1726
+ * - `'ok'` — not `never`, or already acknowledged via `--i-understand`.
1727
+ * - `'prompt'` — interactive shell: caller must run a loud confirm prompt.
1728
+ * Throws in a non-interactive shell when the flag is absent, so a headless
1729
+ * `create --policy never` can't silently downgrade a bundle's protection.
1730
+ */
1731
+ export function assertNeverPolicyAcknowledged(policy, opts) {
1732
+ if (policy !== 'never')
1733
+ return 'ok';
1734
+ if (opts.iUnderstand)
1735
+ return 'ok';
1736
+ if (!opts.interactive) {
1737
+ throw new Error("Refusing to set the 'never' prompt-policy without confirmation. This tier stores " +
1738
+ 'the bundle WITHOUT the biometry access control: every read is silent, with no Touch ID ' +
1739
+ 'and no user-presence check — any code running as you can read it. Re-run with ' +
1740
+ '--i-understand to confirm you want an unprotected, automation-only bundle.');
1630
1741
  }
1631
- console.error(chalk.red(`Invalid policy '${raw}'. Use 'always' or 'daily'.`));
1632
- process.exit(1);
1742
+ return 'prompt';
1743
+ }
1744
+ /** Loud confirm prompt shown before creating/switching to `never` in a TTY.
1745
+ * Returns true to proceed. Kept separate from assertNeverPolicyAcknowledged so
1746
+ * the decision logic stays pure and unit-testable. */
1747
+ async function confirmNeverPolicyInteractive(bundleName) {
1748
+ console.error(chalk.red.bold('WARNING: policy "never" stores this bundle with NO biometry ACL.'));
1749
+ console.error(chalk.red(`Reads of '${bundleName}' will be fully silent — no Touch ID, no user-presence check.`));
1750
+ console.error(chalk.yellow('Use it only for low-sensitivity, automation-only credentials.'));
1751
+ const { confirm } = await import('@inquirer/prompts');
1752
+ return confirm({ message: `Store '${bundleName}' WITHOUT biometry protection?`, default: false });
1633
1753
  }
1634
1754
  /** Validate a --backend value, exiting with a clear message on a bad one. */
1635
1755
  function parseBackendOpt(raw) {
@@ -1653,48 +1773,9 @@ function humanRemaining(expiresAt) {
1653
1773
  const days = Math.round(hours / 24);
1654
1774
  return `locks in ${days} day${days === 1 ? '' : 's'}`;
1655
1775
  }
1656
- /**
1657
- * Quote one argument for a Windows `cmd.exe` command line, as built by Node's
1658
- * `spawn(..., { shell: true })` on win32 (`agents secrets exec`). cmd.exe does
1659
- * NO quoting of its own, so an unquoted arg with a space is split into several
1660
- * args, and a cmd metacharacter (`&|<>()^`) would be interpreted by the shell.
1661
- * We wrap any arg with whitespace, a quote, or a metacharacter in double quotes
1662
- * and escape embedded quotes / trailing backslashes per the CommandLineToArgvW
1663
- * rules, so the *child's* argv parse reconstructs the original argument.
1664
- *
1665
- * CAVEAT: cmd.exe expands `%VAR%` (always) and `!VAR!` (under delayed expansion)
1666
- * BEFORE argv parsing, and double-quoting does NOT suppress `%`/`!` (the
1667
- * "BatBadBut" / CVE-2024-1874 class). We deliberately do not escape `%`/`!`:
1668
- * `agents secrets exec` runs a caller-supplied command against a bundle the
1669
- * caller owns, so caller-controlled `%`/`!` is not a privilege boundary. If that
1670
- * ever changes (exec'ing an untrusted command line), route through a shell that
1671
- * disables expansion rather than relying on this quoter. An empty arg becomes
1672
- * `""`. Exported for tests. No-ops on non-Windows (the caller only invokes it
1673
- * under `process.platform === 'win32'`).
1674
- */
1675
- export function quoteWin32ExecArg(arg) {
1676
- if (arg.length > 0 && !/[\s"&|<>()^]/.test(arg))
1677
- return arg;
1678
- let result = '"';
1679
- let backslashes = 0;
1680
- for (const ch of arg) {
1681
- if (ch === '\\') {
1682
- backslashes += 1;
1683
- continue;
1684
- }
1685
- if (ch === '"') {
1686
- // Double the run of backslashes, then escape this quote.
1687
- result += '\\'.repeat(backslashes * 2 + 1) + '"';
1688
- backslashes = 0;
1689
- continue;
1690
- }
1691
- result += '\\'.repeat(backslashes) + ch;
1692
- backslashes = 0;
1693
- }
1694
- // Trailing backslashes precede the closing quote → must be doubled.
1695
- result += '\\'.repeat(backslashes * 2) + '"';
1696
- return result;
1697
- }
1776
+ // `quoteWin32ExecArg` now lives in lib/platform/exec.ts (shared with the agent
1777
+ // run/shim spawn paths); re-exported here for the colocated secrets tests.
1778
+ export { quoteWin32ExecArg };
1698
1779
  /**
1699
1780
  * Copy text to the system clipboard, cross-platform.
1700
1781
  * macOS: `pbcopy`. Windows: `clip`. Linux: tries `wl-copy` (Wayland), then
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `agents serve` — read-only local web companion.
3
+ *
4
+ * Boots a localhost-only (127.0.0.1) HTTP + SSE server that renders a live
5
+ * dashboard of team status + per-worktree diffs, scheduled routines, and cloud
6
+ * tasks. Everything is a viewer over data other commands already own — there
7
+ * are no mutation endpoints. See src/lib/serve/.
8
+ */
9
+ import type { Command } from 'commander';
10
+ export declare function registerServeCommand(program: Command): void;
@@ -0,0 +1,37 @@
1
+ import chalk from 'chalk';
2
+ import { startServeServer, DEFAULT_SERVE_PORT, SERVE_HOST } from '../lib/serve/server.js';
3
+ export function registerServeCommand(program) {
4
+ program
5
+ .command('serve')
6
+ .description('Read-only local web companion: team diffs, routines, and cloud status (binds 127.0.0.1 only).')
7
+ .option('-p, --port <n>', `Port to bind on ${SERVE_HOST}`, String(DEFAULT_SERVE_PORT))
8
+ .option('--interval <ms>', 'SSE refresh cadence in milliseconds', '3000')
9
+ .action(async (opts) => {
10
+ const port = parseInt(opts.port ?? '', 10) || DEFAULT_SERVE_PORT;
11
+ const intervalMs = parseInt(opts.interval ?? '', 10) || 3000;
12
+ try {
13
+ const { server, port: bound } = await startServeServer(port, {
14
+ cwd: process.cwd(),
15
+ intervalMs,
16
+ });
17
+ const url = `http://${SERVE_HOST}:${bound}`;
18
+ console.log(`${chalk.green('agents serve')} ${chalk.dim('→')} ${chalk.cyan(url)}`);
19
+ console.log(chalk.dim('read-only · localhost only · Ctrl-C to stop'));
20
+ const shutdown = () => {
21
+ server.close(() => process.exit(0));
22
+ };
23
+ process.on('SIGINT', shutdown);
24
+ process.on('SIGTERM', shutdown);
25
+ }
26
+ catch (err) {
27
+ const msg = String(err?.message ?? err);
28
+ if (msg.includes('EADDRINUSE')) {
29
+ console.error(chalk.red(`Port ${port} is already in use. Pass --port <n> to pick another.`));
30
+ }
31
+ else {
32
+ console.error(chalk.red(`Could not start serve: ${msg}`));
33
+ }
34
+ process.exit(1);
35
+ }
36
+ });
37
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `agents sessions inject <sessionId> <text>` — deliver text into the terminal a
3
+ * running session lives in. The CLI face of the Terminal Engine's Gap 2 primitive
4
+ * (`injectIntoTerminal`, src/lib/terminal/inject.ts), so a native watchdog
5
+ * (RUSH-1415) can shell out to nudge a stalled agent with "continue".
6
+ *
7
+ * Resolution: find the active session by id, map its `provenance.reply` rail
8
+ * (provenance.ts:47) to an engine `InjectTarget` and inject. Today only tmux rails
9
+ * are externally addressable; `--pane`/`--pty` target a backend directly when the
10
+ * handle is already known (skipping the session lookup).
11
+ */
12
+ import type { Command } from 'commander';
13
+ /** Attach the `inject` subcommand to an existing `sessions` command. */
14
+ export declare function registerSessionsInjectCommand(sessionsCmd: Command): void;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `agents sessions inject <sessionId> <text>` — deliver text into the terminal a
3
+ * running session lives in. The CLI face of the Terminal Engine's Gap 2 primitive
4
+ * (`injectIntoTerminal`, src/lib/terminal/inject.ts), so a native watchdog
5
+ * (RUSH-1415) can shell out to nudge a stalled agent with "continue".
6
+ *
7
+ * Resolution: find the active session by id, map its `provenance.reply` rail
8
+ * (provenance.ts:47) to an engine `InjectTarget` and inject. Today only tmux rails
9
+ * are externally addressable; `--pane`/`--pty` target a backend directly when the
10
+ * handle is already known (skipping the session lookup).
11
+ */
12
+ import chalk from 'chalk';
13
+ import { getActiveSessions } from '../lib/session/active.js';
14
+ import { injectTargetFromReplyRail } from '../lib/session/inject.js';
15
+ import { injectIntoTerminal } from '../lib/terminal/index.js';
16
+ import { setHelpSections } from '../lib/help.js';
17
+ /** Resolve a session id (short or full) to an addressable terminal target. */
18
+ async function resolveTarget(sessionId) {
19
+ const sessions = await getActiveSessions();
20
+ const match = sessions.find((s) => s.sessionId === sessionId || (s.sessionId != null && s.sessionId.startsWith(sessionId)));
21
+ if (!match)
22
+ return { target: null, reason: `No active session matches "${sessionId}".` };
23
+ const rail = match.provenance?.reply;
24
+ if (!rail) {
25
+ return {
26
+ target: null,
27
+ reason: `Session "${sessionId}" has no addressable reply rail (not running under tmux). Relaunch it under a tmux/pty rail to inject.`,
28
+ };
29
+ }
30
+ const target = injectTargetFromReplyRail(rail);
31
+ if (!target)
32
+ return { target: null, reason: `Session "${sessionId}" reply rail is not injectable.` };
33
+ return { target };
34
+ }
35
+ async function runInject(sessionId, text, options) {
36
+ // Direct-target shortcuts skip the session lookup — the watchdog often already
37
+ // holds the pane id or pty session it wants to type into.
38
+ let target = null;
39
+ if (options.pty) {
40
+ target = { backend: 'pty', id: options.pty };
41
+ }
42
+ else if (options.pane) {
43
+ target = { backend: 'tmux', pane: options.pane, socket: options.socket };
44
+ }
45
+ else {
46
+ const resolved = await resolveTarget(sessionId);
47
+ if (!resolved.target) {
48
+ if (options.json)
49
+ console.log(JSON.stringify({ ok: false, error: resolved.reason }));
50
+ else
51
+ console.error(chalk.red(resolved.reason));
52
+ process.exit(1);
53
+ }
54
+ target = resolved.target;
55
+ }
56
+ const res = await injectIntoTerminal(target, text, {
57
+ enter: options.enter !== false,
58
+ combined: options.combined,
59
+ socket: options.socket,
60
+ host: options.host,
61
+ });
62
+ if (options.json) {
63
+ console.log(JSON.stringify(res));
64
+ }
65
+ else if (res.ok) {
66
+ console.log(chalk.green(`Injected into ${res.backend} (${res.writes} write${res.writes === 1 ? '' : 's'}).`));
67
+ }
68
+ else {
69
+ console.error(chalk.red(res.error ?? 'injection failed'));
70
+ }
71
+ if (!res.ok)
72
+ process.exit(1);
73
+ }
74
+ /** Attach the `inject` subcommand to an existing `sessions` command. */
75
+ export function registerSessionsInjectCommand(sessionsCmd) {
76
+ const injectCmd = sessionsCmd
77
+ .command('inject <sessionId> <text>')
78
+ .description('Deliver text (+ Enter) into the terminal a running session lives in — nudge a stalled agent.')
79
+ .option('--pane <id>', 'Target a tmux pane id directly (e.g. %3), skipping session lookup')
80
+ .option('--pty <id>', 'Target an agents-pty session id directly, skipping session lookup')
81
+ .option('--socket <path>', 'tmux socket path (defaults to the session/shared socket)')
82
+ .option('--host <target>', 'Deliver on a remote host over SSH (tmux/AppleScript backends)')
83
+ .option('--no-enter', 'Send only the text, without a trailing Enter')
84
+ .option('--combined', 'Fuse text + Enter into ONE write (default: two writes, Ink-TUI safe)')
85
+ .option('--json', 'Output the InjectResult as JSON');
86
+ setHelpSections(injectCmd, {
87
+ examples: `
88
+ # Nudge a stalled agent by session id (resolves its tmux pane)
89
+ agents sessions inject a1b2c3d4 "continue"
90
+
91
+ # Target a tmux pane directly (what a watchdog already holds)
92
+ agents sessions inject _ "continue" --pane %3 --socket /tmp/agents/tmux.sock
93
+
94
+ # Type into an agents-pty session without submitting
95
+ agents sessions inject _ "ls" --pty $SID --no-enter
96
+ `,
97
+ notes: `
98
+ - Ink-TUI Enter semantics: by default the text and Enter are two separate
99
+ writes, which is what Claude's Ink TUI needs. --combined fuses them.
100
+ - Only sessions running under a tmux pane are addressable by id today
101
+ (provenance.ts reply rails). Use --pane/--pty for direct targeting.
102
+ - Built on the Terminal Engine (src/lib/terminal): --host runs the tmux /
103
+ AppleScript spec over the same SSH transport the launch engine uses.
104
+ `,
105
+ });
106
+ // The parent `sessions` command also defines --json, so it binds there;
107
+ // optsWithGlobals() merges parent + subcommand options so --json is honored.
108
+ injectCmd.action(async (sessionId, text, _options, command) => {
109
+ await runInject(sessionId, text, command.optsWithGlobals());
110
+ });
111
+ }
@@ -5,6 +5,8 @@ export interface PickedSession {
5
5
  }
6
6
  export interface SessionPickerConfig {
7
7
  message: string;
8
+ /** Dim hint line shown under the header (filters/flags tip). */
9
+ subtitle?: string;
8
10
  sessions: SessionMeta[];
9
11
  filter: (query: string) => SessionMeta[];
10
12
  labelFor: (s: SessionMeta, query: string) => string;