@phnx-labs/agents-cli 1.22.68 → 1.22.70

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 (132) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +17 -14
  3. package/dist/bootstrap.js +3 -0
  4. package/dist/commands/accounts.js +23 -7
  5. package/dist/commands/exec.js +33 -23
  6. package/dist/commands/feed.js +20 -7
  7. package/dist/commands/import.js +2 -2
  8. package/dist/commands/models.js +2 -2
  9. package/dist/commands/monitors.js +3 -0
  10. package/dist/commands/permissions.js +2 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/rules.js +1 -1
  14. package/dist/commands/send.js +35 -2
  15. package/dist/commands/sessions-inject.d.ts +58 -0
  16. package/dist/commands/sessions-inject.js +143 -7
  17. package/dist/commands/sessions-picker.js +1 -0
  18. package/dist/commands/share.js +43 -14
  19. package/dist/commands/ssh.js +205 -2
  20. package/dist/commands/traces.js +1 -1
  21. package/dist/lib/account-capabilities.js +0 -2
  22. package/dist/lib/account-registry.d.ts +3 -3
  23. package/dist/lib/account-registry.js +25 -7
  24. package/dist/lib/accounting/usage.d.ts +7 -2
  25. package/dist/lib/accounting/usage.js +142 -10
  26. package/dist/lib/acp/client.d.ts +1 -1
  27. package/dist/lib/acp/client.js +12 -1
  28. package/dist/lib/acp/harnesses.js +1 -1
  29. package/dist/lib/add-dir.js +0 -2
  30. package/dist/lib/agent-cli-commands.js +0 -2
  31. package/dist/lib/agent-spec/agents.d.ts +1 -1
  32. package/dist/lib/agent-spec/agents.js +2 -83
  33. package/dist/lib/boot-profile.d.ts +14 -0
  34. package/dist/lib/boot-profile.js +66 -0
  35. package/dist/lib/browser/ipc.js +9 -1
  36. package/dist/lib/browser/remote-control.d.ts +6 -3
  37. package/dist/lib/browser/remote-control.js +6 -3
  38. package/dist/lib/browser/service.d.ts +4 -1
  39. package/dist/lib/browser/service.js +7 -1
  40. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  41. package/dist/lib/channels/providers/desktop.js +5 -4
  42. package/dist/lib/channels/registry.d.ts +6 -0
  43. package/dist/lib/channels/send.d.ts +11 -2
  44. package/dist/lib/channels/send.js +11 -2
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/cloud/rush.d.ts +10 -2
  47. package/dist/lib/cloud/rush.js +15 -9
  48. package/dist/lib/daemon/daemon.js +28 -8
  49. package/dist/lib/daemon/runner.js +6 -2
  50. package/dist/lib/devices/health.d.ts +38 -2
  51. package/dist/lib/devices/health.js +43 -5
  52. package/dist/lib/devices/worker-pick.d.ts +1 -1
  53. package/dist/lib/devices/worker-pick.js +4 -1
  54. package/dist/lib/exec.d.ts +2 -2
  55. package/dist/lib/exec.js +7 -34
  56. package/dist/lib/feed-broadcast.d.ts +47 -5
  57. package/dist/lib/feed-broadcast.js +127 -24
  58. package/dist/lib/hooks/install.js +0 -87
  59. package/dist/lib/installations/strategies.js +1 -1
  60. package/dist/lib/mcp-registry.js +0 -13
  61. package/dist/lib/mcp.js +2 -2
  62. package/dist/lib/model-tiers.js +1 -1
  63. package/dist/lib/models.js +0 -63
  64. package/dist/lib/monitors/engine.js +18 -0
  65. package/dist/lib/monitors/sources/command.js +13 -3
  66. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  67. package/dist/lib/monitors/sources/failure.js +52 -0
  68. package/dist/lib/monitors/sources/types.d.ts +9 -0
  69. package/dist/lib/notify.d.ts +11 -0
  70. package/dist/lib/notify.js +17 -4
  71. package/dist/lib/owner-message.d.ts +55 -0
  72. package/dist/lib/owner-message.js +64 -0
  73. package/dist/lib/permissions-registry.d.ts +0 -2
  74. package/dist/lib/permissions-registry.js +3 -50
  75. package/dist/lib/permissions.d.ts +3 -17
  76. package/dist/lib/permissions.js +4 -73
  77. package/dist/lib/run-trace-sync.d.ts +15 -0
  78. package/dist/lib/run-trace-sync.js +43 -21
  79. package/dist/lib/rush-session.d.ts +19 -0
  80. package/dist/lib/rush-session.js +24 -0
  81. package/dist/lib/secrets/drivers/rush.js +2 -1
  82. package/dist/lib/secrets/filestore.d.ts +4 -0
  83. package/dist/lib/secrets/filestore.js +164 -3
  84. package/dist/lib/session/active.d.ts +10 -0
  85. package/dist/lib/session/active.js +3 -0
  86. package/dist/lib/session/cloud.js +2 -1
  87. package/dist/lib/session/db.d.ts +12 -1
  88. package/dist/lib/session/db.js +20 -1
  89. package/dist/lib/session/discover.js +81 -1
  90. package/dist/lib/session/linear.d.ts +13 -0
  91. package/dist/lib/session/linear.js +44 -0
  92. package/dist/lib/session/live-metadata.js +1 -0
  93. package/dist/lib/session/parse.js +2 -3
  94. package/dist/lib/session/prompt.d.ts +7 -1
  95. package/dist/lib/session/prompt.js +12 -2
  96. package/dist/lib/session/recovery.d.ts +21 -12
  97. package/dist/lib/session/recovery.js +29 -11
  98. package/dist/lib/session/remote/watch.js +5 -2
  99. package/dist/lib/session/state.js +11 -13
  100. package/dist/lib/share/backend.d.ts +2 -2
  101. package/dist/lib/share/backend.js +20 -9
  102. package/dist/lib/share/delete.d.ts +5 -1
  103. package/dist/lib/share/delete.js +7 -2
  104. package/dist/lib/share/http-error.d.ts +52 -0
  105. package/dist/lib/share/http-error.js +65 -0
  106. package/dist/lib/share/publish.d.ts +13 -3
  107. package/dist/lib/share/publish.js +19 -15
  108. package/dist/lib/share/worker-template.js +5 -1
  109. package/dist/lib/sink-format.d.ts +34 -0
  110. package/dist/lib/sink-format.js +17 -0
  111. package/dist/lib/smart-launch.js +27 -4
  112. package/dist/lib/staleness/detectors/permissions.js +0 -20
  113. package/dist/lib/staleness/writers/commands.js +1 -1
  114. package/dist/lib/staleness/writers/hooks.js +2 -2
  115. package/dist/lib/storage/index.d.ts +14 -0
  116. package/dist/lib/storage/index.js +14 -0
  117. package/dist/lib/storage/selection.d.ts +48 -0
  118. package/dist/lib/storage/selection.js +39 -0
  119. package/dist/lib/storage/visibility.d.ts +82 -0
  120. package/dist/lib/storage/visibility.js +99 -0
  121. package/dist/lib/subagents-registry.js +2 -12
  122. package/dist/lib/subagents.d.ts +0 -10
  123. package/dist/lib/subagents.js +0 -37
  124. package/dist/lib/teams/agents.js +3 -1
  125. package/dist/lib/teams/placement-probe.js +1 -0
  126. package/dist/lib/teams/scheduler.d.ts +8 -1
  127. package/dist/lib/teams/scheduler.js +4 -1
  128. package/dist/lib/traces/backend.js +13 -2
  129. package/dist/lib/types.d.ts +1 -1
  130. package/dist/lib/worktree/held.d.ts +166 -0
  131. package/dist/lib/worktree/held.js +368 -0
  132. package/package.json +2 -2
