@phnx-labs/agents-cli 1.22.71 → 1.22.73

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 (51) hide show
  1. package/CHANGELOG.md +45 -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.js +6 -5
  12. package/dist/lib/actor.d.ts +36 -4
  13. package/dist/lib/actor.js +73 -9
  14. package/dist/lib/agent-spec/index.d.ts +4 -0
  15. package/dist/lib/agent-spec/index.js +5 -0
  16. package/dist/lib/agent-spec/materialize.d.ts +13 -0
  17. package/dist/lib/agent-spec/materialize.js +414 -0
  18. package/dist/lib/agent-spec/package-resolve.d.ts +12 -0
  19. package/dist/lib/agent-spec/package-resolve.js +274 -0
  20. package/dist/lib/agent-spec/package-schema.d.ts +5 -0
  21. package/dist/lib/agent-spec/package-schema.js +147 -0
  22. package/dist/lib/agent-spec/package-types.d.ts +121 -0
  23. package/dist/lib/agent-spec/package-types.js +11 -0
  24. package/dist/lib/daemon/usage-sync-service.d.ts +12 -5
  25. package/dist/lib/daemon/usage-sync-service.js +27 -5
  26. package/dist/lib/exec.js +3 -0
  27. package/dist/lib/fleet-shared-state.d.ts +30 -0
  28. package/dist/lib/fleet-shared-state.js +5 -0
  29. package/dist/lib/hooks/install.d.ts +31 -1
  30. package/dist/lib/hooks/install.js +44 -2
  31. package/dist/lib/mcp.d.ts +14 -2
  32. package/dist/lib/mcp.js +12 -2
  33. package/dist/lib/packages/output-home.d.ts +39 -0
  34. package/dist/lib/packages/output-home.js +203 -0
  35. package/dist/lib/paths.d.ts +9 -0
  36. package/dist/lib/paths.js +26 -0
  37. package/dist/lib/project-resources.js +14 -5
  38. package/dist/lib/session/active.d.ts +12 -0
  39. package/dist/lib/session/active.js +5 -1
  40. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  41. package/dist/lib/session/actor-sidecar.js +2 -0
  42. package/dist/lib/session/db.d.ts +60 -1
  43. package/dist/lib/session/db.js +191 -6
  44. package/dist/lib/session/mirror.d.ts +67 -0
  45. package/dist/lib/session/mirror.js +158 -0
  46. package/dist/lib/session/types.d.ts +18 -0
  47. package/dist/lib/spinner.d.ts +39 -0
  48. package/dist/lib/spinner.js +41 -0
  49. package/dist/lib/startup/command-registry.js +1 -1
  50. package/dist/lib/types.d.ts +6 -0
  51. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.73
