@timurproko/a1 0.1.1-dev.8 → 0.1.4

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 (77) hide show
  1. package/README.md +60 -62
  2. package/bin/cli.js +14 -2
  3. package/bin/module-identity.js +132 -0
  4. package/bin/ui.js +10 -0
  5. package/dist/native/darwin-arm64/manifest.json +1 -1
  6. package/dist/native/linux-x64/manifest.json +1 -1
  7. package/dist/native/win32-x64/manifest.json +2 -2
  8. package/dist/native/win32-x64/process-guardian.exe +0 -0
  9. package/dist/src/cli/capabilities.d.ts +15 -0
  10. package/dist/src/cli/capabilities.js +7 -0
  11. package/dist/src/cli/dispatch.d.ts +9 -3
  12. package/dist/src/cli/dispatch.js +104 -16
  13. package/dist/src/cli/index.d.ts +2 -0
  14. package/dist/src/cli/index.js +2 -0
  15. package/dist/src/cli/packages.d.ts +23 -0
  16. package/dist/src/cli/packages.js +84 -0
  17. package/dist/src/foundation/agent-engine-contracts/index.d.ts +1 -0
  18. package/dist/src/foundation/agent-engine-contracts/index.js +1 -0
  19. package/dist/src/foundation/agent-engine-contracts/package-ports.d.ts +54 -0
  20. package/dist/src/foundation/agent-engine-contracts/package-ports.js +31 -0
  21. package/dist/src/foundation/pi-component-adapter/shell-editor-autocomplete.js +1 -1
  22. package/dist/src/foundation/pi-component-adapter/shell-extension-ui.js +1 -1
  23. package/dist/src/foundation/pi-component-adapter/shell-footer-status.js +1 -1
  24. package/dist/src/foundation/pi-component-adapter/shell-presenters-transcript.d.ts +15 -0
  25. package/dist/src/foundation/pi-component-adapter/shell-presenters-transcript.js +34 -1
  26. package/dist/src/foundation/pi-component-adapter/shell-selectors-dialogs.js +1 -1
  27. package/dist/src/foundation/pi-component-adapter/shell-shared-facade.d.ts +1 -1
  28. package/dist/src/foundation/pi-component-adapter/shell-shared-facade.js +1 -1
  29. package/dist/src/foundation/pi-component-adapter/upstream/adjacent/core/keybindings.d.ts +2 -2
  30. package/dist/src/foundation/pi-component-adapter/upstream/adjacent/core/keybindings.js +1 -1
  31. package/dist/src/foundation/pi-component-adapter/upstream/components/countdown-timer.d.ts +1 -1
  32. package/dist/src/foundation/pi-component-adapter/upstream/components/custom-entry.d.ts +1 -1
  33. package/dist/src/foundation/pi-component-adapter/upstream/components/custom-entry.js +1 -1
  34. package/dist/src/foundation/pi-component-adapter/upstream/components/daxnuts.d.ts +1 -1
  35. package/dist/src/foundation/pi-component-adapter/upstream/components/earendil-announcement.d.ts +1 -1
  36. package/dist/src/foundation/pi-component-adapter/upstream/components/earendil-announcement.js +1 -1
  37. package/dist/src/foundation/pi-component-adapter/upstream/components/extension-editor.d.ts +1 -1
  38. package/dist/src/foundation/pi-component-adapter/upstream/components/extension-editor.js +1 -1
  39. package/dist/src/foundation/pi-component-adapter/upstream/components/first-time-setup.d.ts +1 -1
  40. package/dist/src/foundation/pi-component-adapter/upstream/components/first-time-setup.js +1 -1
  41. package/dist/src/foundation/pi-component-adapter/upstream/components/mermaid.js +1 -1
  42. package/dist/src/foundation/pi-component-adapter/upstream/components/owned-editor.d.ts +1 -1
  43. package/dist/src/foundation/pi-component-adapter/upstream/components/owned-editor.js +1 -1
  44. package/dist/src/foundation/pi-component-adapter/upstream/components/scoped-models-selector.d.ts +1 -1
  45. package/dist/src/foundation/pi-component-adapter/upstream/components/scoped-models-selector.js +1 -1
  46. package/dist/src/foundation/pi-component-adapter/upstream/components/session-footer.d.ts +1 -1
  47. package/dist/src/foundation/pi-component-adapter/upstream/components/session-footer.js +1 -1
  48. package/dist/src/foundation/pi-component-adapter/upstream/components/session-selector-search.js +1 -1
  49. package/dist/src/foundation/pi-component-adapter/upstream/components/session-selector.d.ts +1 -1
  50. package/dist/src/foundation/pi-component-adapter/upstream/components/session-selector.js +1 -1
  51. package/dist/src/foundation/pi-component-adapter/upstream/components/skill-invocation-message.d.ts +1 -1
  52. package/dist/src/foundation/pi-component-adapter/upstream/components/skill-invocation-message.js +1 -1
  53. package/dist/src/foundation/pi-component-adapter/upstream/components/status-indicator.d.ts +1 -1
  54. package/dist/src/foundation/pi-component-adapter/upstream/components/status-indicator.js +1 -1
  55. package/dist/src/foundation/pi-component-adapter/upstream/components/tree-selector.d.ts +1 -1
  56. package/dist/src/foundation/pi-component-adapter/upstream/components/tree-selector.js +1 -1
  57. package/dist/src/foundation/pi-component-adapter/upstream/components/trust-selector.d.ts +1 -1
  58. package/dist/src/foundation/pi-component-adapter/upstream/components/trust-selector.js +1 -1
  59. package/dist/src/foundation/pi-component-adapter/upstream/theme/theme.d.ts +1 -1
  60. package/dist/src/foundation/pi-component-adapter/upstream/theme/theme.js +1 -1
  61. package/dist/src/foundation/pi-engine-adapter/adapter.d.ts +8 -1
  62. package/dist/src/foundation/pi-engine-adapter/adapter.js +28 -1
  63. package/dist/src/foundation/pi-engine-adapter/index.d.ts +1 -0
  64. package/dist/src/foundation/pi-engine-adapter/index.js +1 -0
  65. package/dist/src/foundation/pi-engine-adapter/package-integration.d.ts +13 -0
  66. package/dist/src/foundation/pi-engine-adapter/package-integration.js +112 -0
  67. package/dist/src/foundation/pi-engine-adapter/runtime-integration.d.ts +18 -1
  68. package/dist/src/foundation/pi-engine-adapter/runtime-integration.js +37 -2
  69. package/dist/src/foundation/pi-owned-ui-integration/session-shell.js +14 -2
  70. package/dist/src/foundation/pi-tui-runtime-adapter/adapter.js +1 -1
  71. package/dist/src/foundation/release/release.js +26 -2
  72. package/dist/src/foundation/release/update.d.ts +11 -1
  73. package/dist/src/foundation/release/update.js +106 -5
  74. package/docs/architecture/toolchain.md +3 -3
  75. package/docs/ci-release-runbook.md +81 -33
  76. package/docs/features/launch-profiles.md +2 -0
  77. package/package.json +10 -3
