@timurproko/a1 0.1.1-dev.9 → 0.1.5-dev.11

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 +76 -15
  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 +80 -40
  76. package/docs/features/launch-profiles.md +2 -0
  77. package/package.json +8 -3
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { PRODUCT_IDENTITY } from "../../product-identity.js";
7
- import { copyToClipboard, getAgentDir, ProjectTrustStore, SessionManager, } from "@earendil-works/pi-coding-agent";
7
+ import { copyToClipboard, DefaultPackageManager, getAgentDir, ProjectTrustStore, SessionManager, } from "@earendil-works/pi-coding-agent";
8
8
  import { OWNED_UI_EXTENSION_CONTRACT_VERSION, OWNED_UI_EXTENSION_RENDER_CALLBACKS, OWNED_UI_EXTENSION_UI_CALLBACKS, OWNED_UI_EXTENSION_UI_PROPERTIES, assertOwnedUiCommand, assertOwnedUiExtensionUiPort, assertOwnedUiSnapshot, } from "../owned-ui-contracts/index.js";
9
9
  import { PINNED_PI_SETTINGS_CALLBACKS, PINNED_PI_WORKFLOW_COMMAND_NAMES, } from "./workflows.js";
10
10
  import { createPiRuntimeIntegration } from "./runtime-integration.js";
@@ -19,6 +19,7 @@ const DEFAULT_SURFACE = {
19
19
  };