4
+
5
+ - **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`.
6
+
7
+ - **Remote-host session previews render inline, no per-row SSH (PHNX-3792).** On
8
+ the interactive device, a session that originated on another box used to show
9
+ as a bare `[host/<peer>]` row with no topic, and its preview pane fetched the
10
+ peer's digest live over SSH — slow, and blank when the peer was asleep. Each
11
+ box now mirrors its own sessions' lightweight preview/metadata (topic, first
12
+ user message, last activity, agent+version, cwd, ticket, PR) into its
13
+ conflict-free `devices/<device>/daemon-state.json`, which the daemon's existing
14
+ bounded Git transport already delivers fleet-wide. The picker, `agents sessions`
15
+ list, and `focus` read the local mirror, so a peer session shows its real
16
+ topic/preview inline — even while the peer is offline (last-synced). The live
17
+ SSH digest fetch remains the fallback for a never-synced session, and `space`
18
+ still reads the full transcript live. No transcript leaves the worker; the
19
+ mirror is bounded and pruned by age. Source: `src/lib/session/mirror.ts`,
20
+ `src/lib/fleet-shared-state.ts`, `src/lib/session/db.ts`,
21
+ `src/lib/daemon/usage-sync-service.ts`, `src/commands/sessions-picker.ts`.
22
+
23
+ - **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`.
24
+
25
+ - **`agents sessions --json` now carries the initiator's Phoenix ID (PHNX-3798).**
26
+ The actor resolver already mapped a run's tailnet identity to a `phoenixId`
27
+ (the stable internal work identity) and rode it through the exec env, but it was
28
+ never persisted per session, so a durable listing couldn't attribute a session
29
+ to a person's work identity. The `phoenixId` now flows the same path the
30
+ `actor`/`initiatedBy` fields already take — stamped into the durable actor
31
+ sidecar at launch, joined into a new write-once `phoenix_id` column via a v47
32
+ schema migration, and emitted by the sessions feed. Best-effort throughout: a
33
+ session with no mapped Phoenix ID (the common case) simply omits the field
34
+ rather than emitting a null/empty sentinel. This is the persistence half of the
35
+ ownership work whose resolver landed in PHNX-3798's first PR. Source:
36
+ `src/lib/session/actor-sidecar.ts`, `src/lib/session/db.ts`,
37
+ `src/lib/session/types.ts`, `src/lib/exec.ts`.
38
+
39
+ - **`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`.
40
+ - **`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`.
41
+ - **`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`.
42
+ - **`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`.
43
+
44
+ ## 1.22.72
45
+
46
+ - **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`.
47
+
3
48
  ## 1.22.71
4
49
 
5
50
  - **The daemon now keeps itself current instead of running stale code for days (PHNX-3695).** The long-running daemon (`agents __daemon-run`) deliberately opted out of the interactive CLI's auto-update (`AGENTS_CLI_DISABLE_AUTO_UPDATE=1` forced on for `__daemon-run`), so new agents-cli code never reached a running daemon until a human ran `agents daemon restart` — R5 ("the installed CLI auto-updates") silently did not hold for the one process that runs unattended the longest. A new supervised `self-update` service checks npm roughly every 75 minutes, installs and byte-verifies a newer version with the same primitives `agents upgrade` already uses (never a bare `npm install -g`), best-effort pulls the `.system` companion repo and reconciles with `agents sync --local`, then exits so the OS supervisor (launchd `KeepAlive` / systemd `Restart=always`) relaunches it onto the new code — clients reconnect on their own, and the scheduler's atomic `(routine, scheduledFor)` claim dedupes any routine mid-fire across the restart. It fails closed on every step: an install or verify failure leaves the running daemon untouched and retries next tick, and it no-ops on a dev build or a shadowed install. A version-skewed browser-IPC client no longer just prints "run agents daemon restart" — `reconcileDaemonVersion` now asks the daemon to run this same fail-closed path on demand (`request-self-update`), sharing one in-flight attempt with the periodic tick so concurrent requests can't race two installs. That on-demand trigger is DECOUPLED from the install: because every version-skewed `agents browser <verb>` routes through it, the daemon kicks the self-update off in the background and answers "triggered" immediately rather than making the browser verb wait out the whole check→download→install→verify (tens of seconds, worst case ~15 min) — the daemon installs, verifies, and exits on its own, and the browser reconnects. Source: `cli/src/lib/daemon/self-update-service.ts`, `cli/src/lib/browser/ipc.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);
@@ -18,6 +18,7 @@ import { sanitizeForTerminal, redactSecrets } from '../lib/redact.js';
18
18
  import { resolveProjectKey } from '../lib/project-key.js';
19
19
  import { listProjectDefs, resolveProjectNameForCwd } from '../lib/projects.js';
20
20
  import ora from 'ora';
21
+ import { interruptibleSpinner } from '../lib/spinner.js';
21
22
  import { SESSION_AGENTS, isAgentTmuxAlias, sessionDisplayAgent } from '../lib/session/types.js';
22
23
  import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
23
24
  import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, composeWin32CommandLine } from '../lib/platform/index.js';
