@phnx-labs/agents-cli 1.22.72 → 1.22.74

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 (61) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +2 -0
  3. package/dist/cli/command-registry.d.ts +1 -1
  4. package/dist/cli/command-registry.js +2 -1
  5. package/dist/commands/packages-materialize.d.ts +17 -0
  6. package/dist/commands/packages-materialize.js +93 -0
  7. package/dist/commands/packages.d.ts +6 -4
  8. package/dist/commands/packages.js +8 -4
  9. package/dist/commands/repo.js +8 -8
  10. package/dist/commands/sessions-picker.js +38 -7
  11. package/dist/commands/sessions.d.ts +50 -1
  12. package/dist/commands/sessions.js +85 -10
  13. package/dist/lib/actor.d.ts +36 -4
  14. package/dist/lib/actor.js +73 -9
  15. package/dist/lib/agent-spec/index.d.ts +4 -0
  16. package/dist/lib/agent-spec/index.js +5 -0
  17. package/dist/lib/agent-spec/materialize.d.ts +13 -0
  18. package/dist/lib/agent-spec/materialize.js +414 -0
  19. package/dist/lib/agent-spec/package-resolve.d.ts +12 -0
  20. package/dist/lib/agent-spec/package-resolve.js +274 -0
  21. package/dist/lib/agent-spec/package-schema.d.ts +5 -0
  22. package/dist/lib/agent-spec/package-schema.js +147 -0
  23. package/dist/lib/agent-spec/package-types.d.ts +121 -0
  24. package/dist/lib/agent-spec/package-types.js +11 -0
  25. package/dist/lib/cloud/rush.d.ts +1 -1
  26. package/dist/lib/cloud/rush.js +2 -2
  27. package/dist/lib/daemon/usage-sync-service.d.ts +12 -5
  28. package/dist/lib/daemon/usage-sync-service.js +27 -5
  29. package/dist/lib/exec.js +3 -0
  30. package/dist/lib/fleet-shared-state.d.ts +30 -0
  31. package/dist/lib/fleet-shared-state.js +5 -0
  32. package/dist/lib/hooks/install.d.ts +31 -1
  33. package/dist/lib/hooks/install.js +44 -2
  34. package/dist/lib/mcp.d.ts +14 -2
  35. package/dist/lib/mcp.js +12 -2
  36. package/dist/lib/packages/output-home.d.ts +39 -0
  37. package/dist/lib/packages/output-home.js +203 -0
  38. package/dist/lib/paths.d.ts +9 -0
  39. package/dist/lib/paths.js +26 -0
  40. package/dist/lib/project-resources.d.ts +6 -2
  41. package/dist/lib/project-resources.js +133 -44
  42. package/dist/lib/rush-session.d.ts +10 -2
  43. package/dist/lib/rush-session.js +12 -3
  44. package/dist/lib/secrets/drivers/rush.js +1 -1
  45. package/dist/lib/session/active.d.ts +12 -0
  46. package/dist/lib/session/active.js +5 -1
  47. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  48. package/dist/lib/session/actor-sidecar.js +2 -0
  49. package/dist/lib/session/cloud.js +1 -1
  50. package/dist/lib/session/db.d.ts +60 -1
  51. package/dist/lib/session/db.js +191 -6
  52. package/dist/lib/session/live-metadata.d.ts +24 -0
  53. package/dist/lib/session/live-metadata.js +55 -0
  54. package/dist/lib/session/mirror.d.ts +67 -0
  55. package/dist/lib/session/mirror.js +158 -0
  56. package/dist/lib/session/types.d.ts +18 -0
  57. package/dist/lib/spinner.d.ts +39 -0
  58. package/dist/lib/spinner.js +41 -0
  59. package/dist/lib/startup/command-registry.js +1 -1
  60. package/dist/lib/types.d.ts +6 -0
  61. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,78 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.74
