@timurproko/a1 0.1.1-dev.12 → 0.1.1-dev.14

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.
package/README.md CHANGED
@@ -10,13 +10,37 @@ npm install --global @timurproko/a1@latest
10
10
 
11
11
  ```sh
12
12
  a1 # A1-owned UI and profile: ~/.a1/agent
13
- a1 pi # untouched vanilla Pi oracle: ~/.pi/agent
14
- a1 sandbox # unchanged isolated vanilla Pi profile: ~/.a1/sandbox
15
13
  a1 version # show Installed, Release (latest), and Next versions
16
14
  a1 update # update to npm latest
17
15
  a1 update:next # update to npm next
18
16
  ```
19
17
 
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.
21
+
22
+ ```sh
23
+ a1 pi # untouched vanilla Pi oracle: ~/.pi/agent
24
+ a1 sandbox # unchanged isolated vanilla Pi profile: ~/.a1/sandbox
25
+ ```
26
+
27
+ ## Extensions
28
+
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.
31
+
32
+ ```sh
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
39
+ ```
40
+
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.
43
+
20
44
  ## Develop
21
45
 
22
46
  ```sh
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);
@@ -5,7 +5,7 @@
5
5
  "platform": "darwin",
6
6
  "architecture": "arm64",
7
7
  "capability": "unsupported",
8
- "builtAt": "2026-08-23T13:01:43.157Z",
8
+ "builtAt": "2026-08-23T14:54:52.984Z",
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-23T13:01:56.341Z",
8
+ "builtAt": "2026-08-23T14:54:49.534Z",
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-23T13:02:22.268Z",
8
+ "builtAt": "2026-08-23T14:55:13.661Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian.exe",
11
- "sha256": "f36ba90394ff9d7bd32612d8b89f96f23afa394e22260466271938ae27ee5c9f",
11
+ "sha256": "068b70d85658579b34c845f742cd9e209239f4b93fcfb4d39962fd1d4d19d226",
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;
@@ -0,0 +1,84 @@
1
+ import { configurationRootForProfile, initializeProductProfile, resolveLaunchProfilePaths } from "../features/launch/index.js";
2
+ import { PRODUCT_TEXT } from "../product-identity.js";
3
+ export async function runPackageCommand(request, environment) {
4
+ const stdout = environment.stdout ?? (message => process.stdout.write(message));
5
+ const stderr = environment.stderr ?? (message => process.stderr.write(message));
6
+ const cwd = environment.cwd ?? process.cwd();
7
+ const processEnvironment = environment.environment ?? process.env;
8
+ const paths = resolveLaunchProfilePaths({ environment: processEnvironment });
9
+ const profileRoot = configurationRootForProfile("a1", paths);
10
+ if (profileRoot === null)
11
+ throw new Error(PRODUCT_TEXT.diagnostic("has no profile root for package commands"));
12
+ try {
13
+ await (environment.initializeProfile ?? initializeProductProfile)(profileRoot);
14
+ }
15
+ catch (error) {
16
+ stderr(`${PRODUCT_TEXT.diagnostic(`could not prepare its profile at ${profileRoot}: ${message(error)}`)}\n`);
17
+ return 1;
18
+ }
19
+ const port = environment.createPort({
20
+ profileRoot,
21
+ cwd,
22
+ onProgress: progress => stdout(`${progress.message}\n`),
23
+ });
24
+ const outcome = await runVerb(port, request);
25
+ const rendered = renderPackageOutcome(outcome, profileRoot);
26
+ (outcome.status === "completed" ? stdout : stderr)(rendered);
27
+ return outcome.status === "completed" ? 0 : 1;
28
+ }
29
+ async function runVerb(port, request) {
30
+ if (request.verb === "list")
31
+ return await port.list();
32
+ if (request.verb === "refresh-models")
33
+ return await port.refreshModels();
34
+ if (request.verb === "update")
35
+ return await port.update(request.source ?? undefined);
36
+ if (request.source === null)
37
+ throw new Error(PRODUCT_TEXT.diagnostic(`requires a source for ${request.verb}`));
38
+ return request.verb === "install" ? await port.install(request.source) : await port.remove(request.source);
39
+ }
40
+ export function renderPackageOutcome(outcome, profileRoot) {
41
+ const name = PRODUCT_TEXT.displayName;
42
+ if (outcome.status === "failed") {
43
+ return `${PRODUCT_TEXT.diagnostic(`could not ${describeOperation(outcome.operation)}: ${outcome.detail ?? "unknown failure"}`)}\n`;
44
+ }
45
+ if (outcome.status === "not-found") {
46
+ return `${PRODUCT_TEXT.diagnostic(`found no package matching ${outcome.source ?? "that source"} in ${profileRoot}.`)}\n`;
47
+ }
48
+ switch (outcome.operation) {
49
+ case "install":
50
+ return `${name} installed ${outcome.source} into ${profileRoot}.\n`
51
+ + `Restart ${PRODUCT_TEXT.commandName} for a running session to load it.\n`;
52
+ case "remove":
53
+ return `${name} removed ${outcome.source} from ${profileRoot}.\n`;
54
+ case "update":
55
+ return outcome.source === null
56
+ ? `${name} updated the packages in ${profileRoot}.\n`
57
+ : `${name} updated ${outcome.source}.\n`;
58
+ case "refresh-models":
59
+ return `${name} refreshed the model catalogs in ${profileRoot}.\n`;
60
+ case "list":
61
+ return renderPackageList(outcome, profileRoot);
62
+ }
63
+ }
64
+ function renderPackageList(outcome, profileRoot) {
65
+ if (outcome.packages.length === 0)
66
+ return `${PRODUCT_TEXT.displayName} has no packages installed in ${profileRoot}.\n`;
67
+ const lines = [`Packages installed for ${PRODUCT_TEXT.commandName} in ${profileRoot}:`];
68
+ for (const entry of outcome.packages) {
69
+ lines.push(` ${entry.source}${entry.filtered ? " (partly enabled)" : ""}`);
70
+ if (entry.installedPath !== null)
71
+ lines.push(` ${entry.installedPath}`);
72
+ }
73
+ return `${lines.join("\n")}\n`;
74
+ }
75
+ function describeOperation(operation) {
76
+ if (operation === "refresh-models")
77
+ return "refresh the model catalogs";
78
+ if (operation === "list")
79
+ return "list installed packages";
80
+ return `${operation} the package`;
81
+ }
82
+ function message(error) {
83
+ return (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim();
84
+ }
@@ -2,6 +2,7 @@ export * from "./capability-ports.js";
2
2
  export * from "./domain.js";
3
3
  export * from "./domain-validation.js";
4
4
  export * from "./model.js";
5
+ export * from "./package-ports.js";
5
6
  export * from "./ports.js";
6
7
  export * from "./validation.js";
7
8
  export * from "./serialization.js";
@@ -2,6 +2,7 @@ export * from "./capability-ports.js";
2
2
  export * from "./domain.js";
3
3
  export * from "./domain-validation.js";
4
4
  export * from "./model.js";
5
+ export * from "./package-ports.js";
5
6
  export * from "./ports.js";
6
7
  export * from "./validation.js";
7
8
  export * from "./serialization.js";
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Extension packages are managed outside any session: the command that installs
3
+ * one runs before the runtime exists, against a profile root chosen by the caller
4
+ * rather than by whatever session happens to be open. So this port stands apart
5
+ * from the session-scoped service ports and names only what a package operation
6
+ * needs — a root, a source, and an outcome a caller can render without knowing
7
+ * which engine performed it.
8
+ */
9
+ export type AgentPackageOperation = "install" | "remove" | "update" | "refresh-models" | "list";
10
+ /**
11
+ * Why an operation ended, kept separate from the prose so callers can branch on
12
+ * it. `not-found` is the one failure an engine can state structurally — the source
13
+ * is not configured in this profile — rather than only in a message.
14
+ */
15
+ export type AgentPackageStatus = "completed" | "not-found" | "failed";
16
+ export interface AgentPackageDescriptor {
17
+ readonly source: string;
18
+ readonly installedPath: string | null;
19
+ /** True when the profile enables only part of what the package provides. */
20
+ readonly filtered: boolean;
21
+ }
22
+ export interface AgentPackageOutcome {
23
+ readonly operation: AgentPackageOperation;
24
+ readonly status: AgentPackageStatus;
25
+ readonly source: string | null;
26
+ readonly packages: readonly AgentPackageDescriptor[];
27
+ readonly detail: string | null;
28
+ }
29
+ export interface AgentPackageProgress {
30
+ readonly operation: AgentPackageOperation;
31
+ readonly message: string;
32
+ }
33
+ export interface AgentPackagesPort {
34
+ readonly capabilities: {
35
+ readonly install: boolean;
36
+ readonly remove: boolean;
37
+ readonly update: boolean;
38
+ readonly refreshModels: boolean;
39
+ };
40
+ /** Absolute profile root every operation of this port acts on. */
41
+ readonly profileRoot: string;
42
+ list(): Promise<AgentPackageOutcome>;
43
+ install(source: string): Promise<AgentPackageOutcome>;
44
+ remove(source: string): Promise<AgentPackageOutcome>;
45
+ update(source?: string): Promise<AgentPackageOutcome>;
46
+ refreshModels(): Promise<AgentPackageOutcome>;
47
+ }
48
+ export interface AgentPackagesPortInput {
49
+ readonly profileRoot: string;
50
+ readonly cwd: string;
51
+ readonly onProgress?: (progress: AgentPackageProgress) => void;
52
+ }
53
+ export declare function assertAgentPackagesPort(port: AgentPackagesPort): void;
54
+ export declare function agentPackageOutcome(operation: AgentPackageOperation, status: AgentPackageStatus, detail?: string | null, source?: string | null, packages?: readonly AgentPackageDescriptor[]): AgentPackageOutcome;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Extension packages are managed outside any session: the command that installs
3
+ * one runs before the runtime exists, against a profile root chosen by the caller
4
+ * rather than by whatever session happens to be open. So this port stands apart
5
+ * from the session-scoped service ports and names only what a package operation
6
+ * needs — a root, a source, and an outcome a caller can render without knowing
7
+ * which engine performed it.
8
+ */
9
+ export function assertAgentPackagesPort(port) {
10
+ if (typeof port?.capabilities !== "object" || port.capabilities === null)
11
+ throw new TypeError("packages port capabilities are required");
12
+ if (typeof port.profileRoot !== "string" || port.profileRoot.length === 0)
13
+ throw new TypeError("packages port requires an absolute profile root");
14
+ for (const operation of ["list", "install", "remove", "update", "refreshModels"]) {
15
+ if (typeof port[operation] !== "function")
16
+ throw new TypeError(`packages port requires ${operation}`);
17
+ }
18
+ for (const capability of ["install", "remove", "update", "refreshModels"]) {
19
+ if (typeof port.capabilities[capability] !== "boolean")
20
+ throw new TypeError(`packages capability ${capability} must be explicit`);
21
+ }
22
+ }
23
+ export function agentPackageOutcome(operation, status, detail, source, packages = []) {
24
+ return Object.freeze({
25
+ operation,
26
+ status,
27
+ source: source ?? null,
28
+ packages: Object.freeze([...packages]),
29
+ detail: detail ?? null,
30
+ });
31
+ }
@@ -4,6 +4,7 @@ export * from "./runtime-integration.js";
4
4
  export * from "./session-integration.js";
5
5
  export * from "./model-auth-integration.js";
6
6
  export * from "./settings-integration.js";
7
+ export * from "./package-integration.js";
7
8
  export * from "./resource-extension-integration.js";
8
9
  export * from "./workflow-controllers.js";
9
10
  export * from "./workflows.js";
@@ -4,6 +4,7 @@ export * from "./runtime-integration.js";
4
4
  export * from "./session-integration.js";
5
5
  export * from "./model-auth-integration.js";
6
6
  export * from "./settings-integration.js";
7
+ export * from "./package-integration.js";
7
8
  export * from "./resource-extension-integration.js";
8
9
  export * from "./workflow-controllers.js";
9
10
  export * from "./workflows.js";
@@ -0,0 +1,13 @@
1
+ import { type AgentPackagesPort, type AgentPackagesPortInput } from "../agent-engine-contracts/index.js";
2
+ /**
3
+ * Pinned Pi's package manager already takes the profile root as an argument, so
4
+ * this binds it to the root A1 chose rather than to whatever the configuration-root
5
+ * environment variable happens to say. Pi's own package command handler is not used:
6
+ * it prints Pi's command names and ends the process, and both of those belong to
7
+ * whichever A1 command is running.
8
+ *
9
+ * The settings manager is created with project trust withheld, which is what keeps
10
+ * every operation to the user scope of the selected profile — a project-local
11
+ * `packages` list is never read, so it can never be written either.
12
+ */
13
+ export declare function createPiPackagesPort(input: AgentPackagesPortInput): AgentPackagesPort;
@@ -0,0 +1,112 @@
1
+ import { join } from "node:path";
2
+ import { DefaultPackageManager, ModelRuntime, SettingsManager } from "@earendil-works/pi-coding-agent";
3
+ import { agentPackageOutcome, } from "../agent-engine-contracts/index.js";
4
+ const MODEL_REFRESH_TIMEOUT_MS = 15_000;
5
+ /**
6
+ * Pinned Pi's package manager already takes the profile root as an argument, so
7
+ * this binds it to the root A1 chose rather than to whatever the configuration-root
8
+ * environment variable happens to say. Pi's own package command handler is not used:
9
+ * it prints Pi's command names and ends the process, and both of those belong to
10
+ * whichever A1 command is running.
11
+ *
12
+ * The settings manager is created with project trust withheld, which is what keeps
13
+ * every operation to the user scope of the selected profile — a project-local
14
+ * `packages` list is never read, so it can never be written either.
15
+ */
16
+ export function createPiPackagesPort(input) {
17
+ const { profileRoot, cwd } = input;
18
+ const settingsManager = SettingsManager.create(cwd, profileRoot, { projectTrusted: false });
19
+ const packageManager = new DefaultPackageManager({ cwd, agentDir: profileRoot, settingsManager });
20
+ packageManager.setProgressCallback(event => {
21
+ if (event.type !== "start" || !event.message)
22
+ return;
23
+ input.onProgress?.({ operation: operationFor(event.action), message: event.message });
24
+ });
25
+ const configured = () => packageManager.listConfiguredPackages()
26
+ .filter(entry => entry.scope === "user")
27
+ .map(entry => Object.freeze({
28
+ source: entry.source,
29
+ installedPath: entry.installedPath ?? null,
30
+ filtered: entry.filtered,
31
+ }));
32
+ const isConfigured = (source) => configured().some(entry => entry.source === source || entry.source.startsWith(`${source}@`));
33
+ return Object.freeze({
34
+ capabilities: Object.freeze({ install: true, remove: true, update: true, refreshModels: true }),
35
+ profileRoot,
36
+ async list() {
37
+ return await attempt("list", null, async () => agentPackageOutcome("list", "completed", null, null, configured()));
38
+ },
39
+ async install(source) {
40
+ return await attempt("install", source, async () => {
41
+ await packageManager.installAndPersist(source);
42
+ return agentPackageOutcome("install", "completed", null, source, configured());
43
+ });
44
+ },
45
+ async remove(source) {
46
+ return await attempt("remove", source, async () => {
47
+ const removed = await packageManager.removeAndPersist(source);
48
+ if (!removed)
49
+ return agentPackageOutcome("remove", "not-found", null, source);
50
+ return agentPackageOutcome("remove", "completed", null, source, configured());
51
+ });
52
+ },
53
+ async update(source) {
54
+ return await attempt("update", source ?? null, async () => {
55
+ // Asking first keeps "that package is not installed here" a structural
56
+ // answer rather than a string a caller would have to recognize.
57
+ if (source !== undefined && !isConfigured(source))
58
+ return agentPackageOutcome("update", "not-found", null, source);
59
+ await packageManager.update(source);
60
+ return agentPackageOutcome("update", "completed", null, source ?? null, configured());
61
+ });
62
+ },
63
+ async refreshModels() {
64
+ return await attempt("refresh-models", null, async () => {
65
+ await refreshModelCatalogs(profileRoot);
66
+ return agentPackageOutcome("refresh-models", "completed");
67
+ });
68
+ },
69
+ });
70
+ }
71
+ async function attempt(operation, source, run) {
72
+ try {
73
+ return await run();
74
+ }
75
+ catch (error) {
76
+ return agentPackageOutcome(operation, "failed", describe(error), source);
77
+ }
78
+ }
79
+ async function refreshModelCatalogs(profileRoot) {
80
+ const controller = new AbortController();
81
+ const timeout = setTimeout(() => controller.abort(), MODEL_REFRESH_TIMEOUT_MS);
82
+ try {
83
+ const modelRuntime = await ModelRuntime.create({
84
+ authPath: join(profileRoot, "auth.json"),
85
+ modelsPath: join(profileRoot, "models.json"),
86
+ allowModelNetwork: false,
87
+ signal: controller.signal,
88
+ });
89
+ const result = await modelRuntime.refresh({ allowNetwork: true, force: true, signal: controller.signal });
90
+ if (result.aborted)
91
+ throw new Error(`model catalog refresh timed out after ${MODEL_REFRESH_TIMEOUT_MS}ms`);
92
+ if (result.errors.size > 0) {
93
+ throw new Error(Array.from(result.errors, ([provider, error]) => `${provider}: ${error.message}`).join("; "));
94
+ }
95
+ }
96
+ finally {
97
+ clearTimeout(timeout);
98
+ }
99
+ }
100
+ function operationFor(action) {
101
+ if (action === "install")
102
+ return "install";
103
+ if (action === "remove")
104
+ return "remove";
105
+ return "update";
106
+ }
107
+ function describe(error) {
108
+ const message = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim();
109
+ if (message.length === 0)
110
+ return "unknown package failure";
111
+ return message.length > 600 ? `${message.slice(0, 597)}...` : message;
112
+ }
@@ -44,13 +44,22 @@ export interface SelfUpdateOptions {
44
44
  now?: () => number;
45
45
  }
46
46
  export type UpdateActivationPhase = Extract<UpdateTransactionPhase, "materialized" | "certified" | "active-reference-committed">;
47
+ /**
48
+ * Copying the release is the longest step with nothing to say for itself, so it
49
+ * reports the files it has written against the files it must write. A caller that
50
+ * shows progress can then move with the work instead of guessing at it.
51
+ */
52
+ export interface UpdateMaterializationProgress {
53
+ readonly completed: number;
54
+ readonly total: number;
55
+ }
47
56
  export interface UpdateLifecycleCoordinator {
48
57
  targetIsActive(targetVersion: string): Promise<boolean>;
49
58
  shutdownVerifiedOwners(targetVersion: string): Promise<{
50
59
  priorActiveVersion: string | null;
51
60
  }>;
52
61
  verifyPackageUnlocked(packageRoot: string): Promise<void>;
53
- activateInstalled(packageRoot: string, targetVersion: string, phase: (phase: UpdateActivationPhase) => Promise<void>): Promise<void>;
62
+ activateInstalled(packageRoot: string, targetVersion: string, phase: (phase: UpdateActivationPhase) => Promise<void>, onMaterializing?: (progress: UpdateMaterializationProgress) => void): Promise<void>;
54
63
  }
55
64
  export interface UpdateTransactionJournal {
56
65
  readonly path: string;
@@ -105,8 +105,21 @@ export function createUpdateLifecycleCoordinator(environment = process.env, file
105
105
  throw new Error(PRODUCT_TEXT.diagnostic(`package remains locked after verified shutdown: ${errorMessage(error)}`));
106
106
  }
107
107
  },
108
- async activateInstalled(packageRoot, targetVersion, phase) {
109
- const candidate = await materializeRelease(packageRoot, paths.dataDir);
108
+ async activateInstalled(packageRoot, targetVersion, phase, onMaterializing) {
109
+ let total = 0;
110
+ let completed = 0;
111
+ const candidate = await materializeRelease(packageRoot, paths.dataDir, {
112
+ onProgress: event => {
113
+ total = event.fileCount;
114
+ onMaterializing?.({ completed, total });
115
+ },
116
+ onOperation: event => {
117
+ if (event.operation !== "candidate-write")
118
+ return;
119
+ completed += 1;
120
+ onMaterializing?.({ completed, total });
121
+ },
122
+ });
110
123
  if (candidate.packageVersion !== targetVersion)
111
124
  throw new Error(`installed ${PRODUCT_TEXT.displayName} version ${candidate.packageVersion} does not match target ${targetVersion}`);
112
125
  await stateStore.recordCandidate(candidate);
@@ -123,10 +136,16 @@ export function createUpdateLifecycleCoordinator(environment = process.env, file
123
136
  }
124
137
  const PROGRESS_BAR_WIDTH = 39;
125
138
  const PROGRESS_TICK_MS = 200;
139
+ /**
140
+ * Copying the release owns 78–92 and is reported file by file, so the bar crosses
141
+ * that span with the work rather than parking at its start. What follows is short,
142
+ * which is why the remaining milestones sit close together.
143
+ */
144
+ const MATERIALIZE_PROGRESS = Object.freeze({ from: 78, to: 92 });
126
145
  const ACTIVATION_PROGRESS = {
127
- materialized: { at: 85, creepTo: 90 },
128
- certified: { at: 90, creepTo: 95 },
129
- "active-reference-committed": { at: 95, creepTo: 99 },
146
+ materialized: { at: 92, creepTo: 94 },
147
+ certified: { at: 94, creepTo: 96 },
148
+ "active-reference-committed": { at: 96, creepTo: 99 },
130
149
  };
131
150
  function renderProgressBar(percent) {
132
151
  const bounded = Math.min(100, Math.max(0, Math.round(percent)));
@@ -163,9 +182,12 @@ function createUpdateProgress(output, enabled) {
163
182
  if (creepTo <= current)
164
183
  return;
165
184
  // Creep asymptotically toward (but never reach) the next milestone so
166
- // long opaque phases such as npm install still show visible motion.
185
+ // long opaque phases such as npm install still show visible motion. The
186
+ // ceiling stays a whole point below the milestone: settling on `creepTo`
187
+ // itself would render as that milestone and make arriving at it invisible,
188
+ // which is what a stalled bar looks like.
167
189
  timer = setInterval(() => {
168
- current = Math.min(creepTo - 0.5, current + (creepTo - current) * 0.04);
190
+ current = Math.min(creepTo - 1, current + (creepTo - current) * 0.04);
169
191
  draw();
170
192
  }, PROGRESS_TICK_MS);
171
193
  timer.unref?.();
@@ -293,13 +315,17 @@ export async function runSelfUpdate(options) {
293
315
  // example, if bare A1 is launched before the update is resumed). Recheck
294
316
  // immediately before activation so recovery cannot start a second cohort.
295
317
  await measure("ownership-release", async () => { await lifecycle.shutdownVerifiedOwners(targetVersion); });
296
- progress.set(75, 85);
318
+ progress.set(75, MATERIALIZE_PROGRESS.from);
297
319
  let activationPhaseStartedAt = now();
298
320
  await lifecycle.activateInstalled(packageRoot, targetVersion, async (phase) => {
299
321
  options.onPhaseTiming?.({ phase, durationMs: Math.max(0, now() - activationPhaseStartedAt) });
300
322
  transaction = await transactionStore.advance(phase);
301
323
  progress.set(ACTIVATION_PROGRESS[phase].at, ACTIVATION_PROGRESS[phase].creepTo);
302
324
  activationPhaseStartedAt = now();
325
+ }, ({ completed, total }) => {
326
+ const span = MATERIALIZE_PROGRESS.to - MATERIALIZE_PROGRESS.from;
327
+ const done = total > 0 ? Math.min(1, completed / total) : 0;
328
+ progress.set(MATERIALIZE_PROGRESS.from + span * done);
303
329
  });
304
330
  options.onPhaseTiming?.({ phase: "supervisor-verified", durationMs: Math.max(0, now() - activationPhaseStartedAt) });
305
331
  const transactionStartedAt = now();
@@ -8,6 +8,8 @@
8
8
  | `a1 pi` | Untouched vanilla Pi fallback and comparison oracle | ordinary `~/.pi/agent` |
9
9
  | `a1 sandbox` | Unchanged isolated vanilla Pi profile for experiments | `~/.a1/sandbox` |
10
10
 
11
+ `a1 pi` and `a1 sandbox` are development instruments: one compares A1 against pinned Pi, the other tries resources against an isolated profile. Prerelease builds — what `a1 update:next` installs — expose them. A release build does not, and does not recognize the words: what it exposes is bare `a1` plus the maintenance and package commands. Working in this repository is unaffected, because `npm start:pi` and `npm run start:sandbox` prepare the profile and launch directly rather than through the command line.
12
+
11
13
  There is no `a1 agent` command. The former `a1 ui` subcommand is removed. Bare `a1` is the owned agent product surface and remains the entry point when multi-agent UX is introduced.
12
14
 
13
15
  ## First launch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurproko/a1",
3
- "version": "0.1.1-dev.12",
3
+ "version": "0.1.1-dev.14",
4
4
  "description": "Standalone terminal workspace for supervised native and managed agents",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.13.0",