@timurproko/a1 0.1.8-dev.544 → 0.1.8-dev.558

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 (82) hide show
  1. package/bin/cli.js +4 -0
  2. package/dist/app/session-shell/session-shell-root.js +15 -5
  3. package/dist/cli/dispatch.d.ts +5 -0
  4. package/dist/cli/dispatch.js +26 -0
  5. package/dist/cli/session-context.d.ts +9 -0
  6. package/dist/cli/session-context.js +29 -0
  7. package/dist/composition/owned-ui.js +6 -2
  8. package/dist/contracts/owned-ui/model.d.ts +5 -0
  9. package/dist/contracts/owned-ui/validation.js +6 -0
  10. package/dist/contracts/presentation/index.d.ts +13 -0
  11. package/dist/contracts/presentation/index.js +30 -0
  12. package/dist/features/launch/index.d.ts +2 -0
  13. package/dist/features/launch/index.js +1 -0
  14. package/dist/features/launch/session-context.d.ts +8 -0
  15. package/dist/features/launch/session-context.js +8 -0
  16. package/dist/features/owned-ui/project-trust-prompt.js +1 -1
  17. package/dist/features/owned-ui/reference-screen-app.js +7 -8
  18. package/dist/features/owned-ui/settings-app.js +9 -5
  19. package/dist/foundation/lifecycle/index.d.ts +2 -0
  20. package/dist/foundation/lifecycle/index.js +1 -0
  21. package/dist/foundation/lifecycle/session-repository-context.d.ts +18 -0
  22. package/dist/foundation/lifecycle/session-repository-context.js +173 -0
  23. package/dist/foundation/release/index.d.ts +2 -2
  24. package/dist/foundation/release/index.js +1 -1
  25. package/dist/foundation/release/update.d.ts +22 -1
  26. package/dist/foundation/release/update.js +61 -12
  27. package/dist/integrations/pi/components/models-dialog.js +9 -9
  28. package/dist/integrations/pi/components/shell-editor-autocomplete.d.ts +0 -6
  29. package/dist/integrations/pi/components/shell-editor-autocomplete.js +41 -8
  30. package/dist/integrations/pi/components/shell-extension-ui.js +4 -2
  31. package/dist/integrations/pi/components/shell-footer-status.js +1 -1
  32. package/dist/integrations/pi/components/shell-presenters-transcript.js +4 -2
  33. package/dist/integrations/pi/components/skills-command.d.ts +2 -0
  34. package/dist/integrations/pi/components/skills-command.js +11 -10
  35. package/dist/integrations/pi/components/skills-dialog.js +8 -3
  36. package/dist/integrations/pi/components/theme.d.ts +4 -0
  37. package/dist/integrations/pi/components/theme.js +10 -0
  38. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.d.ts +4 -4
  39. package/dist/integrations/pi/components/upstream/components/extension-editor.d.ts +2 -2
  40. package/dist/integrations/pi/components/upstream/components/extension-editor.js +9 -10
  41. package/dist/integrations/pi/components/upstream/components/extension-input.d.ts +17 -0
  42. package/dist/integrations/pi/components/upstream/components/extension-input.js +72 -0
  43. package/dist/integrations/pi/components/upstream/components/extension-selector.d.ts +15 -0
  44. package/dist/integrations/pi/components/upstream/components/extension-selector.js +92 -0
  45. package/dist/integrations/pi/components/upstream/components/scoped-models-selector.d.ts +3 -2
  46. package/dist/integrations/pi/components/upstream/components/scoped-models-selector.js +16 -15
  47. package/dist/integrations/pi/components/upstream/components/session-footer.js +25 -9
  48. package/dist/integrations/pi/components/upstream/components/session-selector.js +25 -16
  49. package/dist/integrations/pi/components/upstream/components/thinking-selector.d.ts +6 -4
  50. package/dist/integrations/pi/components/upstream/components/thinking-selector.js +22 -12
  51. package/dist/integrations/pi/components/upstream/components/tree-selector.d.ts +3 -2
  52. package/dist/integrations/pi/components/upstream/components/tree-selector.js +16 -13
  53. package/dist/integrations/pi/components/upstream/components/trust-selector.js +11 -5
  54. package/dist/integrations/pi/engine/adapter.d.ts +2 -1
  55. package/dist/integrations/pi/engine/adapter.js +2 -0
  56. package/dist/integrations/pi/engine/message-values.js +1 -0
  57. package/dist/integrations/pi/engine/resource-catalog.js +1 -1
  58. package/dist/integrations/pi/engine/session-runtime.d.ts +11 -2
  59. package/dist/integrations/pi/engine/session-runtime.js +136 -33
  60. package/dist/integrations/pi/engine/transcript-projection.js +35 -0
  61. package/dist/integrations/pi/startup-public.manifest.json +2 -0
  62. package/dist/native/darwin-arm64/manifest.json +1 -1
  63. package/dist/native/linux-x64/manifest.json +1 -1
  64. package/dist/native/win32-x64/manifest.json +2 -2
  65. package/dist/native/win32-x64/process-guardian.exe +0 -0
  66. package/dist/product-identity.d.ts +1 -1
  67. package/dist/product-identity.js +1 -1
  68. package/dist/product-identity.json +2 -1
  69. package/dist/ui/components/dialog-panel.d.ts +2 -1
  70. package/dist/ui/components/dialog-panel.js +6 -1
  71. package/dist/ui/components/index.d.ts +2 -0
  72. package/dist/ui/components/index.js +1 -0
  73. package/dist/ui/components/shortcut-hints.d.ts +7 -0
  74. package/dist/ui/components/shortcut-hints.js +12 -0
  75. package/dist/ui/components/shortcuts.d.ts +2 -0
  76. package/dist/ui/components/shortcuts.js +5 -2
  77. package/dist/ui/components/transcript-viewport.d.ts +1 -1
  78. package/docs/architecture/project-structure.md +6 -3
  79. package/docs/ci-release-runbook.md +9 -7
  80. package/docs/openspec-archive-automation.md +6 -0
  81. package/docs/validation.md +5 -2
  82. package/package.json +1 -1