4
+
5
+ - **Launching an agent no longer dirties your tracked `.gitignore` (PHNX-3718).**
6
+ The per-harness resource sync self-manages ignore rules for the generated
7
+ `.claude/`, `.cursor/`, `.codex/`, … dirs it writes on every launch. Those
8
+ rules used to be written into the tracked `<project>/.gitignore`, but they are
9
+ never committed upstream — so every launch left the repo with a permanent
10
+ `M .gitignore` (one managed block per harness that had run there) that in turn
11
+ blocked `git pull` with *"local changes to .gitignore would be overwritten by
12
+ merge"*. The managed block now lives in `.git/info/exclude` (git's per-clone,
13
+ uncommitted ignore file, resolved via `git rev-parse --git-path info/exclude`
14
+ so it works from a subdir, linked worktree, or submodule), keeping the
15
+ generated dirs hidden while leaving the working tree clean. On the next launch,
16
+ a repo already dirtied by the old behavior self-heals: the leftover managed
17
+ block is stripped from the tracked `.gitignore` (hand-written rules untouched;
18
+ an untracked file that held only our block is removed). Source:
19
+ `cli/src/lib/project-resources.ts`.
20
+
21
+ - **`sessions preview` of a remote session no longer dead-ends on the dispatcher
22
+ (PHNX-3890).** A box that launched a session running on another device kept a
23
+ transcript-less live row for it whose machine defaulted to itself, so
24
+ `agents sessions preview <full-uuid>` — and the interactive browser's preview
25
+ pane — rendered `Live session — full transcript not indexed here.` instead of
26
+ fetching the owning peer's digest. A passive peer rendered the same session
27
+ fine. The launcher shim is now reconciled against the fleet's own view of which
28
+ device runs the session, and a full-UUID hit only skips the fleet fan-out when
29
+ this box can actually answer for it (a transcript on disk, or a row that already
30
+ names another device). Remote previews fill in asynchronously over SSH; a
31
+ locally readable transcript or synced mirror still renders with no hop, and an
32
+ unreachable owner still fails loud. Source: `cli/src/commands/sessions.ts`,
33
+ `cli/src/lib/session/live-metadata.ts`.
34
+
35
+ ## 1.22.73
36
+
37
+ - **Ctrl-C works again while `agents sessions` is reaching other machines (PHNX-3791).** The static session list, `Searching the fleet…`, and the cloud session/fetch paths wrap their long SSH/network waits in `ora(...)`. ora defaults to `discardStdin: true`, which on a TTY flips stdin into raw mode for the spinner's lifetime (via `stdin-discarder`) — and raw mode disables the terminal's own ISIG, so a Ctrl-C keystroke no longer raises SIGINT. ora's compensating "re-emit SIGINT when I read a 0x03 byte" only fires when stdin is in *flowing* mode, but a fresh `process.stdin` has `flowing === null`, `isPaused()` returns false, the discarder skips its `resume()`, and `prependListener('data')` doesn't auto-resume — so the byte is never read and SIGINT is never raised. The global handler in `index.ts` (`process.exit(130)`) was correct; it simply never fired. The user-visible result: a sweep that stalls on offline/unreachable boxes (each burning a ~12s SSH `ConnectTimeout`) could not be aborted — measured, `agents sessions --flat` had to be SIGKILLed. New `lib/spinner.ts#interruptibleSpinner` is a drop-in for `ora(text)` built with `discardStdin: false`: the terminal stays in cooked mode, Ctrl-C raises SIGINT normally (and the terminal SIGINTs the whole foreground group, reaping the outstanding `ssh` children), and the process exits promptly — only cosmetic stray-keystroke discarding during the spin is lost. The five cross-machine/network spinners in `sessions.ts` route through it; short local spinners are unchanged. Source: `cli/src/lib/spinner.ts`, `cli/src/lib/spinner.test.ts`, `cli/src/commands/sessions.ts`.
38
+
39
+ - **Remote-host session previews render inline, no per-row SSH (PHNX-3792).** On
40
+ the interactive device, a session that originated on another box used to show
41
+ as a bare `[host/<peer>]` row with no topic, and its preview pane fetched the
42
+ peer's digest live over SSH — slow, and blank when the peer was asleep. Each
43
+ box now mirrors its own sessions' lightweight preview/metadata (topic, first
44
+ user message, last activity, agent+version, cwd, ticket, PR) into its
45
+ conflict-free `devices/<device>/daemon-state.json`, which the daemon's existing
46
+ bounded Git transport already delivers fleet-wide. The picker, `agents sessions`
47
+ list, and `focus` read the local mirror, so a peer session shows its real
48
+ topic/preview inline — even while the peer is offline (last-synced). The live
49
+ SSH digest fetch remains the fallback for a never-synced session, and `space`
50
+ still reads the full transcript live. No transcript leaves the worker; the
51
+ mirror is bounded and pruned by age. Source: `src/lib/session/mirror.ts`,
52
+ `src/lib/fleet-shared-state.ts`, `src/lib/session/db.ts`,
53
+ `src/lib/daemon/usage-sync-service.ts`, `src/commands/sessions-picker.ts`.
54
+
55
+ - **Ctrl-C works during `agents repo` git operations too (PHNX-3793).** Follow-up to PHNX-3791. `cli/src/commands/repo.ts` wrapped its seven git network ops — clone, git-backing, pull, push, adopt, sync — in bare `ora(...)` spinners, whose default `discardStdin: true` raw-modes the TTY and swallows Ctrl-C for the whole operation (same trap PHNX-3791 fixed for the session sweep). A `git clone`/`pull` stalled on a slow or dead remote could hang longer than a fleet sweep with no way to abort. All seven now use `lib/spinner.ts#interruptibleSpinner` (`discardStdin: false`), so Ctrl-C raises SIGINT and the process exits promptly. Source: `cli/src/commands/repo.ts`.
56
+
57
+ - **`agents sessions --json` now carries the initiator's Phoenix ID (PHNX-3798).**
58
+ The actor resolver already mapped a run's tailnet identity to a `phoenixId`
59
+ (the stable internal work identity) and rode it through the exec env, but it was
60
+ never persisted per session, so a durable listing couldn't attribute a session
61
+ to a person's work identity. The `phoenixId` now flows the same path the
62
+ `actor`/`initiatedBy` fields already take — stamped into the durable actor
63
+ sidecar at launch, joined into a new write-once `phoenix_id` column via a v47
64
+ schema migration, and emitted by the sessions feed. Best-effort throughout: a
65
+ session with no mapped Phoenix ID (the common case) simply omits the field
66
+ rather than emitting a null/empty sentinel. This is the persistence half of the
67
+ ownership work whose resolver landed in PHNX-3798's first PR. Source:
68
+ `src/lib/session/actor-sidecar.ts`, `src/lib/session/db.ts`,
69
+ `src/lib/session/types.ts`, `src/lib/exec.ts`.
70
+
71
+ - **`agents packages materialize` materializes a schema-v3 `agent.yaml` package into an ephemeral Claude, Codex, or OpenCode home (PHNX-3838).** The noun-first surface takes `--harness`, `--harness-version` (root `--version` is the CLI version flag), and `--output-home`. It is a thin front door over the canonical native-home materializer (`agent-spec/materialize.ts`): it resolves the package once and projects every declared resource (instructions, skills, subagents, mcp, hooks) into the output home, then prints the materialization receipt (agent ref + digest, harness, per-resource targets, warnings); `--json` is the Factory/Prix Cloud handoff and is byte-identical to the `materialization-receipt.json` written into the home. It writes only under `--output-home`: it never mutates the live user home, never copies secrets, and never execs a harness. A non-portable harness, a non-exact `--harness-version`, a missing/invalid package, and an output-path escape (including the live `~/.claude`/`~/.codex`/`~/.opencode` homes) all fail loud. Source: `cli/src/commands/packages-materialize.ts`, `cli/src/lib/packages/output-home.ts`, `cli/src/lib/agent-spec/materialize.ts`.
72
+ - **`agents packages materialize` is hardened against four path-traversal / symlink escapes (PHNX-3838).** (1) `materializeAgentPackage` now realpath-verifies every per-resource write/delete target against the canonical output home before touching disk, and `--output-home` additionally refuses the live home ROOT and canonicalizes symlinked ancestors. So neither `--output-home "$HOME"` (the materializer appends `.claude`/`.codex`/`.opencode`), a symlink aliasing `$HOME`, nor a symlink planted at the harness-config-dir join point inside the output home can write into the live harness home — and a direct (Factory / Prix Cloud) caller that skips the CLI front door is protected too. (2) The stale-prune step strictly validates the unsigned `materialization-receipt.json` schema and realpath-checks every `resources[].target` as a contained path at deletion time, so a planted receipt can no longer make it `rm -rf` a `../victim`, an absolute path, or a path under a symlinked ancestor outside the output home. (3) A package `hook.name` must be a safe single path segment, re-checked for containment before the script is copied and `chmod +x`'d. (4) Package resources reject symlinked file/directory/hook sources and realpath-check every source under the package's real root, so `instructions.md -> /etc/passwd` or a skill dir symlinked outside the package can no longer be read or copied. Source: `cli/src/lib/packages/output-home.ts`, `cli/src/lib/agent-spec/materialize.ts`, `cli/src/lib/agent-spec/package-resolve.ts`, `cli/src/lib/paths.ts`.
73
+ - **`agents packages materialize` closes two more output-home boundary gaps (PHNX-3838).** (1) When any protected `~/.claude`/`~/.codex`/`~/.opencode` home is a DANGLING symlink or dangling chain (following relative links and chains), `resolveOutputHome` now fails closed and refuses EVERY output home until the link is repaired — because the materializer's first `mkdir -p` would otherwise follow the link and re-create the operator's live harness home at the materialized tree, and the absent target it points at has no canonical spelling to compare a specific candidate against. (2) Materializing a package that carries hooks no longer runs the normal orphan-shim sweep against the ONE process-global shim dir (`~/.agents/.cache/shims/hooks/`): that sweep deletes every shim absent from the manifest it is handed, so with a package's hooks-only manifest it would wipe the operator's unrelated hook shims. `registerHooksToSettings` gained an isolated `skipGlobalShimSweep` option that the materializer sets; normal install/sync keeps the sweep. Source: `cli/src/lib/packages/output-home.ts`, `cli/src/lib/hooks/install.ts`, `cli/src/lib/agent-spec/materialize.ts`.
74
+ - **`agents packages materialize` fails closed on a dangling protected home instead of approximating filesystem collation (PHNX-3838).** An earlier iteration compared the live-home identity case- and Unicode-normalization-insensitively (NFC + `toLowerCase`) on macOS/Windows to catch a spelling-equivalent alias of a DANGLING `~/.claude` (e.g. an absent `.../Real-Claude` vs `--output-home .../real-claude`). An independent macOS review proved that approximation wrong: APFS treats e.g. U+017F ſ and ASCII `s` as identical while JS `toLowerCase` does not, and an OS-derived case-sensitivity flag is wrong for per-volume semantics. The guard no longer tries to reproduce a volume's collation from the path text — when a protected home dangles it refuses every output home (fail closed, above), and an EXISTING protected home is compared with exact `realpath` identity, which needs no approximation. The `pathIdentityKey`/`pathIsWithin`/`FS_CASE_INSENSITIVE` helpers added for the approximation are removed. Source: `cli/src/lib/packages/output-home.ts`, `cli/src/lib/paths.ts`.
75
+
3
76
  ## 1.22.72