package/README.md CHANGED
@@ -1,106 +1,104 @@
1
1
  # A1
2
2
 
3
- A1 is a terminal-native agent launcher for Windows, Linux, and macOS. Bare `a1` runs the A1-owned Pi-compatible UI, while explicit fallback profiles can launch untouched Pi directly. A1 also manages release selection, process ownership, and updates.
4
-
5
3
  ## Install
6
4
 
7
- Requirements:
8
-
9
- - Node.js 22.19 through 24.x
10
- - npm 11
11
-
12
- Published packages include the integrity-verified process guardian for supported platforms. Building A1 from source additionally requires Rust/Cargo 1.85 or newer.
13
-
14
5
  ```sh
15
6
  npm install --global @timurproko/a1@latest
16
7
  ```
17
8
 
18
- The package installs only the `a1` command.
19
-
20
9
  ## Commands
21
10
 
22
11
  ```sh
23
12
  a1 # A1-owned UI and profile: ~/.a1/agent
24
- a1 pi # untouched vanilla Pi oracle: ~/.pi/agent
25
- a1 sandbox # unchanged isolated vanilla Pi profile: ~/.a1/sandbox
26
13
  a1 version # show Installed, Release (latest), and Next versions
27
14
  a1 update # update to npm latest
28
15
  a1 update:next # update to npm next
29
16
  ```
30
17
 
31
- Bare `a1` owns its Pi-compatible TUI composition and uses the public Pi engine, components, and terminal APIs. It does not insert a PTY or terminal-byte relay. Default visuals and workflows track the pinned vanilla Pi baseline; A1-specific visual customization and structured tabs remain disabled.
32
-
33
- `a1 pi` is the untouched upstream fallback and comparison oracle. Use it to distinguish A1-owned UI problems from upstream Pi, profile, provider, or terminal problems. The `a1 ui` subcommand was removed and is not a compatibility alias; run bare `a1` for the owned UI.
18
+ Prerelease builds what `a1 update:next` installs add two development profiles
19
+ for comparing against pinned Pi and for experimenting against an isolated profile.
20
+ A release build does not carry them.
34
21
 
35
- `a1 sandbox` is unchanged: it launches vanilla Pi with isolated Pi configuration and resources. “Sandbox” does not mean operating-system, filesystem, process, network, or credential security isolation. See [`docs/features/launch-profiles.md`](docs/features/launch-profiles.md) for first-run directories, independent `/login`, trust behavior, extension placement, and recovery.
22
+ ```sh
23
+ a1 pi # untouched vanilla Pi oracle: ~/.pi/agent
24
+ a1 sandbox # unchanged isolated vanilla Pi profile: ~/.a1/sandbox
25
+ ```
36
26
 
37
- Every interactive command creates an independent, non-detachable launch instance. Any number of `a1`, `a1 pi`, and `a1 sandbox` commands may run in separate terminals at the same time. Closing one command closes only its owned UI/Pi process and all agents, extensions, tools, daemons, and descendants created by that invocation; it never requires finding PIDs, deleting control state, or restarting the supervisor. The idle supervisor may remain available for release coordination after all interactive instances close, but it owns no surviving instance runtime.
27
+ ## Extensions
38
28
 