@@ -0,0 +1,173 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { open, lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { PRODUCT_IDENTITY } from "../../product-identity.js";
6
+ import { resolveProductPaths } from "./paths.js";
7
+ const SCHEMA = "a1-session-repository-context-v1";
8
+ const MAX_RECORD_BYTES = 16 * 1024;
9
+ const MAX_HEADER_BYTES = 64 * 1024;
10
+ const GIT_TIMEOUT_MS = 5_000;
11
+ /** Persist one explicit same-repository worktree association for a stable Pi session. */
12
+ export async function setSessionRepositoryContext(identity, worktreePath, options = {}) {
13
+ const header = await readSessionHeader(identity);
14
+ const startup = await readGitWorktreeIdentity(header.cwd, false, options.signal);
15
+ const selected = await readGitWorktreeIdentity(worktreePath, true, options.signal);
16
+ if (!samePath(startup.commonDir, selected.commonDir)) {
17
+ throw new Error("associated worktree belongs to a different Git repository");
18
+ }
19
+ const record = {
20
+ schema: SCHEMA,
21
+ sessionId: header.id,
22
+ sessionFile: header.file,
23
+ startupRoot: startup.root,
24
+ startupCommonDir: startup.commonDir,
25
+ worktreeRoot: selected.root,
26
+ worktreeCommonDir: selected.commonDir,
27
+ worktreeGitDir: selected.gitDir,
28
+ updatedAt: new Date().toISOString(),
29
+ };
30
+ const destination = recordPath(header, options);
31
+ const directory = dirname(destination);
32
+ await mkdir(directory, { recursive: true, mode: 0o700 });
33
+ const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
34
+ try {
35
+ await writeFile(temporary, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
36
+ await rename(temporary, destination);
37
+ }
38
+ finally {
39
+ await rm(temporary, { force: true }).catch(() => undefined);
40
+ }
41
+ return { cwd: selected.root, branch: requireBranch(selected) };
42
+ }
43
+ /** Remove only the association belonging to the supplied stable session. */
44
+ export async function clearSessionRepositoryContext(identity, options = {}) {
45
+ const header = await readSessionHeader(identity);
46
+ await rm(recordPath(header, options), { force: true });
47
+ }
48
+ /** Read and revalidate a persisted association; malformed or stale state fails closed to absence. */
49
+ export async function readSessionRepositoryContext(identity, options = {}) {
50
+ let header;
51
+ try {
52
+ header = await readSessionHeader(identity);
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ try {
58
+ const path = recordPath(header, options);
59
+ const metadata = await lstat(path);
60
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size === 0 || metadata.size > MAX_RECORD_BYTES)
61
+ return null;
62
+ const bytes = await readFile(path, { signal: options.signal });
63
+ const value = JSON.parse(bytes.toString("utf8"));
64
+ if (!isRecord(value)
65
+ || value.schema !== SCHEMA
66
+ || value.sessionId !== header.id
67
+ || value.sessionFile !== header.file
68
+ || !validPath(value.startupRoot)
69
+ || !validPath(value.startupCommonDir)
70
+ || !validPath(value.worktreeRoot)
71
+ || !validPath(value.worktreeCommonDir)
72
+ || !validPath(value.worktreeGitDir)
73
+ || typeof value.updatedAt !== "string")
74
+ return null;
75
+ const startup = await readGitWorktreeIdentity(header.cwd, false, options.signal);
76
+ const selected = await readGitWorktreeIdentity(value.worktreeRoot, true, options.signal);
77
+ if (!samePath(startup.root, value.startupRoot)
78
+ || !samePath(startup.commonDir, value.startupCommonDir)
79
+ || !samePath(selected.root, value.worktreeRoot)
80
+ || !samePath(selected.commonDir, value.worktreeCommonDir)
81
+ || !samePath(selected.gitDir, value.worktreeGitDir)
82
+ || !samePath(startup.commonDir, selected.commonDir))
83
+ return null;
84
+ return { cwd: selected.root, branch: requireBranch(selected) };
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ async function readSessionHeader(identity) {
91
+ if (!validSessionId(identity.sessionId) || !validPath(identity.sessionFile))
92
+ throw new Error("active session identity is unavailable");
93
+ const file = await realpath(resolve(identity.sessionFile));
94
+ const handle = await open(file, "r");
95
+ try {
96
+ const buffer = Buffer.alloc(MAX_HEADER_BYTES);
97
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, 0);
98
+ const newline = buffer.subarray(0, bytesRead).indexOf(0x0a);
99
+ if (newline < 0)
100
+ throw new Error("session header is unavailable");
101
+ const value = JSON.parse(buffer.subarray(0, newline).toString("utf8"));
102
+ if (!isRecord(value) || value.type !== "session" || value.id !== identity.sessionId || !validPath(value.cwd)) {
103
+ throw new Error("active session identity does not match its session file");
104
+ }
105
+ return { id: identity.sessionId, cwd: await realpath(resolve(value.cwd)), file };
106
+ }
107
+ finally {
108
+ await handle.close();
109
+ }
110
+ }
111
+ async function readGitWorktreeIdentity(path, requireWorktreeRoot, signal) {
112
+ if (!validPath(path))
113
+ throw new Error("associated worktree path is invalid");
114
+ const cwd = await realpath(resolve(path));
115
+ const { stdout } = await executeGit(["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir", "--git-dir"], cwd, MAX_RECORD_BYTES, signal);
116
+ const lines = stdout.split(/\r?\n/u).filter(Boolean);
117
+ if (lines.length !== 3)
118
+ throw new Error("associated worktree identity is invalid");
119
+ const [rootValue, commonValue, gitValue] = lines;
120
+ if (!rootValue || !commonValue || !gitValue)
121
+ throw new Error("associated worktree identity is incomplete");
122
+ const root = await realpath(rootValue);
123
+ if (requireWorktreeRoot && !samePath(root, cwd))
124
+ throw new Error("associated path must be a Git worktree root");
125
+ const commonDir = await realpath(commonValue);
126
+ const gitDir = await realpath(gitValue);
127
+ let branch = null;
128
+ if (requireWorktreeRoot) {
129
+ const branchResult = await executeGit(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd, 4_096, signal);
130
+ branch = branchResult.stdout.trim();
131
+ if (branch.length === 0 || branch.length > 256)
132
+ throw new Error("associated worktree has a detached or invalid branch");
133
+ }
134
+ return { root, commonDir, gitDir, branch };
135
+ }
136
+ async function executeGit(arguments_, cwd, maxBuffer, signal) {
137
+ const { execFile } = await import("node:child_process");
138
+ const execute = promisify(execFile);
139
+ return await execute("git", [...arguments_], {
140
+ cwd,
141
+ windowsHide: true,
142
+ timeout: GIT_TIMEOUT_MS,
143
+ maxBuffer,
144
+ encoding: "utf8",
145
+ signal,
146
+ });
147
+ }
148
+ function requireBranch(identity) {
149
+ if (identity.branch === null)
150
+ throw new Error("associated worktree branch is unavailable");
151
+ return identity.branch;
152
+ }
153
+ function recordPath(header, options) {
154
+ const dataDir = options.dataDir ?? resolveProductPaths().dataDir;
155
+ const digest = createHash("sha256").update(header.id).update("\0").update(pathKey(header.file)).digest("hex");
156
+ return join(dataDir, PRODUCT_IDENTITY.state.sessionRepositoryDirectory, `${digest}.json`);
157
+ }
158
+ function samePath(left, right) {
159
+ return pathKey(left) === pathKey(right);
160
+ }
161
+ function pathKey(value) {
162
+ const normalized = resolve(value);
163
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
164
+ }
165
+ function validSessionId(value) {
166
+ return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(value);
167
+ }
168
+ function validPath(value) {
169
+ return typeof value === "string" && value.length > 0 && value.length <= 32_768 && !value.includes("\0") && isAbsolute(value);
170
+ }
171
+ function isRecord(value) {
172
+ return typeof value === "object" && value !== null && !Array.isArray(value);
173
+ }
@@ -20,8 +20,8 @@ export { createRestartSeal, readRestartCertifiedRelease, releaseCertificationDoc
20
20
  export type { RestartSeal, RestartValidationEvent } from "./restart-certification.js";
21
21
  export { STABLE_RELEASE_SCHEMA, createStableReleaseEvidence, verifyStableRegistry } from "./stable-release.js";
22
22
  export type { StableRegistryState, StableRegistryVerificationOptions, StableReleaseEvidence, StableReleaseEvidenceInput } from "./stable-release.js";
23
- export { PRODUCT_PACKAGE, assertUpdatePerformanceBudget, createNpmProcessRunner, createUpdateLifecycleCoordinator, planUpdateOwnership, renderUpdateProgressBar, runSelfUpdate, } from "./update.js";
24
- export type { ProcessRequest, ProcessResult, SelfUpdateOptions, UpdateActivationPhase, UpdateChannel, UpdateFileMetadata, UpdateFileSystem, UpdateLifecycleCoordinator, UpdateMaterializationProgress, UpdateMeasuredPhase, UpdateOutput, UpdateOwnershipAction, UpdatePackageReplacementInput, UpdatePerformanceEvidence, UpdatePhaseTimingEvent, UpdateProcessRunner, UpdateTransactionJournal, } from "./update.js";
23
+ export { PACKAGE_UNLOCK_PATIENCE_MS, PRODUCT_PACKAGE, assertUpdatePerformanceBudget, createNpmProcessRunner, createUpdateLifecycleCoordinator, isPackageLockError, lockedPackageDiagnostic, planUpdateOwnership, renderUpdateProgressBar, runSelfUpdate, } from "./update.js";
24
+ export type { ProcessRequest, ProcessResult, SelfUpdateOptions, UpdateActivationPhase, UpdateChannel, UpdateFileMetadata, UpdateFileSystem, UpdateLifecycleCoordinator, UpdateMaterializationProgress, UpdateMeasuredPhase, UpdateOutput, UpdateOwnershipAction, UpdatePackageReplacementInput, UpdatePerformanceEvidence, UpdatePhaseTimingEvent, UpdateProcessRunner, UpdateTransactionJournal, UpdateUnlockPatience, } from "./update.js";
25
25
  export { UPDATE_ACTIVATION_CONTRACT, UPDATE_ACTIVATION_ENTRY, UPDATE_ACTIVATION_MANIFEST_FIELD, activateInstalledRelease, delegateActivation, readActivationContracts, runActivationEntry, } from "./update-activation.js";
26
26
  export type { UpdateActivationCallbacks, UpdateActivationEvent, UpdateActivationRequest } from "./update-activation.js";
27
27
  export { selectSupervisorLaunchReleaseId, selectUpdateLaunchRelease } from "./update-launch.js";
@@ -9,7 +9,7 @@ export { collectRelease, prepareReleaseCleanup, runBoundedReleaseCleanup, runRel
9
9
  export { RELEASE_MANIFEST_FILENAME, assertImmutableExecutionRoot, consumeMaterializationProof, materializeRelease, readCertifiedReleaseManifest, readMaterializedRelease, resolveReleaseEntryPoint, verifyMaterializedRelease, } from "./release-store.js";
10
10
  export { createRestartSeal, readRestartCertifiedRelease, releaseCertificationDocument, restartSealDigest } from "./restart-certification.js";
11
11
  export { STABLE_RELEASE_SCHEMA, createStableReleaseEvidence, verifyStableRegistry } from "./stable-release.js";
12
- export { PRODUCT_PACKAGE, assertUpdatePerformanceBudget, createNpmProcessRunner, createUpdateLifecycleCoordinator, planUpdateOwnership, renderUpdateProgressBar, runSelfUpdate, } from "./update.js";
12
+ export { PACKAGE_UNLOCK_PATIENCE_MS, PRODUCT_PACKAGE, assertUpdatePerformanceBudget, createNpmProcessRunner, createUpdateLifecycleCoordinator, isPackageLockError, lockedPackageDiagnostic, planUpdateOwnership, renderUpdateProgressBar, runSelfUpdate, } from "./update.js";
13
13
  export { UPDATE_ACTIVATION_CONTRACT, UPDATE_ACTIVATION_ENTRY, UPDATE_ACTIVATION_MANIFEST_FIELD, activateInstalledRelease, delegateActivation, readActivationContracts, runActivationEntry, } from "./update-activation.js";
14
14
  export { selectSupervisorLaunchReleaseId, selectUpdateLaunchRelease } from "./update-launch.js";
15
15
  export { UPDATE_RECOVERY_SCHEMA, cleanupUpdateRecoveryCapsules, inspectUpdateLauncherSet, npmPrefixForGlobalRoot, prepareUpdateRecoveryCapsule, readUpdateRecoveryCapsule, removeUpdateRecoveryCapsule, runProtectedPackageReplacement, updateLauncherPaths, updateNpmInstallArguments, } from "./update-recovery.js";
@@ -23,6 +23,16 @@ export interface UpdateFileSystem {
23
23
  realpath(path: string): Promise<string>;
24
24
  lstat?(path: string): Promise<UpdateFileMetadata>;
25
25
  access?(path: string): Promise<void>;
26
+ rename?(from: string, to: string): Promise<void>;
27
+ }
28
+ /**
29
+ * How long the update keeps checking that nothing holds the package tree, and the clock it
30
+ * checks with. Tests inject a clock; the product waits in real time.
31
+ */
32
+ export interface UpdateUnlockPatience {
33
+ readonly windowMs?: number;
34
+ readonly sleep?: (durationMs: number) => Promise<void>;
35
+ readonly now?: () => number;
26
36
  }
27
37
  export interface UpdateOutput {
28
38
  stdout(message: string): void;
@@ -103,6 +113,17 @@ export interface UpdatePackageReplacementInput {
103
113
  readonly environment: NodeJS.ProcessEnv;
104
114
  readonly onRecoveryState: (state: UpdateRecoveryState) => Promise<void>;
105
115
  }
116
+ /**
117
+ * The package tree is checked for holders by renaming it and renaming it back, because on
118
+ * Windows that is the one operation that fails while any file under it is open. A holder is
119
+ * usually brief: a scanner, an indexer, a file browser, or a session still shutting down.
120
+ * The update therefore keeps checking for this long before it calls the package locked.
121
+ */
122
+ export declare const PACKAGE_UNLOCK_PATIENCE_MS = 15000;
123
+ /** Whether a rename failure means something holds the tree, as opposed to the tree being wrong. */
124
+ export declare function isPackageLockError(error: unknown): boolean;
125
+ /** What the user reads when the package stayed held for the whole patience window. */
126
+ export declare function lockedPackageDiagnostic(packageRoot: string, waitedMs: number, attempts: number, error: unknown): string;
106
127
  /** Bound on the child diagnostics an update keeps: enough for npm's failure reason, never a log. */
107
128
  export declare const CHILD_DIAGNOSTIC_LIMIT = 8000;
108
129
  export declare function createNpmProcessRunner(platform?: NodeJS.Platform): UpdateProcessRunner;
@@ -114,7 +135,7 @@ export type UpdateOwnershipAction = "clean-dead-record" | "leave-running" | "end
114
135
  * because that installation is exactly what is about to be replaced under it.
115
136
  */
116
137
  export declare function planUpdateOwnership(ownership: "live-verified" | "dead", runsFromRetainedRelease: boolean): UpdateOwnershipAction;
117
- export declare function createUpdateLifecycleCoordinator(environment?: NodeJS.ProcessEnv, fileSystem?: UpdateFileSystem, output?: UpdateOutput): UpdateLifecycleCoordinator;
138
+ export declare function createUpdateLifecycleCoordinator(environment?: NodeJS.ProcessEnv, fileSystem?: UpdateFileSystem, output?: UpdateOutput, patience?: UpdateUnlockPatience): UpdateLifecycleCoordinator;
118
139
  /** Renders the terminal-only update meter; the visual preview shares this exact frame. */
119
140
  export declare function renderUpdateProgressBar(percent: number): string;
120
141
  export declare function runSelfUpdate(options: SelfUpdateOptions): Promise<number>;
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { access, lstat, readFile, realpath } from "node:fs/promises";
2
+ import { access, lstat, readFile, realpath, rename } from "node:fs/promises";
3
3
  import { connect } from "node:net";
4
4
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
5
  import crossSpawn from "cross-spawn";
@@ -23,7 +23,32 @@ const defaultFileSystem = {
23
23
  realpath,
24
24
  lstat,
25
25
  access,
26
+ rename,
26
27
  };
28
+ /**
29
+ * The package tree is checked for holders by renaming it and renaming it back, because on
30
+ * Windows that is the one operation that fails while any file under it is open. A holder is
31
+ * usually brief: a scanner, an indexer, a file browser, or a session still shutting down.
32
+ * The update therefore keeps checking for this long before it calls the package locked.
33
+ */
34
+ export const PACKAGE_UNLOCK_PATIENCE_MS = 15_000;
35
+ const PACKAGE_UNLOCK_INITIAL_DELAY_MS = 100;
36
+ const PACKAGE_UNLOCK_MAX_DELAY_MS = 1_000;
37
+ // Platform: Windows reports a held directory as EPERM or EBUSY; EACCES and ENOTEMPTY are the other
38
+ // spellings a rename gives to a tree something is using. Anything else is not a lock.
39
+ const PACKAGE_LOCK_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]);
40
+ const defaultSleep = async (durationMs) => await new Promise(resolvePromise => setTimeout(resolvePromise, durationMs));
41
+ /** Whether a rename failure means something holds the tree, as opposed to the tree being wrong. */
42
+ export function isPackageLockError(error) {
43
+ return error instanceof Error && "code" in error && typeof error.code === "string" && PACKAGE_LOCK_CODES.has(error.code);
44
+ }
45
+ /** What the user reads when the package stayed held for the whole patience window. */
46
+ export function lockedPackageDiagnostic(packageRoot, waitedMs, attempts, error) {
47
+ const seconds = Math.max(1, Math.round(waitedMs / 1_000));
48
+ return PRODUCT_TEXT.diagnostic(`package remained locked for ${seconds}s after verified shutdown (${attempts} checks): ${errorMessage(error)}. `
49
+ + `Another program is holding a file under ${packageRoot}: an antivirus scan, a file browser or terminal open inside it, `
50
+ + `or a session still shutting down. Close it and run the update again; nothing was changed.`);
51
+ }
27
52
  const defaultOutput = {
28
53
  stdout(message) { process.stdout.write(message); },
29
54
  stderr(message) { process.stderr.write(message); },
@@ -79,7 +104,7 @@ async function readActiveEndpoint(paths, activeReleaseId, environment) {
79
104
  const legacy = await readEndpointMetadata(legacyPaths.endpointMetadataPath);
80
105
  return legacy ? { metadata: legacy, paths: legacyPaths } : null;
81
106
  }
82
- export function createUpdateLifecycleCoordinator(environment = process.env, fileSystem = defaultFileSystem, output = defaultOutput) {
107
+ export function createUpdateLifecycleCoordinator(environment = process.env, fileSystem = defaultFileSystem, output = defaultOutput, patience = {}) {
83
108
  const paths = resolveProductPaths(environment);
84
109
  const stateStore = new CohortStateStore(paths.dataDir);
85
110
  return {
@@ -139,16 +164,40 @@ export function createUpdateLifecycleCoordinator(environment = process.env, file
139
164
  if (fileSystem.access)
140
165
  await fileSystem.access(packageRoot);
141
166
  const probe = `${packageRoot}.${PRODUCT_IDENTITY.filesystem.slug}-unlock-probe`;
142
- const { rename } = await import("node:fs/promises");
143
- try {
144
- await rename(packageRoot, probe);
145
- await rename(probe, packageRoot);
146
- }
147
- catch (error) {
148
- // Invariant: best-effort rollback runs if the first rename succeeded and the second did not.
149
- await rename(probe, packageRoot).catch(() => { });
150
- throw new Error(PRODUCT_TEXT.diagnostic(`package remains locked after verified shutdown: ${errorMessage(error)}`));
151
- }
167
+ const renameTree = fileSystem.rename ?? rename;
168
+ const windowMs = patience.windowMs ?? PACKAGE_UNLOCK_PATIENCE_MS;
169
+ const now = patience.now ?? Date.now;
170
+ const sleep = patience.sleep ?? defaultSleep;
171
+ const startedAt = now();
172
+ let delay = PACKAGE_UNLOCK_INITIAL_DELAY_MS;
173
+ let attempts = 0;
174
+ // Rationale: a holder found right after ownership was released is almost always transient,
175
+ // so one failed rename is a reason to look again, not to abandon an update that has not
176
+ // yet changed anything. The wait is bounded so a genuine lock still fails clearly.
177
+ const withPatience = async (from, to, describeFailure) => {
178
+ while (true) {
179
+ attempts += 1;
180
+ try {
181
+ await renameTree(from, to);
182
+ return;
183
+ }
184
+ catch (error) {
185
+ const elapsed = now() - startedAt;
186
+ if (!isPackageLockError(error) || elapsed >= windowMs)
187
+ throw describeFailure(error);
188
+ // Rationale: the last pause is cut to the window's edge so the wait ends when promised.
189
+ await sleep(Math.min(delay, windowMs - elapsed));
190
+ delay = Math.min(PACKAGE_UNLOCK_MAX_DELAY_MS, delay * 2);
191
+ }
192
+ }
193
+ };
194
+ await withPatience(packageRoot, probe, error => isPackageLockError(error)
195
+ ? new Error(lockedPackageDiagnostic(packageRoot, now() - startedAt, attempts, error))
196
+ : new Error(PRODUCT_TEXT.diagnostic(`could not verify that the package is unlocked: ${errorMessage(error)}`)));
197
+ // Invariant: the package tree is put back under its own name before this returns; a holder
198
+ // that appears while it wears the probe name is waited out the same way, and if it never
199
+ // lets go the diagnostic names where the tree is so the user can restore it.
200
+ await withPatience(probe, packageRoot, error => new Error(PRODUCT_TEXT.diagnostic(`could not restore the package after checking it was unlocked: ${errorMessage(error)}. The package is at ${probe}; rename it back to ${packageRoot} before running the update again.`)));
152
201
  },
153
202
  async activateInstalled(packageRoot, targetVersion, phase, onMaterializing, onWarmup) {
154
203
  const request = { packageRoot, dataDir: paths.dataDir, targetVersion, environment };
@@ -1,5 +1,5 @@
1
1
  import { Input, Key, getKeybindings, matchesKey, truncateToWidth, } from "@earendil-works/pi-tui";
2
- import { piTheme } from "./theme.js";
2
+ import { piTheme, renderPiModalShortcutHints } from "./theme.js";
3
3
  const MAX_VISIBLE_ROWS = 10;
4
4
  const MODELS_TITLE = "Models";
5
5
  function fullModelId(model) {
@@ -228,7 +228,7 @@ export class ModelsDialogComponent {
228
228
  push();
229
229
  if (this.#refreshStatus !== undefined)
230
230
  push(theme.fg(this.#refreshStatus.kind, ` ${this.#refreshStatus.message}`));
231
- push(theme.fg("dim", ` ${this.#hints().join(" · ")}`));
231
+ push(renderPiModalShortcutHints(this.#hints()));
232
232
  push(border);
233
233
  return lines;
234
234
  }
@@ -237,13 +237,13 @@ export class ModelsDialogComponent {
237
237
  const confirm = keyLabel("tui.select.confirm");
238
238
  const save = keyLabel("app.models.save");
239
239
  return [
240
- "type to search",
241
- "↑↓ navigate",
242
- ...(tab.length === 0 ? [] : [`${tab} filter`]),
243
- ...(confirm.length === 0 ? [] : [`${confirm} switch`]),
244
- "space scope",
245
- ...(save.length === 0 ? [] : [`${save} save`]),
246
- "esc close",
240
+ { action: "type to search" },
241
+ { key: "↑↓", action: "navigate" },
242
+ ...(tab.length === 0 ? [] : [{ key: tab, action: "filter" }]),
243
+ ...(confirm.length === 0 ? [] : [{ key: confirm, action: "switch" }]),
244
+ { key: "space", action: "scope" },
245
+ ...(save.length === 0 ? [] : [{ key: save, action: "save" }]),
246
+ { key: "esc", action: "close" },
247
247
  ];
248
248
  }
249
249
  #emptyText() {
@@ -16,14 +16,8 @@ export declare const PINNED_PI_BUILTIN_SLASH_COMMANDS: ({
16
16
  export declare const OWNED_BUILTIN_SLASH_COMMANDS: ({
17
17
  readonly name: "thinking";
18
18
  readonly description: "Set thinking level";
19
- readonly argumentHint: "<level>";
20
- } | {
21
- name: string;
22
- description: string;
23
- argumentHint?: never;
24
19
  } | {
25
20
  name: string;
26
21
  description: string;
27
- argumentHint: string;
28
22
  })[];
29
23
  export declare function createPiShellEditor(options: PiShellEditorOptions): PiShellEditorPort;
@@ -9,12 +9,14 @@ import { createTuiFacade, ensureTheme, isAutocompleteProvider, } from "./shell-s
9
9
  import { SKILLS_COMMAND_NAME, collapseSkillCommands, createSkillsTunnelProvider, } from "./skills-command.js";
10
10
  /** A selected autocomplete row: the accent primary column, then the aligned description. */
11
11
  const SELECTED_DESCRIBED_ROW = /^(→ .*?\S)(\s{2,}.*)$/u;
12
- const THINKING_SLASH_COMMAND = { name: "thinking", description: "Set thinking level", argumentHint: "<level>" };
12
+ const PINNED_THINKING_SLASH_COMMAND = { name: "thinking", description: "Set thinking level", argumentHint: "<level>" };
13
+ const OWNED_THINKING_SLASH_COMMAND = { name: "thinking", description: "Set thinking level" };
14
+ const OWNED_LOGIN_SLASH_COMMAND = { name: "login", description: "Configure provider authentication" };
13
15
  export const PINNED_PI_BUILTIN_SLASH_COMMANDS = [
14
16
  { name: "settings", description: "Open settings menu" },
15
17
  { name: "model", description: "Select model (opens selector UI)", argumentHint: "<provider/model>" },
16
18
  { name: "tree", description: "Navigate session tree (switch branches)" },
17
- THINKING_SLASH_COMMAND,
19
+ PINNED_THINKING_SLASH_COMMAND,
18
20
  { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
19
21
  { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
20
22
  { name: "import", description: "Import and resume a session from a JSONL file" },
@@ -36,9 +38,16 @@ export const PINNED_PI_BUILTIN_SLASH_COMMANDS = [
36
38
  { name: "quit", description: "Quit" },
37
39
  ];
38
40
  /** Bare A1's built-in catalog: `models` replaces the pinned pair and is followed by `thinking`. */
39
- export const OWNED_BUILTIN_SLASH_COMMANDS = PINNED_PI_BUILTIN_SLASH_COMMANDS.flatMap(command => command.name === "model"
40
- ? [{ name: "models", description: "Switch models and manage scoped model cycling" }, THINKING_SLASH_COMMAND]
41
- : command.name === "thinking" || command.name === "scoped-models" ? [] : [command]);
41
+ export const OWNED_BUILTIN_SLASH_COMMANDS = PINNED_PI_BUILTIN_SLASH_COMMANDS.flatMap(command => {
42
+ if (command.name === "model") {
43
+ return [{ name: "models", description: "Switch models and manage scoped model cycling" }, OWNED_THINKING_SLASH_COMMAND];
44
+ }
45
+ if (command.name === "thinking" || command.name === "scoped-models")
46
+ return [];
47
+ if (command.name === "login")
48
+ return [OWNED_LOGIN_SLASH_COMMAND];
49
+ return [command];
50
+ });
42
51
  export function createPiShellEditor(options) {
43
52
  ensureTheme();
44
53
  const tui = createTuiFacade(options);
@@ -132,9 +141,17 @@ export function createPiShellEditor(options) {
132
141
  const commands = collapsed.commands;
133
142
  const additions = new Map(commands.map(command => [command.name, command]));
134
143
  const builtInNames = new Set(builtInCatalog.map(command => command.name));
135
- const builtIns = builtInCatalog.map(command => autocompleteCommand(command, additions.get(command.name)));
136
- // Invariant: the engine's built-in additions only decorate this profile's catalog; they never surface as resources.
137
- const resources = commands.filter(command => command.source !== "builtin" && !builtInNames.has(command.name)).map(command => autocompleteCommand(command));
144
+ const builtIns = builtInCatalog.flatMap(command => [
145
+ autocompleteCommand(command, additions.get(command.name)),
146
+ // Rationale: the collapsed browser is a primary bare-A1 command, not a resource after quit.
147
+ ...(command.name === "settings" && collapsed.collapsedCommand !== undefined
148
+ ? [autocompleteCommand(collapsed.collapsedCommand)]
149
+ : []),
150
+ ]);
151
+ // Invariant: built-in additions and the promoted collapsed browser never reappear as resources.
152
+ const resources = commands
153
+ .filter(command => command.name !== collapsed.collapsedCommand?.name && command.source !== "builtin" && !builtInNames.has(command.name))
154
+ .map(command => autocompleteCommand(command));
138
155
  const combined = new CombinedAutocompleteProvider([...builtIns, ...resources], options.cwd ?? process.cwd());
139
156
  autocompleteProvider = tunnelSkills.length === 0 ? combined : createSkillsTunnelProvider(combined, tunnelSkills);
140
157
  editor.setAutocompleteProvider(autocompleteProvider);
@@ -229,10 +246,26 @@ export function createPiShellEditor(options) {
229
246
  if (editorUx !== undefined && matchesKey(data, "shift+tab")
230
247
  && !Object.values(keybindings.getEffectiveConfig()).some(keys => (Array.isArray(keys) ? keys : [keys]).some(key => key !== undefined && matchesKey(data, key))))
231
248
  return;
249
+ const hadDraft = editor.getText().length > 0;
232
250
  if (editorUx === undefined)
233
251
  editor.handleInput(data);
234
252
  else
235
253
  editorUx.handleInput(data);
254
+ if (editorUx === undefined || !hadDraft || editor.getText().length > 0)
255
+ return;
256
+ // Invariant: Pi's deletion paths can leave a visible or debounced autocomplete request owned by
257
+ // the removed draft. Reapplying empty text cancels both forms without a duplicate change notice.
258
+ const onChange = editor.onChange;
259
+ delete editor.onChange;
260
+ try {
261
+ editor.setText("");
262
+ }
263
+ finally {
264
+ if (onChange === undefined)
265
+ delete editor.onChange;
266
+ else
267
+ editor.onChange = onChange;
268
+ }
236
269
  },
237
270
  invalidate: () => editor.invalidate(),
238
271
  setFocused: focused => {
@@ -1,5 +1,7 @@
1
- import { ExtensionInputComponent, ExtensionSelectorComponent, getSelectListTheme, } from "../startup-public.js";
2
- import { ExtensionEditorComponent, } from "./upstream/components/extension-editor.js";
1
+ import { getSelectListTheme, } from "../startup-public.js";
2
+ import { ExtensionEditorComponent } from "./upstream/components/extension-editor.js";
3
+ import { ExtensionInputComponent } from "./upstream/components/extension-input.js";
4
+ import { ExtensionSelectorComponent } from "./upstream/components/extension-selector.js";
3
5
  import { setKeybindings, Text, } from "@earendil-works/pi-tui";
4
6
  import { KeybindingsManager, } from "./upstream/adjacent/core/keybindings.js";
5
7
  import { PINNED_PI_LAYOUT, applyPiTheme, applyPiThemeInstance, getAvailablePiThemes, loadPiTheme, piTheme, } from "./theme.js";
@@ -165,7 +165,7 @@ function liveWorkingText(view, workingOverride, progressPresentation) {
165
165
  const message = workingOverride ?? view.status.workingMessage ?? "Working";
166
166
  const progress = view.status.workingProgress;
167
167
  return workingOverride === undefined && progressPresentation === "custom-viewport" && typeof progress === "number"
168
- ? `${message} (${progress}%)`
168
+ ? `${message}(${progress}%)`
169
169
  : message;
170
170
  }
171
171
  function statusComponent(view, ui, outputPad, liveStatusText, placement) {
@@ -191,9 +191,11 @@ export function renderPiShellPackageUpdateNotice(packages, width) {
191
191
  function transcriptComponent(block, cwd, expanded, extensions, submittedPrompt, outputPad, hideThinkingBlock, mermaidRenderingMode, showImages, imageWidthCells, imageAssets, tui) {
192
192
  switch (block.kind) {
193
193
  case "user": {
194
- const skill = parseSkillBlock(block.text);
194
+ const visibleBlock = submittedPrompt === undefined || block.userPresentation === undefined ? block
195
+ : { ...block, text: block.userPresentation.visibleText };
196
+ const skill = parseSkillBlock(visibleBlock.text);
195
197
  if (!skill)
196
- return submittedPrompt ? createPiSubmittedPromptComponent(block, submittedPrompt) : new UserMessageComponent(block.text);
198
+ return submittedPrompt ? createPiSubmittedPromptComponent(visibleBlock, submittedPrompt) : new UserMessageComponent(visibleBlock.text);
197
199
  const invocation = new SkillInvocationMessageComponent(skill, getMarkdownTheme());
198
200
  invocation.setExpanded(expanded);
199
201
  if (!skill.userMessage)
@@ -11,6 +11,8 @@ export interface PiShellSkillSummary {
11
11
  }
12
12
  export interface PiShellCollapsedSkillCommands {
13
13
  readonly commands: readonly PiShellAutocompleteCommand[];
14
+ /** The synthetic command replacing the withheld entries, when skill commands were present. */
15
+ readonly collapsedCommand?: PiShellAutocompleteCommand;
14
16
  /** The skills withheld from the top-level menu, in the engine's discovery order. */
15
17
  readonly skills: readonly PiShellSkillSummary[];
16
18
  }
@@ -27,6 +27,15 @@ export function collapseSkillCommands(commands) {
27
27
  if (skills.length === 0)
28
28
  return { commands, skills };
29
29
  const collapsed = [];
30
+ const collapsedCommand = {
31
+ name: SKILLS_COMMAND_NAME,
32
+ description: SKILLS_COMMAND_DESCRIPTION,
33
+ argumentOptions: skills.map(skill => ({
34
+ id: skill.name,
35
+ label: skill.name,
36
+ ...(skill.description.length === 0 ? {} : { description: skill.description }),
37
+ })),
38
+ };
30
39
  let inserted = false;
31
40
  for (const command of commands) {
32
41
  if (!command.name.startsWith(SKILL_COMMAND_PREFIX)) {
@@ -36,17 +45,9 @@ export function collapseSkillCommands(commands) {
36
45
  if (inserted)
37
46
  continue;
38
47
  inserted = true;
39
- collapsed.push({
40
- name: SKILLS_COMMAND_NAME,
41
- description: SKILLS_COMMAND_DESCRIPTION,
42
- argumentOptions: skills.map(skill => ({
43
- id: skill.name,
44
- label: skill.name,
45
- ...(skill.description.length === 0 ? {} : { description: skill.description }),
46
- })),
47
- });
48
+ collapsed.push(collapsedCommand);
48
49
  }
49
- return { commands: collapsed, skills };
50
+ return { commands: collapsed, collapsedCommand, skills };
50
51
  }
51
52
  /** Case-insensitive substring match on the name or description; a leading `skill:`/`skills:` in the query is ignored for the name. */
52
53
  export function skillMatchesQuery(skill, query) {
@@ -1,6 +1,6 @@
1
- import { DynamicBorder, keyHint, rawKeyHint } from "../startup-public.js";
1
+ import { DynamicBorder } from "../startup-public.js";
2
2
  import { Container, getKeybindings, Input, Spacer, Text } from "@earendil-works/pi-tui";
3
- import { PINNED_PI_LAYOUT, piTheme } from "./theme.js";
3
+ import { PINNED_PI_LAYOUT, piTheme, renderPiModalShortcutHints } from "./theme.js";
4
4
  import { componentPort, ensureTheme, piShellTruncateToWidth, piShellVisibleWidth } from "./shell-shared-facade.js";
5
5
  import { SKILL_COMMAND_PREFIX, skillMatchesQuery } from "./skills-command.js";
6
6
  /** A single line clipped at the viewport width: no wrap, no ellipsis, padded to the full width. */
@@ -44,7 +44,12 @@ class SkillsSelectorComponent extends Container {
44
44
  this.addChild(new Spacer(1));
45
45
  this.addChild(this.#listContainer);
46
46
  this.addChild(new Spacer(1));
47
- this.addChild(new Text(` ${rawKeyHint("↑↓", "navigate")} ${keyHint("tui.select.confirm", "select")} ${keyHint("tui.select.cancel", "cancel")}`, 0, 0));
47
+ const bindings = getKeybindings();
48
+ this.addChild(new Text(renderPiModalShortcutHints([
49
+ { key: "↑↓", action: "navigate" },
50
+ { key: bindings.getKeys("tui.select.confirm").join("/"), action: "select" },
51
+ { key: bindings.getKeys("tui.select.cancel").join("/"), action: "cancel" },
52
+ ]), 0, 0));
48
53
  this.addChild(new DynamicBorder());
49
54
  this.#updateList();
50
55
  }
@@ -1 +1,5 @@
1
+ import { type SemanticShortcutHint } from "../../../contracts/presentation/index.js";
1
2
  export * from "./upstream/theme/theme.js";
3
+ export type PiModalShortcutHint = SemanticShortcutHint;
4
+ /** The common bare-A1 modal footer: quiet display-cased keys, muted names, and whitespace-only gaps. */
5
+ export declare function renderPiModalShortcutHints(entries: readonly PiModalShortcutHint[], indent?: number): string;
@@ -1 +1,11 @@
1
+ import { renderSemanticShortcutHints, } from "../../../contracts/presentation/index.js";
2
+ import { piTheme } from "./upstream/theme/theme.js";
1
3
  export * from "./upstream/theme/theme.js";
4
+ /** The common bare-A1 modal footer: quiet display-cased keys, muted names, and whitespace-only gaps. */
5
+ export function renderPiModalShortcutHints(entries, indent = 0) {
6
+ const theme = piTheme();
7
+ return renderSemanticShortcutHints(entries, {
8
+ key: text => theme.fg("dim", text),
9
+ action: text => theme.fg("muted", text),
10
+ }, indent);
11
+ }