4
77
 
5
78
  - **The self-managed `.gitignore` block now ignores the `.agents-managed.json` manifest too, so a synced harness dir is actually clean in `git status` (PHNX-3717).** The project-resource sync's gitignore reconciliation (shipped in 1.22.71) listed the synced commands/skills but not the manifest marker file the sync also writes into each harness dir — so `.factory/` (etc.) still showed as untracked on the strength of that one unignored file, defeating the point. `reconcileProjectGitignore` now feeds `.agents-managed.json` through the same anchoring + escape guard, and the block persists (shrunk to just the manifest) while that file exists rather than fully pruning. Covered by a new test that asserts a real `git status --porcelain` no longer reports the generated dir. Source: `cli/src/lib/project-resources.ts`, `cli/src/lib/project-resources.test.ts`.
package/README.md CHANGED
@@ -154,6 +154,8 @@ agents mcp list
154
154
 
155
155
  Skills, slash commands, rules, hooks, and permissions work the same way -- install once in `~/.agents/`, synced to every agent's native format automatically.
156
156
 
157
+ A schema-v3 `agent.yaml` package materializes into an ephemeral Claude, Codex, or OpenCode home for Factory / Prix Cloud workers (`agents packages materialize ./reviewer --harness codex --harness-version 0.42.0 --output-home "$OUTPUT_HOME" --json`) -- it never writes the live user home.
158
+
157
159
  ```bash
158
160
  agents skills add gh:yourteam/python-expert # Knowledge pack -> all agents
159
161
  agents commands add gh:yourteam/commands # Slash commands -> all agents
@@ -105,7 +105,7 @@ export declare const LAZY_COMMAND_NAMES: ReadonlySet<string>;
105
105
  *
106
106
  * Most names map to a single loader. The exceptions encode real coupling on main:
107
107
  * - `add`/`use`/`list`/`remove`/`rm`/`purge` all come from the versions module.
108
- * - `registry`/`search`/`install` all come from the packages module.
108
+ * - `registry`/`search`/`install`/`packages` all come from the packages module.
109
109
  * - `trash` and `restore` are separate registrars in the trash module.
110
110
  * - `prune` needs versions FIRST (it creates `prune <specs...>`) then prune.js
111
111
  * (which finds that command and attaches the `cleanup` subcommand).
@@ -115,7 +115,7 @@ export const LAZY_COMMAND_NAMES = new Set([
115
115
  *
116
116
  * Most names map to a single loader. The exceptions encode real coupling on main:
117
117
  * - `add`/`use`/`list`/`remove`/`rm`/`purge` all come from the versions module.
118
- * - `registry`/`search`/`install` all come from the packages module.
118
+ * - `registry`/`search`/`install`/`packages` all come from the packages module.
119
119
  * - `trash` and `restore` are separate registrars in the trash module.
120
120
  * - `prune` needs versions FIRST (it creates `prune <specs...>`) then prune.js
121
121
  * (which finds that command and attaches the `cleanup` subcommand).
@@ -152,6 +152,7 @@ export const COMMAND_LOADERS = {
152
152
  registry: [loadPackages],
153
153
  search: [loadPackages],
154
154
  install: [loadPackages],
155
+ packages: [loadPackages],
155
156
  routines: [loadRoutines],
156
157
  monitors: [loadMonitors],
157
158
  projects: [loadProjects],
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `agents packages materialize` — user-facing front door for portable-agent
3
+ * materialization (PHNX-3838). ONE execution path: this command resolves the
4
+ * schema-v3 package once with {@link resolveAgentPackage} and projects it into
5
+ * an ephemeral native home with the canonical {@link materializeAgentPackage}
6
+ * (agent-spec/materialize.ts). The front door owns only what a materializer must
7
+ * not: the portable-harness allowlist, an exact harness version, and the
8
+ * output-home refusal that keeps a run off the live `~/.claude` / `~/.codex` /
9
+ * `~/.opencode` homes.
10
+ */
11
+ import type { Command } from 'commander';
12
+ import { type MaterializationReceipt } from '../lib/agent-spec/index.js';
13
+ import { PORTABLE_HARNESSES } from '../lib/packages/output-home.js';
14
+ export { PORTABLE_HARNESSES };
15
+ export type { MaterializationReceipt };
16
+ /** Register `agents packages materialize`. */
17
+ export declare function registerPortablePackageCommands(program: Command): void;
@@ -0,0 +1,93 @@
1
+ import chalk from 'chalk';
2
+ import { die, isJsonMode } from '../lib/format.js';
3
+ import { setHelpSections } from '../lib/help.js';
4
+ import { resolveAgentPackage, materializeAgentPackage, AgentPackageError, } from '../lib/agent-spec/index.js';
5
+ import { MaterializeGuardError, PORTABLE_HARNESSES, assertExactHarnessVersion, assertPortableHarness, resolveOutputHome, } from '../lib/packages/output-home.js';
6
+ export { PORTABLE_HARNESSES };
7
+ function printReceipt(receipt, outputHome) {
8
+ console.log(chalk.bold(`Materialized ${receipt.agent.ref} → ${receipt.harness.id}@${receipt.harness.version}`));
9
+ console.log(` digest ${receipt.agent.digest}`);
10
+ console.log(` output home ${outputHome}`);
11
+ for (const entry of receipt.resources) {
12
+ console.log(` ${entry.kind.padEnd(12)} ${entry.name} → ${entry.target}`);
13
+ }
14
+ if (receipt.warnings.length > 0) {
15
+ console.log(chalk.yellow(' warnings'));
16
+ for (const warning of receipt.warnings)
17
+ console.log(chalk.yellow(` ${warning}`));
18
+ }
19
+ }
20
+ function fail(err, json) {
21
+ if (err instanceof MaterializeGuardError || err instanceof AgentPackageError) {
22
+ die(err.message, 1, { json });
23
+ }
24
+ die(err instanceof Error ? err.message : String(err), 1, { json });
25
+ }
26
+ /** Register `agents packages materialize`. */
27
+ export function registerPortablePackageCommands(program) {
28
+ const packagesCmd = program
29
+ .command('packages')
30
+ .description('Portable agent packages — materialize schema-v3 agent.yaml into an ephemeral harness home');
31
+ setHelpSections(packagesCmd, {
32
+ examples: `
33
+ # Materialize a package into an ephemeral Claude home
34
+ agents packages materialize ./reviewer --harness claude --harness-version 2.1.0 --output-home /tmp/reviewer-claude
35
+
36
+ # Machine-readable receipt for Factory / Prix Cloud
37
+ agents packages materialize ./reviewer --harness codex --harness-version 0.42.0 --output-home "$OUTPUT_HOME" --json
38
+
39
+ # OpenCode worker on a Factory box
40
+ agents packages materialize ./reviewer --harness opencode --harness-version 1.0.0 --output-home "$OUTPUT_HOME" --json
41
+ `,
42
+ notes: `
43
+ Materialize writes only under --output-home. It never mutates ~/.claude,
44
+ ~/.codex, or ~/.opencode, never copies secrets, and never execs a harness.
45
+
46
+ Factory usage: point --output-home at the worker's ephemeral home and pass
47
+ --json so the orchestrator can read the receipt (agent ref + digest,
48
+ harness, per-resource targets, warnings).
49
+ `,
50
+ });
51
+ const materializeCmd = packagesCmd
52
+ .command('materialize <package>')
53
+ .description('Materialize a schema-v3 agent.yaml package into an ephemeral Claude, Codex, or OpenCode home')
54
+ .requiredOption('--harness <id>', `Target harness: ${PORTABLE_HARNESSES.join(', ')}`)
55
+ .requiredOption('--harness-version <version>', 'Exact harness version to gate capabilities and stamp on the receipt (not --version: that is the CLI version flag)')
56
+ .requiredOption('--output-home <dir>', 'Ephemeral home to write into (must not escape or target the live user home)')
57
+ .option('--json', 'Emit the materialization receipt as JSON')
58
+ .action((pkg, opts) => {
59
+ const json = isJsonMode(opts);
60
+ try {
61
+ const harness = assertPortableHarness(opts.harness);
62
+ const harnessVersion = assertExactHarnessVersion(opts.harnessVersion);
63
+ const outputHome = resolveOutputHome(opts.outputHome);
64
+ const resolved = resolveAgentPackage(pkg);
65
+ const receipt = materializeAgentPackage(resolved, { harness, harnessVersion, outputHome });
66
+ if (json) {
67
+ // Verbatim canonical receipt — byte-identical to the
68
+ // materialization-receipt.json the materializer wrote into the home.
69
+ console.log(JSON.stringify(receipt, null, 2));
70
+ return;
71
+ }
72
+ printReceipt(receipt, outputHome);
73
+ }
74
+ catch (err) {
75
+ fail(err, json);
76
+ }
77
+ });
78
+ setHelpSections(materializeCmd, {
79
+ examples: `
80
+ agents packages materialize ./reviewer --harness claude --harness-version 2.1.0 --output-home /tmp/reviewer-claude
81
+ agents packages materialize ./reviewer --harness codex --harness-version 0.42.0 --output-home "$OUTPUT_HOME" --json
82
+ agents packages materialize ./reviewer --harness opencode --harness-version 1.0.0 --output-home "$OUTPUT_HOME" --json
83
+ `,
84
+ notes: `
85
+ Factory / Prix Cloud: materialize into the worker's ephemeral home, then
86
+ exec the harness with HOME=$OUTPUT_HOME. The --json receipt is the handoff.
87
+
88
+ Supported harnesses: claude, codex, opencode. Any other id fails as an
89
+ unsupported capability. A package must be a directory containing agent.yaml
90
+ with schema_version: 3 and an execution block declaring the harness.
91
+ `,
92
+ });
93
+ }
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Package registry and installation commands.
3
3
  *
4
- * Registers `agents registry`, `agents search`, and `agents install`
5
- * for discovering and installing MCP servers, skills, commands, and
6
- * hooks from configured registries or GitHub sources.
4
+ * Registers `agents registry`, `agents search`, `agents install`, and
5
+ * `agents packages materialize` for discovering and installing MCP servers,
6
+ * skills, commands, and hooks from configured registries or GitHub sources,
7
+ * and for materializing a schema-v3 agent.yaml package into an ephemeral
8
+ * harness home (PHNX-3838).
7
9
  */
8
10
  import type { Command } from 'commander';
9
11
  import type { McpPackage } from '../lib/types.js';
10
12
  import { type McpCommandSpec } from '../lib/mcp.js';
11
13
  export declare function buildMcpPackageCommand(pkg: McpPackage): McpCommandSpec;
12
- /** Register the `agents registry`, `agents search`, and `agents install` commands. */
14
+ /** Register the `agents registry`, `agents search`, `agents install`, and `agents packages` commands. */
13
15
  export declare function registerPackagesCommands(program: Command): void;
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Package registry and installation commands.
3
3
  *
4
- * Registers `agents registry`, `agents search`, and `agents install`
5
- * for discovering and installing MCP servers, skills, commands, and
6
- * hooks from configured registries or GitHub sources.
4
+ * Registers `agents registry`, `agents search`, `agents install`, and
5
+ * `agents packages materialize` for discovering and installing MCP servers,
6
+ * skills, commands, and hooks from configured registries or GitHub sources,
7
+ * and for materializing a schema-v3 agent.yaml package into an ephemeral
8
+ * harness home (PHNX-3838).
7
9
  */
8
10
  import * as fs from 'fs';
9
11
  import chalk from 'chalk';
@@ -23,6 +25,7 @@ import { listInstalledVersions, resolveConfiguredAgentTargets, syncResourcesToVe
23
25
  import { formatPath, isInteractiveTerminal, isPromptCancelled, parseCommaSeparatedList, requireDestructiveArg, requireInteractiveSelection, resolveInstalledAgentTargetsAutoInstalling, } from './utils.js';
24
26
  import { itemPicker } from '../lib/picker.js';
25
27
  import { registerMcpCommandToTargets, discoverMcpConfigsFromRepo, installMcpConfigCentrally, } from '../lib/mcp.js';
28
+ import { registerPortablePackageCommands } from './packages-materialize.js';
26
29
  export function buildMcpPackageCommand(pkg) {
27
30
  const packageName = pkg.name || pkg.registry_name;
28
31
  if (pkg.runtime === 'node') {
@@ -80,8 +83,9 @@ async function pickRegistryName(type, verb, pred) {
80
83
  throw err;
81
84
  }
82
85
  }
83
- /** Register the `agents registry`, `agents search`, and `agents install` commands. */
86
+ /** Register the `agents registry`, `agents search`, `agents install`, and `agents packages` commands. */
84
87
  export function registerPackagesCommands(program) {
88
+ registerPortablePackageCommands(program);
85
89
  // ==========================================================================
86
90
  // REGISTRY COMMANDS
87
91
  // ==========================================================================
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import { visibleWidth, padVisible } from '../lib/format.js';
3
3
  import { stripAnsi } from '../lib/session/width.js';
4
- import ora from 'ora';
4
+ import { interruptibleSpinner } from '../lib/spinner.js';
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import * as os from 'os';
@@ -785,7 +785,7 @@ export function registerRepoCommands(program) {
785
785
  return;
786
786
  }
787
787
  const parsed = parseSource(options.from);
788
- const spinner = ora(`Cloning ${options.from} into ${targetDir}...`).start();
788
+ const spinner = interruptibleSpinner(`Cloning ${options.from} into ${targetDir}...`).start();
789
789
  try {
790
790
  fs.mkdirSync(path.dirname(targetDir), { recursive: true });
791
791
  await simpleGit().clone(parsed.url, targetDir);
@@ -885,7 +885,7 @@ export function registerRepoCommands(program) {
885
885
  process.exitCode = 1;
886
886
  return;
887
887
  }
888
- const spinner = ora(`Cloning ${source}...`).start();
888
+ const spinner = interruptibleSpinner(`Cloning ${source}...`).start();
889
889
  try {
890
890
  fs.mkdirSync(path.dirname(targetDir), { recursive: true });
891
891
  await simpleGit().clone(parsed.url, targetDir);
@@ -1023,7 +1023,7 @@ export function registerRepoCommands(program) {
1023
1023
  // user. Only the USER repo gets this — system is cloned by setup, extras
1024
1024
  // by `repo add`.
1025
1025
  if (t.alias === 'user' && url) {
1026
- const spinner = ora(`Git-backing ${t.dir} from ${url}...`).start();
1026
+ const spinner = interruptibleSpinner(`Git-backing ${t.dir} from ${url}...`).start();
1027
1027
  const res = await adoptRepo(url, t.dir);
1028
1028
  if (!res.success) {
1029
1029
  spinner.fail(`user: could not git-back — ${res.error}`);
@@ -1049,7 +1049,7 @@ export function registerRepoCommands(program) {
1049
1049
  // Skip system repo unless explicitly requested
1050
1050
  continue;
1051
1051
  }
1052
- const spinner = ora(`Pulling ${formatRepoTarget(t.alias, t.dir)}...`).start();
1052
+ const spinner = interruptibleSpinner(`Pulling ${formatRepoTarget(t.alias, t.dir)}...`).start();
1053
1053
  const result = await pullRepo(t.dir);
1054
1054
  if (result.success) {
1055
1055
  spinner.succeed(`${formatRepoTarget(t.alias, t.dir, result.branch)}: ${result.commit}`);
@@ -1127,7 +1127,7 @@ export function registerRepoCommands(program) {
1127
1127
  // the previous tick's state and delays propagation by one repo cycle.
1128
1128
  if (t.alias === 'user')
1129
1129
  await publishUserRepoAccountState(true);
1130
- const spinner = ora(`Pushing ${formatRepoTarget(t.alias, t.dir)}...`).start();
1130
+ const spinner = interruptibleSpinner(`Pushing ${formatRepoTarget(t.alias, t.dir)}...`).start();
1131
1131
  const result = await commitAndPush(t.dir, options.message);
1132
1132
  if (result.success) {
1133
1133
  spinner.succeed(`${formatRepoTarget(t.alias, t.dir, result.branch)}: ${result.detail ?? 'pushed'}`);
@@ -1171,7 +1171,7 @@ Examples:
1171
1171
  // state, then fall through to the normal sync (PHNX-3301). System is
1172
1172
  // cloned by setup and extras by `repo add`, so those still skip.
1173
1173
  if (t.alias === 'user' && fs.existsSync(t.dir)) {
1174
- const spinner = ora(`Adopting ${formatRepoTarget(t.alias, t.dir)} in place...`).start();
1174
+ const spinner = interruptibleSpinner(`Adopting ${formatRepoTarget(t.alias, t.dir)} in place...`).start();
1175
1175
  const adopted = await adoptUserRepoIfNeeded(t.dir);
1176
1176
  if (!adopted || !adopted.success) {
1177
1177
  spinner.fail(`${formatRepoTarget(t.alias, t.dir)}: ${adopted?.error ?? 'adopt failed'}`);
@@ -1199,7 +1199,7 @@ Examples:
1199
1199
  // so the same transaction carries it; consume peers after the pull.
1200
1200
  if (t.alias === 'user')
1201
1201
  await publishUserRepoAccountState(false);
1202
- const spinner = ora(`Syncing ${formatRepoTarget(t.alias, t.dir)}...`).start();
1202
+ const spinner = interruptibleSpinner(`Syncing ${formatRepoTarget(t.alias, t.dir)}...`).start();
1203
1203
  const result = t.alias === 'user'
1204
1204
  ? await (async () => {
1205
1205
  const { syncFleetSharedStateRepo } = await import('../lib/fleet-shared-repo-sync.js');
@@ -306,6 +306,7 @@ function previewCacheKey(session, remote) {
306
306
  session.topic, session.ticketId, session.prUrl, session.messageCount,
307
307
  session.tokenCount, session.model, session.todos, session.plan,
308
308
  session.recentDirectoriesTouched, session.skillsUsed,
309
+ session.firstUserMessage, session.mirrorSyncedAt,
309
310
  ]);
310
311
  }
311
312
  export function clearPreviewMemoryCacheForTest() {
@@ -363,12 +364,38 @@ export function buildPreview(session) {
363
364
  return cached;
364
365
  const safe = sanitizeMeta(session);
365
366
  // Remote session: the transcript is on the peer's disk, so there is nothing to
366
- // parse here. Fetch the peer's already-computed digest over SSH (kicked off on
367
- // first render; the pane repaints when it lands) and render the same compact
368
- // preview a local row gets. Until it arrives — or when the peer can't answer —
369
- // show the metadata header (agent, cwd, msgs, tokens — all carried over in the
370
- // fan-out) plus where it lives and how to open it.
367
+ // parse here. The common case is now a fleet-synced MIRROR row (PHNX-3792): its
368
+ // topic + first-user snippet + metadata are already local, so the compact card
369
+ // renders INLINE with no per-row SSH. The live SSH digest fetch stays as the
370
+ // fallback for a never-synced row (or a peer whose digest was already fetched
371
+ // this session), and `space` still reads the full transcript live over SSH.
371
372
  if (remote) {
373
+ // A fleet-synced MIRROR row is the explicit signal that this box already
374
+ // holds the peer session's topic + first-user snippet locally (PHNX-3792):
375
+ // render inline with NO per-row SSH. An ordinary remote row (a live fan-out
376
+ // row, or a never-synced host-dispatch stub) is NOT treated as synced — it
377
+ // keeps the live digest fetch below, so this change is scoped to mirror rows.
378
+ const fetched = remoteDigestCache.get(remoteDigestKey(session.id, remote));
379
+ if (fetched?.state === 'ready') {
380
+ // A richer live digest already landed this session (changed files, tool
381
+ // mix) — prefer it over the lightweight mirror card.
382
+ const note = ' ' + chalk.gray(`on `) + chalk.bold.white(remote)
383
+ + chalk.gray(` — enter to resume there`);
384
+ const body = formatCompactPreview(fetched.digest, safe);
385
+ const output = [formatHeader(safe, []), '', note, body].filter(Boolean).join('\n');
386
+ previewCache.set(cacheKey, output);
387
+ return output;
388
+ }
389
+ if (safe.mirrorSyncedAt !== undefined) {
390
+ const note = ' ' + chalk.gray(`on `) + chalk.bold.white(remote)
391
+ + chalk.gray(` — synced from the fleet; enter to resume there, or space to read it live over SSH`);
392
+ const metaBody = formatMetaOnlyBody(safe);
393
+ const output = [formatHeader(safe, []), '', note, metaBody].filter(Boolean).join('\n');
394
+ previewCache.set(cacheKey, output);
395
+ return output;
396
+ }
397
+ // Not a mirror row: fall back to the live SSH digest fetch (kicked off on
398
+ // first render; the pane repaints when it lands).
372
399
  const note = ' ' + chalk.gray(`on `) + chalk.bold.white(remote)
373
400
  + chalk.gray(` — enter to resume there, or space then enter to read it over SSH`);
374
401
  const entry = remoteDigestForPreview(session, remote);
@@ -554,8 +581,12 @@ function formatMetaOnlyBody(session) {
554
581
  const termWidth = process.stdout.columns || 80;
555
582
  const valueWidth = termWidth - VERB_GUTTER - 5;
556
583
  // Same verb-led rows as the digest body (RUSH-2757), from SessionMeta alone.
557
- if (session.topic) {
558
- lines.push(verbLabel('Asked') + chalk.white(`"${truncate(session.topic.trim(), valueWidth)}"`));
584
+ // Prefer the fuller first genuine user turn over the one-line topic when the
585
+ // row carries it (a fleet-synced mirror row does — PHNX-3792) so the inline
586
+ // card reads like the originating prompt, not just its title.
587
+ const asked = session.firstUserMessage?.trim() || session.topic?.trim();
588
+ if (asked) {
589
+ lines.push(verbLabel('Asked') + chalk.white(`"${truncate(asked, valueWidth)}"`));
559
590
  }
560
591
  const compact = formatTodoCompact(session.todos);
561
592
  const teamLine = formatTeamLineage(session);
@@ -896,9 +896,58 @@ export declare function metadataResolveOutcome(localMatches: SessionMeta[], remo
896
896
  sessions: SessionMeta[];
897
897
  unreachable: string[];
898
898
  }, selector: string): MetadataResolveOutcome;
899
- /** Injectable dependency for the live-registry cold-miss fallback (RUSH-2682). */
899
+ /**
900
+ * Whether a local candidate answers a full-UUID selector **definitively** — i.e.
901
+ * whether finding it on this box is the whole answer, so the fleet fan-out can be
902
+ * skipped (PHNX-3890).
903
+ *
904
+ * Two shapes qualify, and both are claims this box can actually back:
905
+ *
906
+ * - **A real transcript on this disk** (`filePath`). It renders locally, whoever
907
+ * owns it — a local session or a fleet-synced mirror — so no SSH hop is needed.
908
+ * - **A genuine peer attribution** (`machine` names another box). The row already
909
+ * routes the read to its owner through `transcriptOnPeerOf`, so the peer that
910
+ * would answer the fan-out is the peer the render already dials.
911
+ *
912
+ * What does NOT qualify is the launcher-shim shape: a transcript-less live row
913
+ * whose `machine` is this box only because it DEFAULTED there
914
+ * (`activeSessionToSessionMeta`'s `active.machine ?? self`,
915
+ * `computeLocalMetadataMatches`'s `machine || localMachine`). A dispatcher holds
916
+ * exactly that row for a session whose agent and transcript live on a peer: the
917
+ * launch process is here, the conversation is not. Treating it as definitive is
918
+ * what dead-ended `agents sessions preview <full-uuid>` on the local
919
+ * "Live session — full transcript not indexed here." stub while a passive peer —
920
+ * which has no local row at all, so it fans out — rendered the real digest.
921
+ * Process locality is not transcript locality.
922
+ */
923
+ export declare function isLocallyDefinitiveMatch(session: SessionMeta, self: string): boolean;
924
+ /**
925
+ * Drop the launcher-shim local rows that a peer has since answered for
926
+ * (PHNX-3890). `fleetCandidatesByQuery` groups a logical session's copies per
927
+ * machine and every consumer reads `hits[0]`, with local rows passed first — so
928
+ * simply fanning out is not enough: the self-defaulted shim would still win the
929
+ * attribution and route the read back to a box with no transcript. When the peer
930
+ * that actually owns the session has answered the sweep, its row is the strictly
931
+ * better one, and the shim carries no information the candidate loses (same
932
+ * logical id, so the candidate — and any ambiguity it is part of — survives
933
+ * through the remote hit).
934
+ *
935
+ * A row that is {@link isLocallyDefinitiveMatch} is never dropped, so a locally
936
+ * readable transcript or a synced mirror still renders here with no SSH hop. When
937
+ * no peer answered for that id, the shim is kept: with no fleet evidence, a
938
+ * transcript-less self-attributed row is indistinguishable from a session THIS box
939
+ * just started, and dropping it would re-break the cold-index lookup RUSH-2682
940
+ * fixed.
941
+ */
942
+ export declare function preferOwnerAttribution(localMatches: SessionMeta[], remoteSessions: SessionMeta[], self: string): SessionMeta[];
943
+ /** Injectable dependencies for the live-registry cold-miss fallback (RUSH-2682)
944
+ * and the fleet-attribution reconciliation (PHNX-3890). */
900
945
  export type LiveMetadataDeps = {
901
946
  loadActive?: typeof loadLocalActiveSessions;
947
+ /** The fleet-active snapshot rows used to attribute a self-defaulted launcher
948
+ * shim to its true execution host. Defaults to the cached fleet snapshot; the
949
+ * cache is warmed by any fleet-wide `agents sessions --active`. */
950
+ loadFleetActive?: () => ActiveSession[];
902
951
  };
903
952
  /**
904
953
  * Local metadata candidates for a selector: the indexed SQLite rows, plus — when