20
20
  export class PiEngineAdapter {
21
21
  #runtimeFactory;
22
+ #checkPackageUpdates;
22
23
  #cwd;
23
24
  #agentDir;
24
25
  #sessionId;
@@ -73,6 +74,10 @@ export class PiEngineAdapter {
73
74
  this.#agentDir = options.agentDir ?? getAgentDir();
74
75
  this.#sessionId = options.sessionId ?? "owned-session-1";
75
76
  this.#runtimeFactory = options.createRuntime ?? createDefaultPiRuntime;
77
+ this.#checkPackageUpdates = options.checkPackageUpdates
78
+ ?? (options.createRuntime
79
+ ? async () => []
80
+ : settingsManager => checkDefaultPiPackageUpdates(this.#cwd, this.#agentDir, settingsManager));
76
81
  this.#workflowHost = options.workflowHost ?? defaultWorkflowHost();
77
82
  this.#availableThemes = options.availableThemes ?? null;
78
83
  this.#workflowInteraction = { prompt: async () => null, notify() { } };
@@ -122,8 +127,25 @@ export class PiEngineAdapter {
122
127
  this.#editor = { ...this.#editor, submitEnabled: true };
123
128
  this.#emitEvent({ type: "session-lifecycle", lifecycle: "ready", reason: null });
124
129
  this.#emitView();
130
+ void this.#announcePackageUpdates(runtime.services.settingsManager);
125
131
  return this.view();
126
132
  }
133
+ async #announcePackageUpdates(settingsManager) {
134
+ if (process.env.PI_OFFLINE)
135
+ return;
136
+ let updates;
137
+ try {
138
+ updates = await this.#checkPackageUpdates(settingsManager);
139
+ }
140
+ catch {
141
+ return;
142
+ }
143
+ if (this.#disposed || updates.length === 0)
144
+ return;
145
+ const packages = updates.map(name => `- ${name}`).join("\n");
146
+ this.#addDiagnostic("info", "package-updates", `Package updates are available. Run pi update --extensions\nPackages:\n${packages}`, true);
147
+ this.#emitView();
148
+ }
127
149
  onEvent(listener) {
128
150
  this.#listeners.add(listener);
129
151
  listener(this.#event({ type: "session-view", view: this.view() }));
@@ -1876,6 +1898,11 @@ export async function createPiEngineAdapter(options = {}) {
1876
1898
  async function createDefaultPiRuntime(input) {
1877
1899
  return createPiRuntimeIntegration({ cwd: input.cwd, agentDir: input.agentDir });
1878
1900
  }
1901
+ async function checkDefaultPiPackageUpdates(cwd, agentDir, settingsManager) {
1902
+ const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
1903
+ const updates = await packageManager.checkForAvailableUpdates();
1904
+ return updates.map(update => update.displayName);
1905
+ }
1879
1906
  function pinnedSessionInfoPresentation(value, sessionName, entries, modelRuntime) {
1880
1907
  const stats = isRecord(value) ? value : {};
1881
1908
  const tokens = dynamicObject(stats, "tokens");
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { type AgentSession, type AgentSessionRuntime } from "@earendil-works/pi-coding-agent";
1
+ import { type AgentSession, type AgentSessionRuntime, type AgentSessionServices, type ScopedModel } from "@earendil-works/pi-coding-agent";
2
2
  export interface PiRuntimeIntegrationOptions {
3
3
  readonly cwd: string;
4
4
  readonly agentDir: string;
@@ -10,9 +10,26 @@ export type PiSessionReplacement = {
10
10
  readonly kind: "resume";
11
11
  readonly sessionPath: string;
12
12
  };
13
+ interface ConfiguredModelScope {
14
+ readonly scopedModels: readonly ScopedModel[];
15
+ readonly model: ScopedModel["model"] | undefined;
16
+ readonly thinkingLevel: ScopedModel["thinkingLevel"];
17
+ readonly diagnostics: readonly {
18
+ type: "info" | "warning" | "error";
19
+ message: string;
20
+ }[];
21
+ }
22
+ /**
23
+ * Mirrors pinned Pi's CLI startup: resolve the `models` patterns from settings
24
+ * into a scoped model list, keep the resolver's warnings (e.g. "No models match
25
+ * pattern ..."), and pick the same initial model pinned Pi would pick — the
26
+ * saved default when it is in scope, otherwise the first scoped model.
27
+ */
28
+ export declare function resolveConfiguredModelScope(services: Pick<AgentSessionServices, "settingsManager" | "modelRuntime">): Promise<ConfiguredModelScope>;
13
29
  export declare function createPiRuntimeIntegration(options: PiRuntimeIntegrationOptions): Promise<AgentSessionRuntime>;
14
30
  export declare function bindPiRuntimeSession(runtime: AgentSessionRuntime, rebind: (session: AgentSession) => Promise<void>): () => void;
15
31
  export declare function replacePiRuntimeSession(runtime: AgentSessionRuntime, replacement: PiSessionReplacement): Promise<{
16
32
  readonly cancelled: boolean;
17
33
  }>;
18
34
  export declare function disposePiRuntimeIntegration(runtime: AgentSessionRuntime): Promise<void>;
35
+ export {};
@@ -1,14 +1,49 @@
1
- import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, SessionManager, } from "@earendil-works/pi-coding-agent";
1
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, resolveModelScopeWithDiagnostics, SessionManager, } from "@earendil-works/pi-coding-agent";
2
+ /**
3
+ * Mirrors pinned Pi's CLI startup: resolve the `models` patterns from settings
4
+ * into a scoped model list, keep the resolver's warnings (e.g. "No models match
5
+ * pattern ..."), and pick the same initial model pinned Pi would pick — the
6
+ * saved default when it is in scope, otherwise the first scoped model.
7
+ */
8
+ export async function resolveConfiguredModelScope(services) {
9
+ const patterns = services.settingsManager.getEnabledModels();
10
+ if (!patterns || patterns.length === 0) {
11
+ return { scopedModels: [], model: undefined, thinkingLevel: undefined, diagnostics: [] };
12
+ }
13
+ const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics([...patterns], services.modelRuntime, { signal: AbortSignal.timeout(15_000) });
14
+ let selected;
15
+ if (scopedModels.length > 0) {
16
+ const savedProvider = services.settingsManager.getDefaultProvider();
17
+ const savedModelId = services.settingsManager.getDefaultModel();
18
+ const savedModel = savedProvider && savedModelId
19
+ ? services.modelRuntime.getModel(savedProvider, savedModelId)
20
+ : undefined;
21
+ selected = (savedModel
22
+ ? scopedModels.find(scoped => scoped.model.provider === savedModel.provider && scoped.model.id === savedModel.id)
23
+ : undefined) ?? scopedModels[0];
24
+ }
25
+ return {
26
+ scopedModels,
27
+ model: selected?.model,
28
+ thinkingLevel: selected?.thinkingLevel,
29
+ diagnostics: diagnostics.map(diagnostic => ({ type: diagnostic.type, message: diagnostic.message })),
30
+ };
31
+ }
2
32
  export async function createPiRuntimeIntegration(options) {
3
33
  const sessionManager = SessionManager.create(options.cwd, options.sessionDir ?? process.env.PI_CODING_AGENT_SESSION_DIR);
4
34
  const createRuntime = async ({ cwd, sessionManager: targetSessionManager, sessionStartEvent, }) => {
5
35
  const services = await createAgentSessionServices({ cwd, agentDir: options.agentDir });
36
+ const modelScope = await resolveConfiguredModelScope(services);
37
+ const hasExistingSession = targetSessionManager.buildSessionContext().messages.length > 0;
6
38
  const created = await createAgentSessionFromServices({
7
39
  services,
8
40
  sessionManager: targetSessionManager,
9
41
  ...(sessionStartEvent ? { sessionStartEvent } : {}),
42
+ ...(modelScope.model && !hasExistingSession ? { model: modelScope.model } : {}),
43
+ ...(modelScope.thinkingLevel && !hasExistingSession ? { thinkingLevel: modelScope.thinkingLevel } : {}),
44
+ ...(modelScope.scopedModels.length > 0 ? { scopedModels: [...modelScope.scopedModels] } : {}),
10
45
  });
11
- return { ...created, services, diagnostics: services.diagnostics };
46
+ return { ...created, services, diagnostics: [...modelScope.diagnostics] };
12
47
  };
13
48
  return createAgentSessionRuntime(createRuntime, {
14
49
  cwd: options.cwd,
@@ -1,6 +1,6 @@
1
1
  import { MOUSE_TRACKING_OFF, MOUSE_TRACKING_ON, parseMouseInput } from "../ui-components/index.js";
2
2
  import { PINNED_PI_HIDDEN_COMMAND_NAMES, PINNED_PI_WORKFLOW_COMMAND_NAMES, } from "../pi-engine-adapter/index.js";
3
- import { createPiExtensionUiBridge, createPiQueuedInputStatus, createPiShellArmin, createPiShellAuthProviderSelector, createPiShellChangelog, createPiShellDaxnuts, createPiShellDialog, createPiShellEarendilAnnouncement, createPiShellEditor, createPiShellExtensionSelector, createPiShellFooter, createPiShellHeader, createPiShellHotkeys, createPiShellLoadedResources, createPiShellLoginDialog, createPiShellModelSelector, createPiShellOperationLoader, createPiShellReloadBox, createPiShellScopedModelsSelector, createPiShellSelector, createPiShellSessionInfo, createPiShellSessionSelector, createPiShellSettingsSelector, createPiShellStatus, createPiShellTranscriptComponent, createPiShellTreeSelector, createPiShellTrustSelector, createPiShellUserMessageSelector, piTheme, renderPiShellStatusText, renderPiShellTranscriptBlock, } from "../pi-component-adapter/index.js";
3
+ import { createPiExtensionUiBridge, createPiQueuedInputStatus, createPiShellArmin, createPiShellAuthProviderSelector, createPiShellChangelog, createPiShellDaxnuts, createPiShellDialog, createPiShellEarendilAnnouncement, createPiShellEditor, createPiShellExtensionSelector, createPiShellFooter, createPiShellHeader, createPiShellHotkeys, createPiShellLoadedResources, createPiShellLoginDialog, createPiShellModelSelector, createPiShellOperationLoader, createPiShellReloadBox, createPiShellScopedModelsSelector, createPiShellSelector, createPiShellSessionInfo, createPiShellSessionSelector, createPiShellSettingsSelector, createPiShellStatus, createPiShellTranscriptComponent, createPiShellTreeSelector, createPiShellTrustSelector, createPiShellUserMessageSelector, piTheme, renderPiShellPackageUpdateNotice, renderPiShellStartupDiagnostic, renderPiShellStatusText, renderPiShellTranscriptBlock, } from "../pi-component-adapter/index.js";
4
4
  import { PiTuiRuntimeAdapter, } from "../pi-tui-runtime-adapter/index.js";
5
5
  export class OwnedUiSessionShellRoot {
6
6
  editor;
@@ -136,7 +136,17 @@ export class OwnedUiSessionShellRoot {
136
136
  return ["", ...rows];
137
137
  return rows;
138
138
  });
139
- const diagnosticRows = this.#view.diagnostics.slice(-3).flatMap(diagnostic => renderPiShellTranscriptBlock({
139
+ const diagnostics = this.#view.diagnostics;
140
+ const startupRows = diagnostics
141
+ .filter(diagnostic => diagnostic.code === "engine-startup")
142
+ .flatMap(diagnostic => renderPiShellStartupDiagnostic(diagnostic, width));
143
+ const packageUpdateRows = diagnostics
144
+ .filter(diagnostic => diagnostic.code === "package-updates")
145
+ .flatMap(diagnostic => renderPiShellPackageUpdateNotice(diagnostic.message.split("\n").filter(line => line.startsWith("- ")).map(line => line.slice(2)), width));
146
+ const diagnosticRows = diagnostics
147
+ .filter(diagnostic => diagnostic.code !== "engine-startup" && diagnostic.code !== "package-updates")
148
+ .slice(-3)
149
+ .flatMap(diagnostic => renderPiShellTranscriptBlock({
140
150
  id: `diagnostic-${diagnostic.sequence}`,
141
151
  kind: diagnostic.severity === "error" ? "error" : "system",
142
152
  status: "finalized",
@@ -149,9 +159,11 @@ export class OwnedUiSessionShellRoot {
149
159
  if (resourceRows.at(-1) === "")
150
160
  resourceRows.pop();
151
161
  return [
162
+ ...startupRows,
152
163
  ...(this.#extensionHeader ?? this.header).render(width),
153
164
  ...resourceRows,
154
165
  ...transcript,
166
+ ...packageUpdateRows,
155
167
  ...diagnosticRows,
156
168
  ];
157
169
  }
@@ -1,4 +1,4 @@
1
- import { HStack, ProcessTerminal, ScrollView, TuiAltScreen, TuiMainScreen, VStack, visibleWidth, } from "@earendil-works/pi-tui";
1
+ import { HStack, ProcessTerminal, ScrollView, TuiAltScreen, TuiMainScreen, VStack, visibleWidth, } from "#pi-tui";
2
2
  export class PiTuiRuntimeError extends Error {
3
3
  stage;
4
4
  constructor(stage, cause) {
@@ -139,11 +139,24 @@ async function findInstalledDependency(root, requesterRoot, name) {
139
139
  let directory = requesterRoot;
140
140
  while (true) {
141
141
  const candidate = resolve(directory, "node_modules", name);
142
- const fromRoot = relative(root, candidate);
143
- if (fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot)) {
142
+ if (await isContained(root, candidate)) {
144
143
  const metadata = await lstat(candidate).catch(() => null);
145
144
  if (metadata?.isDirectory() && !metadata.isSymbolicLink())
146
145
  return candidate;
146
+ // A linked dependency directory is how this installation unifies a module
147
+ // that would otherwise be present twice, and the link is made by A1 itself
148
+ // at install and at every launch. Follow it to the directory it names and
149
+ // collect from there, so the payload still reads only real files — but
150
+ // only when the target is inside this installation, so a link can never
151
+ // pull bytes in from somewhere else.
152
+ if (metadata?.isSymbolicLink()) {
153
+ const target = await realpath(candidate).catch(() => null);
154
+ if (target !== null && await isContained(root, target)) {
155
+ const targetMetadata = await lstat(target).catch(() => null);
156
+ if (targetMetadata?.isDirectory())
157
+ return target;
158
+ }
159
+ }
147
160
  }
148
161
  if (directory === root)
149
162
  return null;
@@ -153,6 +166,17 @@ async function findInstalledDependency(root, requesterRoot, name) {
153
166
  directory = parent;
154
167
  }
155
168
  }
169
+ /** True when `path` sits inside `root`, comparing what each one actually resolves to. */
170
+ async function isContained(root, path) {
171
+ const within = (from, to) => {
172
+ const value = relative(from, to);
173
+ return value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value);
174
+ };
175
+ if (within(root, path))
176
+ return true;
177
+ const canonicalRoot = await realpath(root).catch(() => null);
178
+ return canonicalRoot !== null && within(canonicalRoot, path);
179
+ }
156
180
  function dependencyNames(value) {
157
181
  if (value === undefined)
158
182
  return [];
@@ -39,17 +39,27 @@ export interface SelfUpdateOptions {
39
39
  runner?: UpdateProcessRunner;
40
40
  lifecycle?: UpdateLifecycleCoordinator;
41
41
  transactionStore?: UpdateTransactionJournal;
42
+ progress?: boolean;
42
43
  onPhaseTiming?: (event: UpdatePhaseTimingEvent) => void;
43
44
  now?: () => number;
44
45
  }
45
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
+ }
46
56
  export interface UpdateLifecycleCoordinator {
47
57
  targetIsActive(targetVersion: string): Promise<boolean>;
48
58
  shutdownVerifiedOwners(targetVersion: string): Promise<{
49
59
  priorActiveVersion: string | null;
50
60
  }>;
51
61
  verifyPackageUnlocked(packageRoot: string): Promise<void>;
52
- 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>;
53
63
  }
54
64
  export interface UpdateTransactionJournal {
55
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);
@@ -121,6 +134,81 @@ export function createUpdateLifecycleCoordinator(environment = process.env, file
121
134
  },
122
135
  };
123
136
  }
137
+ const PROGRESS_BAR_WIDTH = 39;
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 });
145
+ const ACTIVATION_PROGRESS = {
146
+ materialized: { at: 92, creepTo: 94 },
147
+ certified: { at: 94, creepTo: 96 },
148
+ "active-reference-committed": { at: 96, creepTo: 99 },
149
+ };
150
+ function renderProgressBar(percent) {
151
+ const bounded = Math.min(100, Math.max(0, Math.round(percent)));
152
+ const filled = Math.round((bounded / 100) * PROGRESS_BAR_WIDTH);
153
+ return `${"█".repeat(filled)}${"░".repeat(PROGRESS_BAR_WIDTH - filled)} ${bounded}%`;
154
+ }
155
+ function createUpdateProgress(output, enabled) {
156
+ let visible = false;
157
+ let current = 0;
158
+ let shown = -1;
159
+ let timer = null;
160
+ const draw = () => {
161
+ const rounded = Math.round(current);
162
+ if (rounded === shown)
163
+ return;
164
+ shown = rounded;
165
+ visible = true;
166
+ output.stdout(`\r${renderProgressBar(rounded)}`);
167
+ };
168
+ const stopCreep = () => {
169
+ if (timer !== null) {
170
+ clearInterval(timer);
171
+ timer = null;
172
+ }
173
+ };
174
+ return {
175
+ set(percent, creepTo = percent) {
176
+ if (!enabled)
177
+ return;
178
+ stopCreep();
179
+ current = Math.max(current, percent);
180
+ shown = -1;
181
+ draw();
182
+ if (creepTo <= current)
183
+ return;
184
+ // Creep asymptotically toward (but never reach) the next milestone so
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.
189
+ timer = setInterval(() => {
190
+ current = Math.min(creepTo - 1, current + (creepTo - current) * 0.04);
191
+ draw();
192
+ }, PROGRESS_TICK_MS);
193
+ timer.unref?.();
194
+ },
195
+ finish() {
196
+ stopCreep();
197
+ if (!visible)
198
+ return;
199
+ output.stdout(`\r${renderProgressBar(100)}\n`);
200
+ visible = false;
201
+ },
202
+ clear() {
203
+ stopCreep();
204
+ if (!visible)
205
+ return;
206
+ output.stdout(`\r${" ".repeat(PROGRESS_BAR_WIDTH + 6)}\r`);
207
+ visible = false;
208
+ shown = -1;
209
+ },
210
+ };
211
+ }
124
212
  export async function runSelfUpdate(options) {
125
213
  const fileSystem = options.fileSystem ?? defaultFileSystem;
126
214
  const output = options.output ?? defaultOutput;
@@ -157,7 +245,8 @@ export async function runSelfUpdate(options) {
157
245
  output.stderr(`${PRODUCT_TEXT.diagnostic(`received a malformed ${distTag} version from npm: ${JSON.stringify(targetLookup.result.stdout.trim())}.`)}\n`);
158
246
  return 1;
159
247
  }
160
- output.stdout(`${PRODUCT_TEXT.diagnostic(`update (${channel}): ${runningVersion} → ${targetVersion}.`)}\n`);
248
+ output.stdout(`${PRODUCT_TEXT.commandName} update (${channel}): ${runningVersion} → ${targetVersion}.\n`);
249
+ const progress = createUpdateProgress(output, options.progress ?? (options.output === undefined && process.stdout.isTTY === true));
161
250
  const rootLookup = await measure("global-root", async () => await runNpm(runner, ["root", "--global"], true, output, "resolve npm's global package root"));
162
251
  if (rootLookup.result === null)
163
252
  return rootLookup.exitCode;
@@ -190,9 +279,11 @@ export async function runSelfUpdate(options) {
190
279
  await transactionStore.finish("completed");
191
280
  }
192
281
  await transactionStore.clearCompleted();
193
- output.stdout(`${PRODUCT_TEXT.diagnostic("is already current and active for this channel; no installation was changed.")}\n`);
282
+ output.stdout(`${PRODUCT_TEXT.commandName} is up to date no update needed.\n`);
194
283
  return 0;
195
284
  }
285
+ // The bar first appears here so a no-change run never flashes it.
286
+ progress.set(3, 15);
196
287
  const cohortState = await new CohortStateStore(paths.dataDir).read();
197
288
  transaction = await transactionStore.begin({
198
289
  channel,
@@ -207,6 +298,7 @@ export async function runSelfUpdate(options) {
207
298
  });
208
299
  transaction = await transactionStore.advance("ownership-released");
209
300
  }
301
+ progress.set(15, 70);
210
302
  if (phaseBefore(transaction.phase, "package-installed")) {
211
303
  const installation = await measure("npm-install", async () => await runNpm(runner, ["install", "--global", "--loglevel=error", "--no-fund", "--no-audit", `${PRODUCT_PACKAGE}@${targetVersion}`], true, output, "start the global npm installation", false));
212
304
  if (installation.result === null)
@@ -218,15 +310,22 @@ export async function runSelfUpdate(options) {
218
310
  }
219
311
  transaction = await transactionStore.advance("package-installed");
220
312
  }
313
+ progress.set(70, 75);
221
314
  // Ownership can be reacquired after an interrupted installation (for
222
315
  // example, if bare A1 is launched before the update is resumed). Recheck
223
316
  // immediately before activation so recovery cannot start a second cohort.
224
317
  await measure("ownership-release", async () => { await lifecycle.shutdownVerifiedOwners(targetVersion); });
318
+ progress.set(75, MATERIALIZE_PROGRESS.from);
225
319
  let activationPhaseStartedAt = now();
226
320
  await lifecycle.activateInstalled(packageRoot, targetVersion, async (phase) => {
227
321
  options.onPhaseTiming?.({ phase, durationMs: Math.max(0, now() - activationPhaseStartedAt) });
228
322
  transaction = await transactionStore.advance(phase);
323
+ progress.set(ACTIVATION_PROGRESS[phase].at, ACTIVATION_PROGRESS[phase].creepTo);
229
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);
230
329
  });
231
330
  options.onPhaseTiming?.({ phase: "supervisor-verified", durationMs: Math.max(0, now() - activationPhaseStartedAt) });
232
331
  const transactionStartedAt = now();
@@ -234,10 +333,12 @@ export async function runSelfUpdate(options) {
234
333
  await transactionStore.finish("completed");
235
334
  await transactionStore.clearCompleted();
236
335
  options.onPhaseTiming?.({ phase: "transaction-complete", durationMs: Math.max(0, now() - transactionStartedAt) });
237
- output.stdout(`${PRODUCT_TEXT.diagnostic(`updated successfully: ${targetVersion} (${channel}).`)}\n`);
336
+ progress.finish();
337
+ output.stdout(`${PRODUCT_TEXT.commandName} updated successfully: ${targetVersion} (${channel}).\n`);
238
338
  return 0;
239
339
  }
240
340
  catch (error) {
341
+ progress.clear();
241
342
  const message = errorMessage(error);
242
343
  const rollback = options.lifecycle
243
344
  ? "previous test lifecycle retained"
@@ -69,11 +69,11 @@ This optional, mutating presentation-maintenance workflow regenerates component
69
69
 
70
70
  Presentation acceptance is the reader comparing `a1 pi` with pinned Pi. `node scripts/check-pinned-pi-source-ledger.mjs` validates accepted provenance; its `--engine-only` mode validates the ownership partition without comparing private upstream source.
71
71
 
72
- ## Preview publication
72
+ ## Publication
73
73
 
74
- A preview candidate must use a unique `-dev.N` version and exact manually accepted bytes. Publication packs once, binds evidence to source commit/version/integrity, runs applicable non-desktop gates, publishes under npm `next`, and verifies registry identity. It must keep `latest` unchanged and record physical/cross-platform certification as deferred.
74
+ One workflow publishes both channels: `.github/workflows/release.yml`, triggered by every push to `develop`. What the pushed commit declares decides the channel: a prerelease version publishes a preview under npm `next` at a unique `-dev.N` version stamped at publish time, and a stable version publishes under npm `latest`. Both pack once, validate those exact bytes on Windows, Linux, and macOS, and publish with provenance from the `npm-publish` environment. The publisher never rebuilds, a preview never changes `latest`, and the release tag, GitHub Release, and `master` are written only after the registry has the package.
75
75
 
76
- The GitHub trusted-publishing workflow is `.github/workflows/npm-publish.yml`. Stable publication remains a separate release process from a clean tagged `master` commit after all mandatory platform gates pass.
76
+ `docs/ci-release-runbook.md` is the operational reference.
77
77
 
78
78
  ## A1 state paths
79
79