@@ -2801,7 +2802,7 @@ limitSource) {
2801
2802
  const forwarded = ensureWholeIndex(buildForwardedArgs(process.argv, new Set(options.host ?? [])));
2802
2803
  if (!forwarded.includes('--json'))
2803
2804
  forwarded.push('--json');
2804
- const fanSpinner = isInteractiveTerminal() ? ora('Reaching other machines...').start() : null;
2805
+ const fanSpinner = isInteractiveTerminal() ? interruptibleSpinner('Reaching other machines...').start() : null;
2805
2806
  try {
2806
2807
  const { sessions: remoteSessions } = await gatherRemoteList(forwarded, undefined);
2807
2808
  if (remoteSessions.length > 0) {
@@ -2841,7 +2842,7 @@ limitSource) {
2841
2842
  const forwarded = buildForwardedArgs(process.argv, new Set(options.host ?? []));
2842
2843
  if (!forwarded.includes('--json'))
2843
2844
  forwarded.push('--json');
2844
- const fanSpinner = isInteractiveTerminal() ? ora('Reaching other machines...').start() : null;
2845
+ const fanSpinner = isInteractiveTerminal() ? interruptibleSpinner('Reaching other machines...').start() : null;
2845
2846
  try {
2846
2847
  const { sessions: remoteSessions } = await gatherRemoteList(forwarded, options.host);
2847
2848
  if (remoteSessions.length > 0) {
@@ -4105,7 +4106,7 @@ async function runCloudSessions(query, options) {
4105
4106
  process.exit(1);
4106
4107
  }
4107
4108
  const mode = resolveViewMode(options, filterOpts);
4108
- const spinner = options.json ? null : ora('Loading cloud sessions...').start();
4109
+ const spinner = options.json ? null : interruptibleSpinner('Loading cloud sessions...').start();
4109
4110
  let sessions;
4110
4111
  try {
4111
4112
  sessions = await discoverCloudSessions({ limit: parseInt(options.limit || '50', 10) });
@@ -4141,7 +4142,7 @@ async function runCloudSessions(query, options) {
4141
4142
  process.exit(1);
4142
4143
  }
4143
4144
  const meta = matches[0];
4144
- const cachedSpinner = options.json ? null : ora('Fetching session...').start();
4145
+ const cachedSpinner = options.json ? null : interruptibleSpinner('Fetching session...').start();
4145
4146
  let cachedPath;
4146
4147
  try {
4147
4148
  cachedPath = await ensureCloudSessionCached(meta.id);
@@ -4972,7 +4973,7 @@ export async function resolveSessionMetadata(selector, scope, deps = { gatherRem
4972
4973
  process.stdout.write(serializeResolvedSessionsJson([outcome.session]));
4973
4974
  }
4974
4975
  export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
4975
- const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
4976
+ const spinner = isInteractiveTerminal() ? interruptibleSpinner('Searching the fleet...').start() : null;
4976
4977
  let candidates = [];
4977
4978
  let deviceCount = 0;
4978
4979
  let unreachable = [];
@@ -13,6 +13,13 @@ export interface ResolvedActor {
13
13
  email?: string;
14
14
  /** GitHub handle, when the actors map records one. */
15
15
  github?: string;
16
+ /**
17
+ * Phoenix (work) identity id for this human, when the actors map records one.
18
+ * Bridges a personal tailnet login (e.g. a personal gmail) to the stable
19
+ * internal work identity, so attribution survives whichever email a person
20
+ * happens to be signed into tailscale with.
21
+ */
22
+ phoenixId?: string;
16
23
  }
17
24
  /** Result of `tailscale whois --json <ip>` we care about. */
18
25
  export interface WhoisIdentity {
@@ -28,16 +35,41 @@ export interface WhoisIdentity {
28
35
  */
29
36
  export declare function actorFromIdentity(who: WhoisIdentity | undefined, host: string, actors: Record<string, ActorConfig>): ResolvedActor;
30
37
  /**
31
- * Compute the actor for a given environment. Pure with respect to `env` (the
32
- * only impurity is the `tailscale whois` / config read on the fresh-SSH path),
33
- * so tests can drive every branch by passing an env explicitly.
38
+ * Injectable tailscale resolvers, so tests can drive the SSH-whois and
39
+ * local-self branches deterministically without a real tailscale on the box
40
+ * (a dev machine that *is* on the tailnet would otherwise make the local path
41
+ * non-deterministic). Production callers use the defaults.
34
42
  */
35
- export declare function computeActor(env?: NodeJS.ProcessEnv): ResolvedActor;
43
+ export interface ActorResolvers {
44
+ whois: (ip: string) => WhoisIdentity | undefined;
45
+ self: () => WhoisIdentity | undefined;
46
+ }
47
+ /**
48
+ * Compute the actor for a given environment. The only impurity is the tailscale
49
+ * shell-out (injectable via `resolvers`), so tests drive every branch explicitly.
50
+ *
51
+ * Resolution order: an inherited env actor wins; otherwise an SSH run whois-es
52
+ * its client IP; a local run (no SSH) credits the device's own tailnet owner;
53
+ * and anything unresolvable degrades to `UNRESOLVED@<host>`. Note the self
54
+ * fallback fires ONLY for a truly local run — an SSH run whose whois fails must
55
+ * NOT be credited to the box owner (that would misattribute a remote human to
56
+ * whoever owns the machine).
57
+ */
58
+ export declare function computeActor(env?: NodeJS.ProcessEnv, resolvers?: ActorResolvers): ResolvedActor;
36
59
  /**
37
60
  * Resolve the actor for the current process, cached for the process lifetime
38
61
  * (the SSH `whois` shell-out runs at most once).
39
62
  */
40
63
  export declare function resolveActor(): ResolvedActor;
64
+ /**
65
+ * Test-only: pin the tailscale resolvers `resolveActor()` uses, so a test that
66
+ * exercises the cached production entrypoint (e.g. `withActorEnv()`) is isolated
67
+ * from whether the box running it is on the tailnet. `computeActor` already takes
68
+ * injected resolvers for its unit tests; this extends the same seam to the cached
69
+ * path. Pass `undefined` to restore the real tailscale resolvers. Resets the
70
+ * cache so the next `resolveActor()` recomputes under the new resolvers.
71
+ */
72
+ export declare function setActorResolvers(resolvers: ActorResolvers | undefined): void;
41
73
  /** Clear the per-process cache. For tests, and for env changes within a run. */
42
74
  export declare function resetActorCache(): void;
43
75
  /**