@@ -35,16 +35,33 @@ export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
35
35
  /** Max peers named in the skipped note before the rest collapse to `+N`. */
36
36
  const SKIPPED_NAME_LIMIT = 4;
37
37
  /**
38
- * Render `agents projects for-cwd`'s output for a resolved (or absent)
39
- * project name — `--json` always prints `{"name": ...}` even on no match, so
40
- * a scripted caller can distinguish "ran and found nothing" from a crash;
41
- * the plain-text form prints nothing on no match. Returns '' when nothing
42
- * should be printed.
38
+ * A path-shaped `agents projects view` argument — `.`, `..`, a `~`-prefixed
39
+ * value, or anything containing a path separator — means "auto-detect the
40
+ * project for this DIRECTORY", not "a project named literally this". A bare
41
+ * token (no separators) stays a project name, exactly as before.
43
42
  */
44
- export function formatForCwdOutput(name, json) {
45
- if (json)
46
- return JSON.stringify({ name: name ?? null });
47
- return name ?? '';
43
+ export function looksLikePath(token) {
44
+ return token === '.' || token === '..' || token.startsWith('~') || token.includes('/');
45
+ }
46
+ /**
47
+ * Best-effort cwd->project detection for `agents projects view <path>`. Delegates
48
+ * to the shared {@link projectNameForCwd} (def-root containment, longest wins),
49
+ * then reads the matched definition's Linear binding and root so ONE call yields
50
+ * the name AND the Linear projectId. Fail-open: an all-null shape when nothing
51
+ * contains the path — never throws, so a scripted caller stays unscoped rather
52
+ * than crashing. This subsumes the removed `agents projects for-cwd`.
53
+ */
54
+ export function detectProjectForPath(cwd, defs) {
55
+ const name = projectNameForCwd(cwd, defs);
56
+ const def = name ? defs.find((d) => d.name === name) : undefined;
57
+ return {
58
+ name: name ?? null,
59
+ linear: {
60
+ name: def?.linear?.name ?? null,
61
+ projectId: def?.linear?.projectId ?? null,
62
+ },
63
+ root: def?.root ?? null,
64
+ };
48
65
  }
49
66
  /**
50
67
  * One compact trailing note for peers that didn't answer the `--fleet`
@@ -570,17 +587,6 @@ export function registerProjectsCommands(program) {
570
587
  console.log(` ${chalk.bold(row.name.padEnd(w.name))} ${chalk.dim(row.path.padEnd(w.path))} ${chalk.cyan(row.repo.padEnd(w.repo))}${agentsSuffix}`);
571
588
  }
572
589
  });
573
- // ---- for-cwd ----
574
- projects
575
- .command('for-cwd [cwd]')
576
- .description('Resolve a directory to its defined project name (root or a repos[].path/subpath match). Defaults to the current directory.')
577
- .option('--json', 'Machine-readable output: {"name": string | null}')
578
- .action((cwdArg, opts) => {
579
- const name = projectNameForCwd(cwdArg ?? process.cwd(), listProjectDefs());
580
- const out = formatForCwdOutput(name, !!opts.json);
581
- if (out)
582
- console.log(out);
583
- });
584
590
  // ---- add ----
585
591
  projects
586
592
  .command('add <name>')
@@ -763,15 +769,42 @@ export function registerProjectsCommands(program) {
763
769
  process.stdout.write(formatFleetSkippedNote(fleetSkipped));
764
770
  }
765
771
  projects
766
- .command('status [name]')
772
+ .command('status [nameOrPath]')
767
773
  .alias('view')
768
- .description('Progress card for every project across the whole fleet, or one named project (alias: view). Named form also prints every milestone and the stored definition.')
774
+ .description('Progress card for every project across the whole fleet, or one named project (alias: view). Named form also prints every milestone and the stored definition. A path argument (., .., a ~-prefixed value, a /-containing value, or --path) auto-detects the project that CONTAINS that directory.')
769
775
  .option('--json', 'Machine-readable output')
776
+ .option('--path [dir]', 'Treat the argument as a DIRECTORY and auto-detect the project that contains it (bare --path uses the cwd). With --json prints {name, linear:{name,projectId}, root}, all-null when nothing matches (fail-open, exit 0).')
770
777
  .option('--window <days>', 'Window for merged PRs, artifacts, and focus areas', '7')
771
778
  .option('--no-remote', 'Skip the GitHub and Linear lookups; faster, offline')
772
779
  .option('--device <name...>', 'Scope fleet status to one or more devices (repeatable)')
773
780
  .option('--devices <names>', 'Scope fleet status to a comma-separated list of devices')
774
781
  .action(async (name, rawOpts) => {
782
+ // Path mode: an explicit --path, or a path-shaped positional (., .., a
783
+ // ~-prefixed value, or a /-containing value) means "auto-detect the
784
+ // project for this DIRECTORY" rather than "a project named literally
785
+ // this". projectNameForCwd resolves the dir (expandLocalHome +
786
+ // path.resolve), so `.`, `~/…`, and relative paths all normalize. This
787
+ // subsumes the removed `agents projects for-cwd`.
788
+ let detectDir;
789
+ if (typeof rawOpts.path === 'string')
790
+ detectDir = rawOpts.path;
791
+ else if (rawOpts.path === true)
792
+ detectDir = process.cwd();
793
+ else if (name !== undefined && looksLikePath(name))
794
+ detectDir = name;
795
+ if (detectDir !== undefined) {
796
+ const detection = detectProjectForPath(detectDir, listProjectDefs());
797
+ if (rawOpts.json) {
798
+ console.log(JSON.stringify(detection));
799
+ return;
800
+ }
801
+ if (!detection.name) {
802
+ console.log(chalk.gray(`No defined project contains ${detectDir}`));
803
+ return;
804
+ }
805
+ // Non-JSON: fall through and render the full card for the detected project.
806
+ name = detection.name;
807
+ }
775
808
  // Named invocation = `view` depth (all milestones + definition). Unnamed
776
809
  // stays the scannable multi-project rollup. `view` is a commander alias
777
810
  // of this same command, so there is only one implementation.
@@ -52,7 +52,7 @@ Project rules & @-imports:
52
52
  @path imports inside AGENTS.md/CLAUDE.md are resolved at session start by the agent
53
53
  itself, not by agents-cli. Support is per-agent:
54
54
  Inlined natively: claude
55
- Literal text: codex, cursor, opencode, copilot, amp, kiro, goose
55
+ Literal text: codex, cursor, opencode, copilot, amp, goose
56
56
 
57
57
  For rules that need to work across all agents, inline the content rather than using
58
58
  @-imports — the second group will load '@path/to/file.md' as a literal string.
@@ -2,7 +2,9 @@ import chalk from 'chalk';
2
2
  import { die } from '../lib/format.js';
3
3
  import { setHelpSections } from '../lib/help.js';
4
4
  import { readMeta } from '../lib/state.js';
5
- import { sendMessage } from '../lib/channels/send.js';
5
+ import { sendMessage, isOwnerAlias } from '../lib/channels/send.js';
6
+ import { ownerMessageComposer } from '../lib/owner-message.js';
7
+ import { fireTraceSyncInBackground } from '../lib/run-trace-sync.js';
6
8
  function mergeAttachments(opts) {
7
9
  const list = [...(opts.attach ?? []), ...(opts.attachment ?? [])];
8
10
  return list.length ? list : undefined;
@@ -23,7 +25,34 @@ function toInput(positionalText, opts, ownerMode) {
23
25
  }
24
26
  async function runSend(positionalText, opts, ownerMode) {
25
27
  const meta = readMeta();
26
- const out = await sendMessage(toInput(positionalText, opts, ownerMode), meta);
28
+ let input = toInput(positionalText, opts, ownerMode);
29
+ // An owner-bound ping (`agents notify`, `agents send --to owner`) goes through
30
+ // the SAME composer as an important `feed post` (PHNX-3698): short-shaped body,
31
+ // TEAM-N keys linkified, session crumb as a tappable console URL — instead of a
32
+ // raw dump. A non-owner send (explicit --channel/--to) is delivered verbatim.
33
+ let ownerCompose;
34
+ if (ownerMode || isOwnerAlias(opts.to)) {
35
+ const flagged = opts.text?.trim() ?? '';
36
+ const positional = (positionalText ?? '').trim();
37
+ const raw = flagged || positional;
38
+ // When both forms are given and disagree, leave it to sendMessage to fail
39
+ // loud with the "pass the message once" error rather than composing a guess.
40
+ const bothDiffer = flagged !== '' && positional !== '' && flagged !== positional;
41
+ if (raw && !bothDiffer) {
42
+ // Resolve the context once, then let each owner destination render its own
43
+ // format: the envelope carries the plain default (for --json and dry-run
44
+ // display), while ownerCompose hands sendToOwner the mrkdwn variant for a
45
+ // Slack owner destination and plain for iMessage (PHNX-3698).
46
+ ownerCompose = ownerMessageComposer(raw);
47
+ input = { ...input, text: ownerCompose('plain'), positionalText: undefined };
48
+ // The console URL in the composed body only resolves once this session's
49
+ // trace shard is uploaded; fire that now so the tapped link isn't a 404.
50
+ // A --dry-run resolves + composes but MUST NOT act (its documented contract),
51
+ // so it never spawns the sync — it just shows what would be sent.
52
+ fireTraceSyncInBackground({ disabled: Boolean(opts.dryRun) });
53
+ }
54
+ }
55
+ const out = await sendMessage(input, meta, ownerCompose);
27
56
  if ('error' in out) {
28
57
  die(out.error);
29
58
  }
@@ -127,6 +156,10 @@ export function registerSendCommand(program) {
127
156
  Set owner.channels + owner.policy.normal in humans.yaml once per fleet.
128
157
  Every channel listed in the normal policy receives an owner-addressed send.
129
158
 
159
+ Owner sends go through the same composer as "feed post": the body is
160
+ short-shaped, any TEAM-N key becomes a Linear URL, and the session crumb
161
+ becomes a tappable https://prix.dev/console/sessions/<id> link.
162
+
130
163
  ${SHARED_NOTES}
131
164
  `,
132
165
  });
@@ -13,5 +13,63 @@
13
13
  * `--pane`/`--pty` target a backend directly when the handle is already known.
14
14
  */
15
15
  import type { Command } from 'commander';
16
+ import { type ActiveSession } from '../lib/session/active.js';
17
+ interface InjectOptions {
18
+ pane?: string;
19
+ socket?: string;
20
+ pty?: string;
21
+ /**
22
+ * The remote device. A single `--device box` arrives here as `['box']` because
23
+ * the parent `sessions` command's variadic `-D, --device <target...>` shadows
24
+ * this subcommand's scalar option under `optsWithGlobals()` — normalize it with
25
+ * {@link normalizeInjectDevice} before use (PHNX-3688).
26
+ */
27
+ device?: string | string[];
28
+ enter?: boolean;
29
+ combined?: boolean;
30
+ json?: boolean;
31
+ }
32
+ /**
33
+ * Whether an active session is the one `sessions inject <token>` means. Matches
34
+ * a resolvable session id (exact or unique prefix) AND — for a tmux-hosted row
35
+ * whose full id never resolved (`sessionId` absent) — the `ag-<agent>-<shortid>`
36
+ * tmux name's `shortid` suffix (exact or prefix), the full tmux name, and the
37
+ * pane id. Those are the only selectors an id-less remote tmux row exposes, so
38
+ * without this an operator has no tool-native way to nudge it (PHNX-3688).
39
+ */
40
+ export declare function matchInjectSelector(session: ActiveSession, token: string): boolean;
41
+ /**
42
+ * The `--device` selector, normalized to a single host string. `optsWithGlobals()`
43
+ * merges the parent `sessions` command's variadic `-D, --device <target...>` over
44
+ * this subcommand's scalar `--device`, so a single `--device box` arrives as
45
+ * `['box']` — which flowed straight into `sshExec` and crashed on
46
+ * `host.startsWith` (PHNX-3688). Coerce the array to its one element; fail loud on
47
+ * several, since inject delivers to exactly one terminal (a fan-out spelling is a
48
+ * user error, not a first-of-list guess).
49
+ */
50
+ export declare function normalizeInjectDevice(value: string | string[] | undefined): string | undefined;
51
+ /**
52
+ * The `agents sessions inject` argv to re-run ON a device (its tmux panes live
53
+ * there, so resolution must happen there). Every flag rides along EXCEPT
54
+ * `--device`: the command runs on the device, resolving locally. Pure so the
55
+ * forwarded invocation is asserted without an SSH hop (PHNX-3688).
56
+ */
57
+ export declare function buildRemoteInjectArgv(sessionId: string, text: string, options: InjectOptions): string[];
58
+ /**
59
+ * Resolve `device` (registry alias or `user@host`) to an ssh target and re-run
60
+ * `agents sessions inject` there, so a bare session id + `--device` resolves on
61
+ * the box that actually holds the session's tmux panes. The tool-native form of
62
+ * the `agents ssh <device> "agents sessions inject <id> …"` workaround (PHNX-3688).
63
+ */
64
+ /**
65
+ * Resolve `--device` to an ssh target. A registered device becomes its
66
+ * `user@dnsName`; a bare unknown name (an ad-hoc `user@host` or ssh_config alias)
67
+ * is handed to ssh verbatim (`resolveHost` returns null for it). A registered
68
+ * device we CANNOT dial — password-auth, addressless — throws its typed error and
69
+ * is NOT degraded to the raw name, which could ssh a coincidentally-matching but
70
+ * unrelated `~/.ssh/config` Host (PHNX-3688 review).
71
+ */
72
+ export declare function resolveInjectSshTarget(device: string): Promise<string>;
16
73
  /** Attach the `inject` subcommand to an existing `sessions` command. */
17
74
  export declare function registerSessionsInjectCommand(sessionsCmd: Command): void;
75
+ export {};
@@ -13,14 +13,121 @@
13
13
  * `--pane`/`--pty` target a backend directly when the handle is already known.
14
14
  */
15
15
  import chalk from 'chalk';
16
- import { getActiveSessions } from '../lib/session/active.js';
16
+ import { getActiveSessions, shortIdFromName } from '../lib/session/active.js';
17
17
  import { injectIntoTerminal, resolveInjectTargetForSession } from '../lib/terminal/index.js';
18
+ import { sshExec, shellQuote } from '../lib/ssh-exec.js';
19
+ import { resolveHost } from '../lib/hosts/registry.js';
20
+ import { sshTargetFor } from '../lib/hosts/types.js';
18
21
  import { setHelpSections } from '../lib/help.js';
22
+ /**
23
+ * Whether an active session is the one `sessions inject <token>` means. Matches
24
+ * a resolvable session id (exact or unique prefix) AND — for a tmux-hosted row
25
+ * whose full id never resolved (`sessionId` absent) — the `ag-<agent>-<shortid>`
26
+ * tmux name's `shortid` suffix (exact or prefix), the full tmux name, and the
27
+ * pane id. Those are the only selectors an id-less remote tmux row exposes, so
28
+ * without this an operator has no tool-native way to nudge it (PHNX-3688).
29
+ */
30
+ export function matchInjectSelector(session, token) {
31
+ if (!token)
32
+ return false;
33
+ const sid = session.sessionId;
34
+ if (sid && (sid === token || sid.startsWith(token)))
35
+ return true;
36
+ const short = session.tmuxName ? shortIdFromName(session.tmuxName) : undefined;
37
+ if (short && (short === token || short.startsWith(token)))
38
+ return true;
39
+ if (session.tmuxName && session.tmuxName === token)
40
+ return true;
41
+ if (session.paneId && session.paneId === token)
42
+ return true;
43
+ return false;
44
+ }
45
+ /**
46
+ * The `--device` selector, normalized to a single host string. `optsWithGlobals()`
47
+ * merges the parent `sessions` command's variadic `-D, --device <target...>` over
48
+ * this subcommand's scalar `--device`, so a single `--device box` arrives as
49
+ * `['box']` — which flowed straight into `sshExec` and crashed on
50
+ * `host.startsWith` (PHNX-3688). Coerce the array to its one element; fail loud on
51
+ * several, since inject delivers to exactly one terminal (a fan-out spelling is a
52
+ * user error, not a first-of-list guess).
53
+ */
54
+ export function normalizeInjectDevice(value) {
55
+ const list = value == null ? [] : Array.isArray(value) ? value : [value];
56
+ const hosts = list.map((v) => String(v).trim()).filter((v) => v.length > 0);
57
+ if (hosts.length === 0)
58
+ return undefined;
59
+ if (hosts.length > 1) {
60
+ throw new Error(`sessions inject targets a single device, but --device named ${hosts.length}: ${hosts.join(', ')}.`);
61
+ }
62
+ return hosts[0];
63
+ }
64
+ /**
65
+ * The `agents sessions inject` argv to re-run ON a device (its tmux panes live
66
+ * there, so resolution must happen there). Every flag rides along EXCEPT
67
+ * `--device`: the command runs on the device, resolving locally. Pure so the
68
+ * forwarded invocation is asserted without an SSH hop (PHNX-3688).
69
+ */
70
+ export function buildRemoteInjectArgv(sessionId, text, options) {
71
+ const argv = ['agents', 'sessions', 'inject', sessionId, text];
72
+ if (options.enter === false)
73
+ argv.push('--no-enter');
74
+ if (options.combined)
75
+ argv.push('--combined');
76
+ if (options.socket)
77
+ argv.push('--socket', options.socket);
78
+ if (options.pty)
79
+ argv.push('--pty', options.pty);
80
+ if (options.pane)
81
+ argv.push('--pane', options.pane);
82
+ if (options.json)
83
+ argv.push('--json');
84
+ return argv;
85
+ }
86
+ /**
87
+ * Resolve `device` (registry alias or `user@host`) to an ssh target and re-run
88
+ * `agents sessions inject` there, so a bare session id + `--device` resolves on
89
+ * the box that actually holds the session's tmux panes. The tool-native form of
90
+ * the `agents ssh <device> "agents sessions inject <id> …"` workaround (PHNX-3688).
91
+ */
92
+ /**
93
+ * Resolve `--device` to an ssh target. A registered device becomes its
94
+ * `user@dnsName`; a bare unknown name (an ad-hoc `user@host` or ssh_config alias)
95
+ * is handed to ssh verbatim (`resolveHost` returns null for it). A registered
96
+ * device we CANNOT dial — password-auth, addressless — throws its typed error and
97
+ * is NOT degraded to the raw name, which could ssh a coincidentally-matching but
98
+ * unrelated `~/.ssh/config` Host (PHNX-3688 review).
99
+ */
100
+ export async function resolveInjectSshTarget(device) {
101
+ const host = await resolveHost(device);
102
+ return host ? sshTargetFor(host) : device;
103
+ }
104
+ async function injectOnDevice(sessionId, text, options, device) {
105
+ let target;
106
+ try {
107
+ target = await resolveInjectSshTarget(device);
108
+ }
109
+ catch (err) {
110
+ const message = err instanceof Error ? err.message : String(err);
111
+ if (options.json)
112
+ console.log(JSON.stringify({ ok: false, error: message }));
113
+ else
114
+ console.error(chalk.red(message));
115
+ process.exit(1);
116
+ }
117
+ const remoteCmd = buildRemoteInjectArgv(sessionId, text, options).map(shellQuote).join(' ');
118
+ const res = sshExec(target, remoteCmd, { multiplex: true });
119
+ if (res.stdout)
120
+ process.stdout.write(res.stdout);
121
+ if (res.stderr)
122
+ process.stderr.write(res.stderr);
123
+ if (res.code !== 0)
124
+ process.exit(res.code ?? 1);
125
+ }
19
126
  /** Resolve a session id (short or full) to an addressable terminal target, via the
20
127
  * same resolver the watchdog uses so both agree on what is reachable. */
21
128
  async function resolveTarget(sessionId) {
22
129
  const sessions = await getActiveSessions();
23
- const match = sessions.find((s) => s.sessionId === sessionId || (s.sessionId != null && s.sessionId.startsWith(sessionId)));
130
+ const match = sessions.find((s) => matchInjectSelector(s, sessionId));
24
131
  if (!match)
25
132
  return { target: null, reason: `No active session matches "${sessionId}".` };
26
133
  const resolution = resolveInjectTargetForSession(match);
@@ -35,8 +142,21 @@ async function resolveTarget(sessionId) {
35
142
  return { target: resolution.target };
36
143
  }
37
144
  async function runInject(sessionId, text, options) {
145
+ let device;
146
+ try {
147
+ device = normalizeInjectDevice(options.device);
148
+ }
149
+ catch (err) {
150
+ const message = err instanceof Error ? err.message : String(err);
151
+ if (options.json)
152
+ console.log(JSON.stringify({ ok: false, error: message }));
153
+ else
154
+ console.error(chalk.red(message));
155
+ process.exit(1);
156
+ }
38
157
  // Direct-target shortcuts skip the session lookup — the watchdog often already
39
- // holds the pane id or pty session it wants to type into.
158
+ // holds the pane id or pty session it wants to type into. The pane's exact
159
+ // address is known, so it composes with --device (tmux send-keys over SSH).
40
160
  let target = null;
41
161
  if (options.pty) {
42
162
  target = { backend: 'pty', id: options.pty };
@@ -44,6 +164,12 @@ async function runInject(sessionId, text, options) {
44
164
  else if (options.pane) {
45
165
  target = { backend: 'tmux', pane: options.pane, socket: options.socket };
46
166
  }
167
+ else if (device) {
168
+ // A bare session id + a device: the session's tmux panes live ON that device,
169
+ // so getActiveSessions here can't see them. Resolve + deliver THERE by re-running
170
+ // inject over SSH (the same command, minus --device) — PHNX-3688.
171
+ return injectOnDevice(sessionId, text, options, device);
172
+ }
47
173
  else {
48
174
  const resolved = await resolveTarget(sessionId);
49
175
  if (!resolved.target) {
@@ -60,7 +186,7 @@ async function runInject(sessionId, text, options) {
60
186
  enter: options.enter !== false,
61
187
  combined: options.combined,
62
188
  socket: options.socket,
63
- host: options.device,
189
+ host: device,
64
190
  });
65
191
  if (options.json) {
66
192
  console.log(JSON.stringify(res));
@@ -82,7 +208,7 @@ export function registerSessionsInjectCommand(sessionsCmd) {
82
208
  .option('--pane <id>', 'Target a tmux pane id directly (e.g. %3), skipping session lookup')
83
209
  .option('--pty <id>', 'Target an agents-pty session id directly, skipping session lookup')
84
210
  .option('--socket <path>', 'tmux socket path (defaults to the session/shared socket)')
85
- .option('--device <target>', 'Deliver on a remote device over SSH (tmux/AppleScript backends)')
211
+ .option('--device <target>', 'Deliver on a remote device over SSH. With a bare session id, the session is resolved ON that device; with --pane, the pane is addressed there directly.')
86
212
  .option('--no-enter', 'Send only the text, without a trailing Enter')
87
213
  .option('--combined', 'Fuse text + Enter into ONE write (default: two writes, Ink-TUI safe)')
88
214
  .option('--json', 'Output the InjectResult as JSON');
@@ -96,6 +222,12 @@ export function registerSessionsInjectCommand(sessionsCmd) {
96
222
 
97
223
  # Type into an agents-pty session without submitting
98
224
  agents sessions inject _ "ls" --pty $SID --no-enter
225
+
226
+ # Nudge a live session on another box (resolved on the device)
227
+ agents sessions inject 214edaae "continue" --device yosemite-s0
228
+
229
+ # Address a known remote pane directly (skips lookup, sends over SSH)
230
+ agents sessions inject _ "continue" --pane %122 --socket $SOCK --device yosemite-s0
99
231
  `,
100
232
  notes: `
101
233
  - Ink-TUI Enter semantics: by default the text and Enter are two separate
@@ -103,8 +235,12 @@ export function registerSessionsInjectCommand(sessionsCmd) {
103
235
  - A session is addressable by id when it resolves to a precise split —
104
236
  tmux, iTerm, a VSCodium/Cursor/VS Code integrated terminal, or a pty
105
237
  (resolveInjectTargetForSession). Use --pane/--pty for direct targeting.
106
- - Built on the Terminal Engine (src/lib/terminal): --device runs the tmux /
107
- AppleScript spec over the same SSH transport the launch engine uses.
238
+ - The id may be the session id (short or full) OR the '<shortid>' suffix of
239
+ a tmux target (ag-<agent>-<shortid>) — the only selector a live tmux
240
+ session whose id column shows '-' exposes.
241
+ - Built on the Terminal Engine (src/lib/terminal): with --pane, --device
242
+ runs the tmux send-keys spec over SSH; with a bare id, --device re-runs
243
+ the lookup on that box (its tmux panes live there, not here).
108
244
  `,
109
245
  });
110
246
  // The parent `sessions` command also defines --json, so it binds there;
@@ -104,6 +104,7 @@ function sanitizeMeta(s) {
104
104
  version: clean(s.version),
105
105
  account: clean(s.account),
106
106
  topic: clean(s.topic),
107
+ firstUserMessage: clean(s.firstUserMessage),
107
108
  label: clean(s.label),
108
109
  ticketId: clean(s.ticketId),
109
110
  prUrl: clean(s.prUrl),
@@ -11,11 +11,13 @@ import { Argument, Option } from 'commander';
11
11
  import chalk from 'chalk';
12
12
  import { DEFAULT_BUCKET_NAME, DEFAULT_CF_BUNDLE, DEFAULT_SHARE_DOMAIN, DEFAULT_WORKER_NAME, generateWriteToken, readCloudflareCreds, readShareConfig, readWriteToken, readWriteTokenEnv, readWriteTokenFromBundle, storeWriteToken, writeShareConfig, } from '../lib/share/config.js';
13
13
  import { addCustomDomain, configureBucketLifecycle, createBucket, deployWorker, enableWorkersDev, findZoneId, hashWorkerScript, putWorkerSecret, updateWorker, WORKER_PHOENIX_ID_BASE_SECRET, setWorkerSecret, } from '../lib/share/provision.js';
14
- import { publishFile, resolveShareUsername, parseMetaEntries, sanitizeLabel, resolveShareVisibility, scanShareContent, formatSensitiveContentError, unlistedNotPrivateWarning, SHARE_VISIBILITY_LEVELS, PUBLISH_VISIBILITY_LEVELS, } from '../lib/share/publish.js';
14
+ import { publishFile, resolveShareUsername, parseMetaEntries, sanitizeLabel, scanShareContent, formatSensitiveContentError, unlistedNotPrivateWarning, SHARE_VISIBILITY_LEVELS, PUBLISH_VISIBILITY_LEVELS, } from '../lib/share/publish.js';
15
15
  import { deleteShare, resolveDeleteTarget } from '../lib/share/delete.js';
16
16
  import { renderWorkerBundle } from '../lib/share/worker-template.js';
17
17
  import { analyticsEnabled } from '../lib/share/analytics.js';
18
- import { phoenixIdBaseForDeploy, resolveShareBackend, } from '../lib/share/backend.js';
18
+ import { extractShareHttpError, formatShareHttpErrorDetail } from '../lib/share/http-error.js';
19
+ import { phoenixIdBaseForDeploy, resolveShareBackend, shouldUseManaged, } from '../lib/share/backend.js';
20
+ import { publishVisibility } from '../lib/storage/visibility.js';
19
21
  import { resolveGitHubUsername } from '../lib/git.js';
20
22
  import { setHelpSections } from '../lib/help.js';
21
23
  import { showUrl } from '../lib/open-url.js';
@@ -245,7 +247,8 @@ export async function runShareList(opts = {}) {
245
247
  throw new Error(OUTDATED_TEMPLATE_HINT);
246
248
  }
247
249
  if (res.status !== 200) {
248
- throw new Error(`Listing failed (${res.status}) for ${listUrl}. Check the endpoint is reachable, or that 'agents artifacts setup' / 'agents auth login' completed.`);
250
+ const detail = formatShareHttpErrorDetail(extractShareHttpError({ status: res.status, body: res.body }));
251
+ throw new Error(`Listing failed (${res.status}) for ${listUrl}${detail}. Check the endpoint is reachable, or that 'agents artifacts setup' / 'agents auth login' completed.`);
249
252
  }
250
253
  if (!/application\/json/i.test(res.contentType)) {
251
254
  // A 200 that isn't JSON means the old Worker ignored ?format=json and served
@@ -389,7 +392,8 @@ export async function runShareRevisions(target, opts = {}) {
389
392
  const fetchListing = opts.fetchListing ?? defaultListingFetch;
390
393
  const res = await fetchListing(revUrl);
391
394
  if (res.status !== 200) {
392
- throw new Error(`Revisions lookup failed (${res.status}) for ${revUrl}. Check the endpoint is reachable, or that 'agents artifacts setup' / 'agents auth login' completed.`);
395
+ const detail = formatShareHttpErrorDetail(extractShareHttpError({ status: res.status, body: res.body }));
396
+ throw new Error(`Revisions lookup failed (${res.status}) for ${revUrl}${detail}. Check the endpoint is reachable, or that 'agents artifacts setup' / 'agents auth login' completed.`);
393
397
  }
394
398
  if (!/application\/json/i.test(res.contentType)) {
395
399
  if (backend.kind === 'managed') {
@@ -624,9 +628,8 @@ export function registerShareCommands(artifactsCmd) {
624
628
  .option('--slug <slug>', 'URL slug override (default: stable slug of the artifact title, then filename)')
625
629
  .option('--github-user <user>', 'GitHub username for the share namespace (default: resolved from gh/git config; ignored on the managed endpoint)')
626
630
  .option('--expire <spec>', "auto-expire (default 30d). e.g. 12h, 30d, 2026-08-01, or 'never'")
627
- .addOption(new Option('--visibility <level>', 'public | unlisted | private | me | org (default public). unlisted is an UNAUTHENTICATED capability URL; private is token-gated (404 without the key); me/org require a Phoenix session. All but public are hidden from the gallery')
628
- .choices([...PUBLISH_VISIBILITY_LEVELS])
629
- .default('public'))
631
+ .addOption(new Option('--visibility <level>', 'public | unlisted | private | me | org. Default when signed in: me (owner-only, Phoenix-gated); use org for your whole email domain, public to opt into the gallery. unlisted is an UNAUTHENTICATED capability URL; private is token-gated (404 without the key). All but public are hidden from the gallery')
632
+ .choices([...PUBLISH_VISIBILITY_LEVELS]))
630
633
  .addOption(new Option('--protected', 'token-gated link (= --visibility private): the URL carries a secret key and returns 404 without it — the authenticated alternative to --unlisted'))
631
634
  .addOption(new Option('--unlisted', 'hidden alias of --visibility unlisted (obscurity, NOT authentication)').hideHelp())
632
635
  .addOption(new Option('--private', 'hidden alias of --visibility unlisted (obscurity, NOT authentication — for real read-auth use --protected)').hideHelp())
@@ -650,11 +653,16 @@ export function registerShareCommands(artifactsCmd) {
650
653
  }
651
654
  try {
652
655
  const meta = parseMetaEntries(opts.meta);
653
- const visibility = resolveShareVisibility({
656
+ // Private by default: an explicit flag wins; otherwise the backend-aware
657
+ // product default — `me` (owner-only) when signed in to Phoenix, `public`
658
+ // for a BYO bucket (the Worker refuses `me`/`org` without a Phoenix owner).
659
+ // `shouldUseManaged` reads the same session/BYO signals `publishFile` will
660
+ // resolve, so the default matches the backend the publish actually uses.
661
+ const visibility = publishVisibility({
654
662
  visibility: opts.visibility,
655
663
  unlisted: opts.unlisted === true || opts.private === true,
656
664
  protected: opts.protected === true,
657
- });
665
+ }, shouldUseManaged({}) ? 'managed' : 'byo');
658
666
  // unlisted (incl. its --private alias) is obscurity, not read-auth — warn
659
667
  // loudly before the publish so a "private" link is never mistaken for a
660
668
  // gated one (PHNX-3654). Skipped for --json so machine output stays clean.
@@ -684,15 +692,25 @@ export function registerShareCommands(artifactsCmd) {
684
692
  });
685
693
  setHelpSections(shareCmd, {
686
694
  examples: `
687
- # Signed in? Just publish — no Cloudflare setup (managed endpoint)
695
+ # Signed in? Just publish — no Cloudflare setup (managed endpoint).
696
+ # Default is private: an owner-only 'me' link, Phoenix-gated.
688
697
  agents auth login
689
698
  agents artifacts share ./out/plan.html
690
699
 
700
+ # Share with everyone at your email domain (no org setup — the domain is it)
701
+ agents artifacts share ./out/plan.html --visibility org
702
+
703
+ # Opt into the public gallery with an OG preview card
704
+ agents artifacts share ./out/landing.html --visibility public
705
+
691
706
  # Hide from the public gallery (direct URL still works, noindex) and expire sooner
692
707
  agents artifacts share ./out/report.html --visibility unlisted --expire 12h
693
708
 
709
+ # Token-gated link — GET returns 404 without the secret ?k= key
710
+ agents artifacts share ./out/secret.html --protected
711
+
694
712
  # Permanent public page (the slug is derived from its title)
695
- agents artifacts share ./out/landing.html --expire never
713
+ agents artifacts share ./out/landing.html --visibility public --expire never
696
714
 
697
715
  # Optional slug override
698
716
  agents artifacts share ./out/report.html --slug q3-report --expire 7d
@@ -720,11 +738,21 @@ ${SHARE_DELETE_EXAMPLES}
720
738
  applies (setup / join). HTML is stored as one object: local images are inlined
721
739
  and Chrome-saved file:// TOC links become in-page hashes.
722
740
 
741
+ Private by default: signed in, a publish with no --visibility is 'me' —
742
+ owner-only, Phoenix-gated, not in the gallery. Pass --visibility org to share
743
+ with everyone at your email domain (no org to create — the domain is the whole
744
+ mechanism), or --visibility public to opt into the gallery with an OG card. A
745
+ BYO publish (no Phoenix session) still defaults to public, since me/org need a
746
+ Phoenix owner to gate on.
747
+
723
748
  Default expiry is 30d so an accidental publish decays. Pass --expire never for
724
749
  a permanent link. --visibility unlisted (hidden aliases: --unlisted / --private)
725
750
  hides the page from the public gallery and agents artifacts share list; the
726
751
  direct URL is still world-readable (unlisted, not secret) and GET sends
727
- X-Robots-Tag: noindex. --visibility me requires a Phoenix session and is only
752
+ X-Robots-Tag: noindex. --protected (= --visibility private) is token-gated
753
+ read-auth: the published URL carries a secret ?k= key (Authorization: Bearer
754
+ is accepted too) and GET returns 404 without it — treat the whole link as a
755
+ secret. --visibility me requires a Phoenix session and is only
728
756
  visible to the signed-in owner; --visibility org requires the same and is
729
757
  visible to members of the same Phoenix organization. A pre-publish scan
730
758
  refuses emails and credential-shaped strings unless --force is passed.
@@ -956,7 +984,7 @@ Prefer to change visibility without a browser? Use 'agents artifacts share visib
956
984
  .addOption(new Option('--scope <level>', 'visibility filter: public (default), unlisted, private, me, org, or all')
957
985
  .choices(['public', 'unlisted', 'private', 'me', 'org', 'all'])
958
986
  .default('public'))
959
- .option('--all', "list every page including hidden unlisted/me/org shares (alias for --scope all)")
987
+ .option('--all', "list every page including hidden unlisted/private/me/org shares (alias for --scope all)")
960
988
  .option('--agent <name>', 'filter to shares published by this agent/harness (case-insensitive)')
961
989
  .option('--session <id>', 'filter to shares published from this session id')
962
990
  // Named --label-contains, not --label: `share <file>` (the parent) already owns
@@ -988,10 +1016,11 @@ Prefer to change visibility without a browser? Use 'agents artifacts share visib
988
1016
  # Everything you've published, newest first
989
1017
  agents artifacts share list
990
1018
 
991
- # Include your hidden pages (unlisted / me / org) so you can see everything
1019
+ # Include your hidden pages (unlisted / private / me / org) so you can see everything
992
1020
  agents artifacts share list --all
993
1021
  agents artifacts share list --scope me
994
1022
  agents artifacts share list --scope unlisted
1023
+ agents artifacts share list --scope private
995
1024
 
996
1025
  # Machine-readable — e.g. pull every still-public URL with jq
997
1026
  agents artifacts share list --list-json | jq -r '.objects[].url'