39
- ## Develop
29
+ Pi extension packages install into A1's own profile, so bare `a1` loads them and
30
+ `a1 pi` and `a1 sandbox` do not. Sources are Pi's: `npm:`, git, or a local path.
40
31
 
41
32
  ```sh
42
- npm ci
43
- npm run build
44
- npm start
33
+ a1 install npm:pi-mcp-adapter # install a package into ~/.a1/agent
34
+ a1 remove npm:pi-mcp-adapter # remove it again (alias: a1 uninstall)
35
+ a1 list # list packages installed for a1
36
+ a1 update --extensions # update every installed package
37
+ a1 update npm:pi-mcp-adapter # update one of them
38
+ a1 update --models # refresh model catalogs
45
39
  ```
46
40
 
47
- `npm start` gives each invocation isolated A1 development state and an isolated development Pi profile. Use `npm start -- --print-environment` to inspect the selected paths without launching the UI.
48
-
49
- A1 control state uses `%APPDATA%\\A1` and `%LOCALAPPDATA%\\A1` on Windows, and the `a1` directory under XDG config/data/runtime roots on Unix. Override it only with declared `A1_*` variables such as `A1_CONFIG_DIR`, `A1_DATA_DIR`, `A1_RUNTIME_DIR`, `A1_DATABASE_PATH`, and `A1_ENDPOINT`. Pi profile roots remain `~/.a1/agent`, `~/.pi/agent`, and `~/.a1/sandbox`. This is a no-migration identity hard cut; see [`docs/architecture/toolchain.md`](docs/architecture/toolchain.md#identity-hard-cut-and-cleanup) before removing obsolete control state.
41
+ A running session loads a newly installed package after a restart. Pi's own
42
+ profile at `~/.pi/agent` is managed by Pi itself.
50
43
 
51
- ### Worktree lifecycle
52
-
53
- The primary worktree stays on `develop` and is integration-only. Each task uses a detached worktree under the repository's ignored `.worktrees/` directory, based on current `origin/develop` unless another base is selected.
44
+ ## Develop
54
45
 
55
46
  ```sh
56
- git fetch origin --prune
57
- git worktree add --detach .worktrees/<task-id> origin/develop
58
- cd .worktrees/<task-id>
47
+ npm ci # install exact locked dependencies
48
+ npm run build # compile TypeScript and the process guardian into dist
49
+ npm start # build and launch an isolated development `a1`
50
+ npm run start:pi # build and launch an isolated development `a1 pi`
51
+ npm run start:sandbox # build and launch an isolated development `a1 sandbox`
52
+ npm run test:fast # typecheck + fast suite, no build needed
53
+ npm test # same as test:fast
54
+ npm run test:full # complete non-physical suite
59
55
  ```
60
56
 
61
- Commit and validate coherent work there. Because `develop` is protected, push the detached commit to one temporary remote branch and merge one pull request for the requested change. After merge, update the primary worktree and remove the task worktree only when its commits are reachable from `develop`:
57
+ ## Release
62
58
 
63
- ```sh
64
- git push origin HEAD:refs/heads/<task-id>
65
- gh pr create --base develop --head <task-id>
66
- # After the pull request is merged:
67
- cd <repository-root>
68
- git fetch origin --prune
69
- git merge --ff-only origin/develop
70
- git worktree remove .worktrees/<task-id>
71
- git worktree prune
72
- ```
59
+ Two channels, both published by CI from the exact bytes it validated. Nothing is
60
+ ever published from a workstation.
73
61
 
74
- Manual acceptance does not leave a permanent validation checkout. Once acceptance is recorded, archive the completed OpenSpec change, integrate the archive commit into `develop`, then remove and prune every task or acceptance worktree retained for that change. Never remove a worktree before all of its implementation, acceptance, and archive commits are reachable from `develop`.
62
+ ### Previews npm `next`
75
63
 
76
- Local package archives and ad hoc test builds belong under `.builds/`, never in the repository root. For manual package tests, use:
64
+ Automatic. Every push to `develop` publishes one, versioned
65
+ `<major.minor.patch>-dev.<run number>` from whatever `package.json` declares. The
66
+ preview version is stamped at publish time and never committed, so previews cost no
67
+ commits and need no command.
77
68
 
78
69
  ```sh
79
- mkdir -p .builds
80
- npm pack --ignore-scripts --pack-destination .builds
70
+ a1 update:next # install the newest preview
81
71
  ```
82
72
 
83
- Run the non-desktop gates with:
73
+ ### Stable npm `latest`
74
+
75
+ One command, from a clean `develop` that matches its remote:
84
76
 
85
77
  ```sh
86
- npm run typecheck
87
- npm run check:architecture
88
- npm run check:deprecated
89
- npm test
90
- npm run test:release
78
+ npm run release -- patch # 0.1.1 -> 0.1.2
79
+ npm run release -- minor # 0.1.1 -> 0.2.0
80
+ npm run release -- major # 0.1.1 -> 1.0.0
81
+ npm run release -- 0.4.0 # an exact version
91
82
  ```
92
83
 
93
- Automated physical-terminal interaction must never run on an active workstation. Future physical certification may run only on dedicated disposable workers or VMs with exclusive test desktops.
94
-
95
- ## Releases
84
+ It lands the version on `develop` through a pull request that merges itself.
85
+ **Landing it is what publishes** — the workflow builds, validates the packed release
86
+ on Windows, Linux, and macOS, publishes to npm `latest` with provenance, and only
87
+ then writes the `v<version>` tag and records the GitHub Release. The command waits
88
+ for that to succeed, then lands the next `-dev.0` version so previews resume
89
+ immediately.
96
90
 
97
- An exact manually accepted `-dev.N` candidate may publish under npm tag `next` after applicable non-desktop gates pass. Such a preview is explicitly uncertified, cannot move `latest`, and cannot claim stable terminal parity or platform support. Stable claims require deferred physical and cross-platform certification against the exact package.
91
+ ```sh
92
+ a1 update # install the newest stable release
93
+ ```
98
94
 
99
- Trusted preview publication uses `.github/workflows/publish-next.yml` and npm provenance. Local release mechanics are documented in [`docs/architecture/toolchain.md`](docs/architecture/toolchain.md).
95
+ After a stable publish, `master` fast-forwards to the released commit, so `master`
96
+ always points at what npm `latest` serves while `develop` carries the work.
100
97
 
101
- ## Architecture
98
+ A release that fails leaves nothing behind: no tag, no GitHub Release, no moved
99
+ branch. Every version you can see somewhere is a version the registry actually
100
+ serves. Release tags are protected from deletion and movement; a wrong tag is
101
+ superseded by the next version, never repointed.
102
102
 
103
- - [`docs/architecture/boundaries.md`](docs/architecture/boundaries.md)
104
- - [`docs/architecture/toolchain.md`](docs/architecture/toolchain.md)
105
- - [`docs/manual-transparent-checkpoint.md`](docs/manual-transparent-checkpoint.md)
106
- - [`docs/features/launch-profiles.md`](docs/features/launch-profiles.md)
103
+ `docs/ci-release-runbook.md` has the full picture, including what happens when a
104
+ publish fails partway.
package/bin/cli.js CHANGED
@@ -2,7 +2,12 @@
2
2
 
3
3
  const packageRoot = new URL("..", import.meta.url);
4
4
  const { fileURLToPath } = await import("node:url");
5
- const { dispatchCli } = await import("../dist/src/cli/index.js");
5
+ const { readFile } = await import("node:fs/promises");
6
+ const { cliCapabilities, dispatchCli } = await import("../dist/src/cli/index.js");
7
+
8
+ // Which commands this build exposes follows from the build's own version, so a
9
+ // released a1 cannot be argued into offering the development profiles.
10
+ const capabilities = cliCapabilities(JSON.parse(await readFile(new URL("package.json", packageRoot), "utf8")).version);
6
11
 
7
12
  process.exitCode = await dispatchCli(process.argv.slice(2), {
8
13
  launch: async intent => {
@@ -21,6 +26,13 @@ process.exitCode = await dispatchCli(process.argv.slice(2), {
21
26
  const { runSelfUpdate } = await import("../dist/src/foundation/release/index.js");
22
27
  return await runSelfUpdate({ packageRoot: fileURLToPath(packageRoot), channel });
23
28
  },
29
+ packages: async request => {
30
+ const [{ runPackageCommand }, { createPiPackagesPort }] = await Promise.all([
31
+ import("../dist/src/cli/index.js"),
32
+ import("../dist/src/foundation/pi-engine-adapter/index.js"),
33
+ ]);
34
+ return await runPackageCommand(request, { createPort: createPiPackagesPort });
35
+ },
24
36
  }, {
25
37
  stderr: message => process.stderr.write(message),
26
- });
38
+ }, capabilities);
@@ -0,0 +1,132 @@
1
+ /**
2
+ * One pi-tui module identity per process.
3
+ *
4
+ * npm materializes @earendil-works/pi-tui twice under A1's package root: once
5
+ * as A1's direct dependency at the node_modules root, and once nested inside
6
+ * @earendil-works/pi-coding-agent, whose published npm-shrinkwrap.json makes
7
+ * npm build its dependency tree exactly as shrinkwrapped instead of sharing a
8
+ * hoisted copy. Both are the same version; npm just cannot tell them apart.
9
+ *
10
+ * Two copies means two of every TUI class. Pinned Pi hands extensions the
11
+ * nested copy — its extension loader aliases the specifier to whatever it
12
+ * resolves from its own directory — so an extension's `instanceof` check and
13
+ * its prototype patches land on classes A1's renderer never uses. Extension
14
+ * chrome silently disappears and routed input dead-ends, with no error.
15
+ *
16
+ * A1 therefore does not import the specifier at all. Its package declares the
17
+ * subpath import `#pi-tui`, resolving to pinned Pi's copy first and to the
18
+ * hoisted one only when that is absent — which is exactly the case where there
19
+ * is one copy and both sides agree anyway. Every A1 module imports `#pi-tui`,
20
+ * so which copy A1 uses is stated in package.json and enforced by Node at
21
+ * resolution, rather than arranged by rewriting an installed tree.
22
+ *
23
+ * What remains here is the check that it worked. The alias names one path
24
+ * inside pinned Pi; if a future layout moved that copy, resolution would fall
25
+ * back to A1's own and the split would return — silently, which is what made
26
+ * this expensive the first time. So launch compares the two resolutions and
27
+ * says so when they differ, loudly and once, instead of leaving a user to
28
+ * discover it as missing extension UI.
29
+ *
30
+ * This lives in bin/ (shipped, plain JS) because it inspects dependency
31
+ * resolution, which the Pi API boundary policy rightly forbids ordinary
32
+ * production code from touching.
33
+ */
34
+ import { createRequire } from "node:module";
35
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
36
+ import { dirname, join } from "node:path";
37
+ import { pathToFileURL } from "node:url";
38
+
39
+ /**
40
+ * What A1's own modules resolve `#pi-tui` to, as a real path.
41
+ *
42
+ * The alias is read from the manifest and its entries tried in order, which is
43
+ * what Node does for a subpath import: a relative target is a file within the
44
+ * package, and a bare one goes through ordinary resolution. Asking Node
45
+ * directly is not an option here — `import.meta.resolve` ignores the parent it
46
+ * is given unless an experimental flag is set, so it would always answer for
47
+ * the running process rather than for the installation being inspected.
48
+ */
49
+ function resolveOwnPiTui(packageRoot) {
50
+ const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
51
+ const alias = manifest.imports?.["#pi-tui"];
52
+ const targets = Array.isArray(alias) ? alias : alias === undefined ? [] : [alias];
53
+ if (targets.length === 0) throw new Error("package.json declares no #pi-tui alias");
54
+ for (const target of targets) {
55
+ if (typeof target !== "string") continue;
56
+ if (target.startsWith("./") || target.startsWith("../")) {
57
+ const candidate = join(packageRoot, target);
58
+ if (existsSync(candidate)) return canonical(candidate);
59
+ continue;
60
+ }
61
+ try {
62
+ return canonical(createRequire(pathToFileURL(join(packageRoot, "package.json")).href).resolve(target));
63
+ } catch {
64
+ continue;
65
+ }
66
+ }
67
+ throw new Error(`no #pi-tui target resolves: ${targets.join(", ")}`);
68
+ }
69
+
70
+ /**
71
+ * What pinned Pi resolves the same specifier to, as a real path — asked from
72
+ * inside Pi's own directory, which is where Pi asks it.
73
+ *
74
+ * Pi's directory is located by walking node_modules outward, the way Node
75
+ * itself would, rather than by resolving Pi's entry: its `exports` map offers
76
+ * no CommonJS condition, so an ordinary require cannot name it. From there a
77
+ * require resolves pi-tui, which publishes no `exports` map at all.
78
+ */
79
+ function resolvePinnedPiTui(packageRoot) {
80
+ let directory = packageRoot;
81
+ while (true) {
82
+ const candidate = join(directory, "node_modules", "@earendil-works", "pi-coding-agent");
83
+ if (existsSync(join(candidate, "package.json"))) {
84
+ return canonical(createRequire(pathToFileURL(join(candidate, "package.json")).href).resolve("@earendil-works/pi-tui"));
85
+ }
86
+ const parent = dirname(directory);
87
+ if (parent === directory) throw new Error("pinned Pi is not installed beneath this package root");
88
+ directory = parent;
89
+ }
90
+ }
91
+
92
+ function canonical(path) {
93
+ try {
94
+ return realpathSync.native(path);
95
+ } catch {
96
+ return path;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Report whether A1 and pinned Pi resolve pi-tui to the same file.
102
+ * Returns a discriminated outcome; never throws.
103
+ */
104
+ export function inspectPiTuiModuleIdentity(packageRoot) {
105
+ let own;
106
+ let pinned;
107
+ try {
108
+ own = resolveOwnPiTui(packageRoot);
109
+ } catch (error) {
110
+ return { kind: "unresolved", side: "a1", message: message(error) };
111
+ }
112
+ try {
113
+ pinned = resolvePinnedPiTui(packageRoot);
114
+ } catch (error) {
115
+ return { kind: "unresolved", side: "pi", message: message(error) };
116
+ }
117
+ return own === pinned ? { kind: "unified", path: own } : { kind: "split", own, pinned };
118
+ }
119
+
120
+ function message(error) {
121
+ return error instanceof Error ? error.message : String(error);
122
+ }
123
+
124
+ /** Launch-entry wrapper: warn on stderr when the two sides disagree. */
125
+ export function assertSinglePiTuiModuleAtLaunch(packageRoot, warn) {
126
+ const outcome = inspectPiTuiModuleIdentity(packageRoot);
127
+ if (outcome.kind === "split") {
128
+ warn(`a1: pi-tui resolves to two different copies (${outcome.own} for a1, ${outcome.pinned} for Pi); extension UI may not render. The #pi-tui alias in a1's package.json no longer names Pi's copy.\n`);
129
+ } else if (outcome.kind === "unresolved") {
130
+ warn(`a1: could not resolve pi-tui from ${outcome.side === "a1" ? "a1" : "pinned Pi"} (${outcome.message}); extension UI may not render.\n`);
131
+ }
132
+ }
package/bin/ui.js CHANGED
@@ -1,5 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ const { assertSinglePiTuiModuleAtLaunch } = await import("./module-identity.js");
4
+ const { fileURLToPath } = await import("node:url");
5
+
6
+ // Before the composition loads pinned Pi's terminal stack: confirm A1 and Pi
7
+ // resolve it to the same copy, so extensions and the owned UI share one module
8
+ // identity (see bin/module-identity.js for the full story). Nothing is repaired
9
+ // here — A1's package manifest decides which copy wins, and this only reports
10
+ // when that stopped being true.
11
+ assertSinglePiTuiModuleAtLaunch(fileURLToPath(new URL("..", import.meta.url)), message => process.stderr.write(message));
12
+
3
13
  const { runSelectedInteractiveRuntime } = await import("../dist/src/features/launch/index.js");
4
14
 
5
15
  runSelectedInteractiveRuntime(process.env.A1_LAUNCH_PROFILE ?? "a1", {
@@ -5,7 +5,7 @@
5
5
  "platform": "darwin",
6
6
  "architecture": "arm64",
7
7
  "capability": "unsupported",
8
- "builtAt": "2026-08-22T17:52:01.473Z",
8
+ "builtAt": "2026-08-23T17:52:00.836Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "7524bf568992517f50ed53196c861c5edeba0d7275633bb4735ab45bd5963a28",
@@ -5,7 +5,7 @@
5
5
  "platform": "linux",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-08-22T17:52:01.995Z",
8
+ "builtAt": "2026-08-23T17:52:10.246Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "29e22fe29de2828982bc67ef418c4adcaab1490281477b7bea592ec6fe621bcd",
@@ -5,10 +5,10 @@
5
5
  "platform": "win32",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-08-22T17:52:47.054Z",
8
+ "builtAt": "2026-08-23T17:52:55.388Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian.exe",
11
- "sha256": "ed14b51aeb577ecd86df4a8a9ca3ea33c27885da8d3d196b26b0d705d70a0b71",
11
+ "sha256": "b31ad029bb569cfcb1a33db24999a556b5e12c1ac63f792fc1c555e6850243e6",
12
12
  "size": 172544
13
13
  },
14
14
  "provenance": {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `pi` and `sandbox` exist to compare A1 against pinned Pi and to try resources
3
+ * against an isolated profile. Both are development instruments rather than
4
+ * product, so a stable release does not carry them: what a released `a1` exposes
5
+ * is the product plus its maintenance and package commands.
6
+ *
7
+ * Which build this is comes from its own version. A prerelease version is a
8
+ * `next`-channel build, which is where that work happens; a release version is
9
+ * not. Nothing to configure, and no way for a released build to be talked into it.
10
+ */
11
+ export interface CliCapabilities {
12
+ readonly developmentProfiles: boolean;
13
+ }
14
+ export declare function cliCapabilities(version: string): CliCapabilities;
15
+ export declare function isPrereleaseVersion(version: string): boolean;
@@ -0,0 +1,7 @@
1
+ import { prerelease } from "semver";
2
+ export function cliCapabilities(version) {
3
+ return Object.freeze({ developmentProfiles: isPrereleaseVersion(version) });
4
+ }
5
+ export function isPrereleaseVersion(version) {
6
+ return (prerelease(version)?.length ?? 0) > 0;
7
+ }
@@ -1,15 +1,18 @@
1
1
  import { type InteractiveLaunchIntent, type LaunchProfileId } from "../features/launch/index.js";
2
+ import type { CliCapabilities } from "./capabilities.js";
3
+ import type { PackageCommandRequest } from "./packages.js";
2
4
  export type UpdateChannel = "stable" | "next";
3
5
  export interface CliHandlers {
4
6
  readonly launch: (intent: InteractiveLaunchIntent) => Promise<number>;
5
7
  readonly version: () => Promise<number>;
6
8
  readonly update: (channel: UpdateChannel) => Promise<number>;
9
+ readonly packages: (request: PackageCommandRequest) => Promise<number>;
7
10
  }
8
11
  export interface CliOutput {
9
12
  readonly stderr: (message: string) => void;
10
13
  }
11
- export declare const CLI_USAGE: string;
12
- export declare function dispatchCli(arguments_: readonly string[], handlers: CliHandlers, output: CliOutput): Promise<number>;
14
+ export declare function cliUsage(capabilities: CliCapabilities): string;
15
+ export declare function dispatchCli(arguments_: readonly string[], handlers: CliHandlers, output: CliOutput, capabilities: CliCapabilities): Promise<number>;
13
16
  export type CliCommand = {
14
17
  readonly kind: "launch";
15
18
  readonly profileId: LaunchProfileId;
@@ -18,8 +21,11 @@ export type CliCommand = {
18
21
  } | {
19
22
  readonly kind: "update";
20
23
  readonly channel: UpdateChannel;
24
+ } | {
25
+ readonly kind: "packages";
26
+ readonly request: PackageCommandRequest;
21
27
  } | {
22
28
  readonly kind: "error";
23
29
  readonly message: string;
24
30
  };
25
- export declare function parseCliCommand(arguments_: readonly string[]): CliCommand;
31
+ export declare function parseCliCommand(arguments_: readonly string[], capabilities: CliCapabilities): CliCommand;
@@ -1,35 +1,123 @@
1
1
  import { interactiveLaunchIntent } from "../features/launch/index.js";
2
2
  import { PRODUCT_TEXT } from "../product-identity.js";
3
- export const CLI_USAGE = PRODUCT_TEXT.usage(["", "pi", "sandbox", "version", "update", "update:next"]);
4
- export async function dispatchCli(arguments_, handlers, output) {
5
- const command = parseCliCommand(arguments_);
3
+ export function cliUsage(capabilities) {
4
+ return PRODUCT_TEXT.usage([
5
+ "",
6
+ ...(capabilities.developmentProfiles ? ["pi", "sandbox"] : []),
7
+ "version",
8
+ "update [self|<source>|--extensions|--models]",
9
+ "update:next",
10
+ "install <source>",
11
+ "remove <source>",
12
+ "list",
13
+ ]);
14
+ }
15
+ const PROFILE_WORDS = new Set(["pi", "sandbox"]);
16
+ export async function dispatchCli(arguments_, handlers, output, capabilities) {
17
+ const command = parseCliCommand(arguments_, capabilities);
6
18
  if (command.kind === "error") {
7
- output.stderr(`${command.message}\n${CLI_USAGE}\n`);
19
+ output.stderr(`${command.message}\n${cliUsage(capabilities)}\n`);
8
20
  return 2;
9
21
  }
10
22
  if (command.kind === "launch")
11
23
  return await handlers.launch(interactiveLaunchIntent(command.profileId));
12
24
  if (command.kind === "version")
13
25
  return await handlers.version();
26
+ if (command.kind === "packages")
27
+ return await handlers.packages(command.request);
14
28
  return await handlers.update(command.channel);
15
29
  }
16
- export function parseCliCommand(arguments_) {
30
+ export function parseCliCommand(arguments_, capabilities) {
17
31
  if (arguments_.length === 0)
18
32
  return { kind: "launch", profileId: "a1" };
19
- if (arguments_.length > 1)
20
- return { kind: "error", message: PRODUCT_TEXT.diagnostic("commands do not accept additional arguments.") };
21
- const [command] = arguments_;
22
- if (command === "pi" || command === "sandbox")
23
- return { kind: "launch", profileId: command };
24
- if (command === "ui")
25
- return { kind: "error", message: `The ui subcommand was removed; run bare ${PRODUCT_TEXT.commandName} for the owned UI.` };
33
+ const [command, ...rest] = arguments_;
34
+ if (capabilities.developmentProfiles && (command === "pi" || command === "sandbox")) {
35
+ return withoutArguments(rest, { kind: "launch", profileId: command });
36
+ }
26
37
  if (command === "version")
27
- return { kind: "version" };
28
- if (command === "update")
29
- return { kind: "update", channel: "stable" };
38
+ return withoutArguments(rest, { kind: "version" });
30
39
  if (command === "update:next")
31
- return { kind: "update", channel: "next" };
40
+ return withoutArguments(rest, { kind: "update", channel: "next" });
41
+ if (command === "update")
42
+ return parseUpdate(rest);
43
+ if (command === "install" || command === "remove" || command === "uninstall") {
44
+ return parseSourceCommand(command === "install" ? "install" : "remove", rest);
45
+ }
46
+ if (command === "list")
47
+ return withoutArguments(rest, { kind: "packages", request: { verb: "list", source: null } });
48
+ if (command === "ui")
49
+ return { kind: "error", message: `The ui subcommand was removed; run bare ${PRODUCT_TEXT.commandName} for the owned UI.` };
32
50
  if (command === "agent")
33
51
  return { kind: "error", message: `Bare ${PRODUCT_TEXT.commandName} is the ${PRODUCT_TEXT.displayName} agent experience; there is no agent subcommand.` };
34
52
  return { kind: "error", message: PRODUCT_TEXT.diagnostic(`received an unknown command: ${command ?? ""}`) };
35
53
  }
54
+ /**
55
+ * `update` carries both meanings pinned Pi gives it: itself by default, and the
56
+ * profile's packages when a target says so. Pi is refused as a target because A1
57
+ * certifies each release against one pinned Pi, so moving Pi underneath it would
58
+ * invalidate what was certified.
59
+ */
60
+ function parseUpdate(rest) {
61
+ if (rest.length === 0)
62
+ return { kind: "update", channel: "stable" };
63
+ if (rest.length > 1)
64
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic("update accepts one target.") };
65
+ const [target] = rest;
66
+ if (target === "self")
67
+ return { kind: "update", channel: "stable" };
68
+ if (target === "pi") {
69
+ return {
70
+ kind: "error",
71
+ message: PRODUCT_TEXT.diagnostic(`pins the Pi version it was certified against; run ${PRODUCT_TEXT.commandName} update to move ${PRODUCT_TEXT.displayName} itself.`),
72
+ };
73
+ }
74
+ // A release channel is spelled with the colon. Taking the bare word as a package
75
+ // source would turn a near miss into a confident search for a package nobody has.
76
+ if (target === "next" || target === "stable") {
77
+ const form = target === "next" ? `${PRODUCT_TEXT.commandName} update:next` : `${PRODUCT_TEXT.commandName} update`;
78
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic(`selects a release channel with a colon; run ${form}.`) };
79
+ }
80
+ if (target === "--extensions")
81
+ return { kind: "packages", request: { verb: "update", source: null } };
82
+ if (target === "--models")
83
+ return { kind: "packages", request: { verb: "refresh-models", source: null } };
84
+ if (target === undefined || target.startsWith("-"))
85
+ return unknownOption(target ?? "", "update");
86
+ if (PROFILE_WORDS.has(target))
87
+ return profileRejection("update");
88
+ return { kind: "packages", request: { verb: "update", source: target } };
89
+ }
90
+ function parseSourceCommand(verb, rest) {
91
+ const flag = rest.find(argument => argument.startsWith("-"));
92
+ if (flag !== undefined)
93
+ return unknownOption(flag, verb);
94
+ if (rest.length === 0)
95
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic(`${verb} requires a package source.`) };
96
+ if (rest.length > 1)
97
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic(`${verb} accepts one package source.`) };
98
+ const [source] = rest;
99
+ if (source === undefined)
100
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic(`${verb} requires a package source.`) };
101
+ if (PROFILE_WORDS.has(source))
102
+ return profileRejection(verb);
103
+ return { kind: "packages", request: { verb, source } };
104
+ }
105
+ function withoutArguments(rest, command) {
106
+ if (rest.length === 0)
107
+ return command;
108
+ const [argument] = rest;
109
+ if (argument !== undefined && (PROFILE_WORDS.has(argument) || argument.startsWith("--profile")))
110
+ return profileRejection("list");
111
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic("commands do not accept additional arguments.") };
112
+ }
113
+ function profileRejection(verb) {
114
+ return {
115
+ kind: "error",
116
+ message: PRODUCT_TEXT.diagnostic(`manages packages in its own profile, so ${verb} takes no profile; Pi manages Pi's own profile.`),
117
+ };
118
+ }
119
+ function unknownOption(option, verb) {
120
+ if (option.startsWith("--profile"))
121
+ return profileRejection(verb);
122
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic(`received an unknown option for ${verb}: ${option}`) };
123
+ }
@@ -1,3 +1,5 @@
1
+ export * from "./capabilities.js";
1
2
  export * from "./dispatch.js";
3
+ export * from "./packages.js";
2
4
  export * from "./version.js";
3
5
  export * from "./version-stats.js";
@@ -1,3 +1,5 @@
1
+ export * from "./capabilities.js";
1
2
  export * from "./dispatch.js";
3
+ export * from "./packages.js";
2
4
  export * from "./version.js";
3
5
  export * from "./version-stats.js";
@@ -0,0 +1,23 @@
1
+ import { initializeProductProfile } from "../features/launch/index.js";
2
+ import type { AgentPackageOutcome, AgentPackagesPort, AgentPackagesPortInput } from "../foundation/agent-engine-contracts/index.js";
3
+ /**
4
+ * Package commands manage A1's own profile and nothing else, so no request carries
5
+ * a profile: `update` with no source means every installed package, and refreshing
6
+ * model catalogs is its own verb rather than a flag the caller has to remember to
7
+ * check.
8
+ */
9
+ export type PackageCommandVerb = "install" | "remove" | "list" | "update" | "refresh-models";
10
+ export interface PackageCommandRequest {
11
+ readonly verb: PackageCommandVerb;
12
+ readonly source: string | null;
13
+ }
14
+ export interface PackageCommandEnvironment {
15
+ readonly createPort: (input: AgentPackagesPortInput) => AgentPackagesPort;
16
+ readonly cwd?: string;
17
+ readonly environment?: NodeJS.ProcessEnv;
18
+ readonly stdout?: (message: string) => void;
19
+ readonly stderr?: (message: string) => void;
20
+ readonly initializeProfile?: typeof initializeProductProfile;
21
+ }
22
+ export declare function runPackageCommand(request: PackageCommandRequest, environment: PackageCommandEnvironment): Promise<number>;
23
+ export declare function renderPackageOutcome(outcome: AgentPackageOutcome, profileRoot: string): string;