@timurproko/a1 0.2.7-dev.1240 → 0.2.7-dev.1244

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 (54) hide show
  1. package/README.md +24 -2
  2. package/bin/cli.js +17 -0
  3. package/dist/app/session-shell/dock-chrome.js +1 -4
  4. package/dist/cli/capabilities.d.ts +2 -0
  5. package/dist/cli/capabilities.js +2 -1
  6. package/dist/cli/dispatch.d.ts +11 -0
  7. package/dist/cli/dispatch.js +22 -6
  8. package/dist/cli/package-messages.js +4 -3
  9. package/dist/cli/session-context.js +18 -1
  10. package/dist/cli/uninstall.d.ts +30 -0
  11. package/dist/cli/uninstall.js +117 -0
  12. package/dist/composition/release-update-observation.js +2 -0
  13. package/dist/composition/session-runtime-registry.d.ts +4 -7
  14. package/dist/composition/session-runtime-registry.js +51 -35
  15. package/dist/foundation/launch-guardian/exit-watcher.js +19 -10
  16. package/dist/foundation/lifecycle/native-claim-gate.d.ts +5 -0
  17. package/dist/foundation/lifecycle/native-claim-gate.js +41 -0
  18. package/dist/foundation/lifecycle/session-claim-lock.d.ts +13 -0
  19. package/dist/foundation/lifecycle/session-claim-lock.js +131 -0
  20. package/dist/foundation/lifecycle/session-repository-context.js +3 -104
  21. package/dist/foundation/release/automatic-update.d.ts +1 -1
  22. package/dist/foundation/release/automatic-update.js +1 -1
  23. package/dist/foundation/release/index.d.ts +1 -1
  24. package/dist/foundation/release/index.js +1 -1
  25. package/dist/foundation/release/update.js +15 -6
  26. package/dist/foundation/resident-tabs/boot-identity.d.ts +6 -0
  27. package/dist/foundation/resident-tabs/boot-identity.js +16 -0
  28. package/dist/foundation/resident-tabs/bounded-host-command.d.ts +2 -0
  29. package/dist/foundation/resident-tabs/bounded-host-command.js +26 -3
  30. package/dist/foundation/resident-tabs/continuity.js +11 -3
  31. package/dist/foundation/resident-tabs/runtime-control.js +2 -1
  32. package/dist/integrations/pi/components/shell-footer-status.js +2 -4
  33. package/dist/integrations/pi/components/shell-shared-facade.d.ts +0 -3
  34. package/dist/integrations/pi/components/shell-shared-facade.js +0 -8
  35. package/dist/integrations/pi/engine/workflow-support.js +17 -17
  36. package/dist/native/darwin-arm64/manifest.json +2 -2
  37. package/dist/native/darwin-arm64/process-guardian.node +0 -0
  38. package/dist/native/darwin-arm64/terminal-host.manifest.json +3 -3
  39. package/dist/native/darwin-arm64/terminal-host.node +0 -0
  40. package/dist/native/darwin-x64/manifest.json +2 -2
  41. package/dist/native/darwin-x64/process-guardian.node +0 -0
  42. package/dist/native/darwin-x64/terminal-host.manifest.json +3 -3
  43. package/dist/native/darwin-x64/terminal-host.node +0 -0
  44. package/dist/native/linux-x64/manifest.json +3 -3
  45. package/dist/native/linux-x64/process-guardian.node +0 -0
  46. package/dist/native/linux-x64/terminal-host.manifest.json +3 -3
  47. package/dist/native/linux-x64/terminal-host.node +0 -0
  48. package/dist/native/win32-x64/manifest.json +2 -2
  49. package/dist/native/win32-x64/process-guardian.node +0 -0
  50. package/dist/native/win32-x64/terminal-host.manifest.json +3 -3
  51. package/dist/native/win32-x64/terminal-host.node +0 -0
  52. package/docs/architecture/project-structure.md +2 -0
  53. package/docs/worktree-link-recovery.md +37 -0
  54. package/package.json +6 -3
@@ -0,0 +1,5 @@
1
+ export interface ClaimGateHandle {
2
+ release(): void;
3
+ }
4
+ /** No PID-file fallback: only the platform descriptor can prove upgraded-writer exclusion. */
5
+ export declare function tryAcquireClaimGate(path: string): ClaimGateHandle | null;
@@ -0,0 +1,41 @@
1
+ import { existsSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { PRODUCT_IDENTITY } from "../../product-identity.js";
6
+ let backend;
7
+ /** No PID-file fallback: only the platform descriptor can prove upgraded-writer exclusion. */
8
+ export function tryAcquireClaimGate(path) {
9
+ if (backend === undefined) {
10
+ const require = createRequire(import.meta.url);
11
+ // Security: resolve only this release's addon, never a binary supplied by tool cwd.
12
+ const own = resolve(fileURLToPath(new URL("../../../", import.meta.url)), "dist", "native", `${process.platform}-${process.arch}`, `${PRODUCT_IDENTITY.artifacts.terminalHostAddon}.node`);
13
+ const override = process.env[PRODUCT_IDENTITY.environment.developmentInstanceId]
14
+ ? process.env[PRODUCT_IDENTITY.environment.terminalHostPath] : undefined;
15
+ for (const candidate of override ? [override, own] : [own]) {
16
+ if (!existsSync(candidate))
17
+ continue;
18
+ try {
19
+ const loaded = require(candidate);
20
+ if (typeof loaded.SessionLock?.tryAcquireGate === "function") {
21
+ backend = loaded.SessionLock;
22
+ break;
23
+ }
24
+ }
25
+ catch { /* Compatibility: an unusable addon is unavailable, not contended. */ }
26
+ }
27
+ }
28
+ if (backend === undefined)
29
+ throw Object.assign(new Error("native worktree coordination is unavailable; repair or update this A1 installation and retry"), {
30
+ code: "worktree-coordination-unavailable",
31
+ });
32
+ try {
33
+ const handle = backend.tryAcquireGate(path);
34
+ return handle.acquired ? handle : null;
35
+ }
36
+ catch (cause) {
37
+ throw Object.assign(new Error("cannot access the worktree mutation gate; check A1 data-directory permissions and filesystem availability", { cause }), {
38
+ code: "worktree-coordination-io",
39
+ });
40
+ }
41
+ }
@@ -0,0 +1,13 @@
1
+ interface ClaimLockOptions {
2
+ readonly signal?: AbortSignal;
3
+ readonly lockTimeoutMs?: number;
4
+ }
5
+ /** Inventory is advisory and read-only, including when abandoned metadata needs recovery. */
6
+ export declare function claimMutationPending(directory: string): Promise<boolean>;
7
+ /**
8
+ * The persistent directory excludes legacy wx-file writers. Only native descriptor authority
9
+ * permits upgraded mutations; owner.json is disposable evidence, never a liveness oracle.
10
+ * Never remove the barrier or authority file: doing so would create a second lock universe.
11
+ */
12
+ export declare function withSessionClaimLock<T>(directory: string, options: ClaimLockOptions, operation: () => Promise<T>): Promise<T>;
13
+ export {};
@@ -0,0 +1,131 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, open, readdir, realpath, rename, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { tryAcquireClaimGate } from "./native-claim-gate.js";
6
+ const FILES = new Set(["owner.json", "owner.next", "recovery.json", "recovery.next"]);
7
+ /** Inventory is advisory and read-only, including when abandoned metadata needs recovery. */
8
+ export async function claimMutationPending(directory) {
9
+ try {
10
+ const barrier = join(directory, "mutation.lock");
11
+ const metadata = await lstat(barrier);
12
+ if (!metadata.isDirectory() || metadata.isSymbolicLink())
13
+ return true;
14
+ const files = await readdir(barrier);
15
+ return files.some(name => name !== "recovery.json");
16
+ }
17
+ catch (error) {
18
+ return !hasCode(error, "ENOENT");
19
+ }
20
+ }
21
+ /**
22
+ * The persistent directory excludes legacy wx-file writers. Only native descriptor authority
23
+ * permits upgraded mutations; owner.json is disposable evidence, never a liveness oracle.
24
+ * Never remove the barrier or authority file: doing so would create a second lock universe.
25
+ */
26
+ export async function withSessionClaimLock(directory, options, operation) {
27
+ options.signal?.throwIfAborted();
28
+ let canonical;
29
+ try {
30
+ await mkdir(directory, { recursive: true, mode: 0o700 });
31
+ canonical = await realpath(directory);
32
+ }
33
+ catch (cause) {
34
+ throw failure("worktree-coordination-io", "cannot access the A1 claim directory; check permissions and filesystem availability", cause);
35
+ }
36
+ const authority = join(canonical, "mutation.authority");
37
+ const barrier = join(canonical, "mutation.lock");
38
+ const deadline = Date.now() + (options.lockTimeoutMs ?? 2_000);
39
+ let gate;
40
+ while (gate === undefined) {
41
+ options.signal?.throwIfAborted();
42
+ const acquired = tryAcquireClaimGate(authority);
43
+ if (acquired !== null) {
44
+ gate = acquired;
45
+ break;
46
+ }
47
+ if (Date.now() >= deadline)
48
+ throw failure("worktree-claim-busy", "worktree mutation is temporarily busy; automatic runtime registration will retry, and linking can be retried");
49
+ await delay(Math.min(20, Math.max(1, deadline - Date.now())), undefined, { signal: options.signal });
50
+ }
51
+ let operationStarted = false;
52
+ try {
53
+ options.signal?.throwIfAborted();
54
+ try {
55
+ await mkdir(barrier, { mode: 0o700 });
56
+ }
57
+ catch (error) {
58
+ if (!hasCode(error, "EEXIST"))
59
+ throw error;
60
+ }
61
+ const metadata = await lstat(barrier);
62
+ if (metadata.isSymbolicLink() || (!metadata.isFile() && !metadata.isDirectory())) {
63
+ throw failure("worktree-coordination-boundary", "worktree mutation barrier is not a regular file or directory; retain it for inspection");
64
+ }
65
+ if (metadata.isFile()) {
66
+ // Security: a new OS gate proves nothing about an older writer that does not participate in it.
67
+ // Even a dead legacy PID does not exclude a competing legacy reclaimer's rename race.
68
+ throw failure("worktree-legacy-recovery-required", "legacy worktree lock prevents safe recovery; stop all older A1 processes, back up only session-worktree-claims/mutation.lock while stopped, then start the updated A1; do not delete sessions or worktree claims");
69
+ }
70
+ for (const name of await readdir(barrier)) {
71
+ const entry = await lstat(join(barrier, name));
72
+ if (!FILES.has(name) || !entry.isFile() || entry.isSymbolicLink()) {
73
+ throw failure("worktree-coordination-boundary", "unexpected worktree coordination entry; retain it for inspection rather than deleting the claim store");
74
+ }
75
+ }
76
+ const owner = join(barrier, "owner.json");
77
+ const pending = join(barrier, "owner.next");
78
+ if (await exists(owner) || await exists(pending)) {
79
+ // Security: keep one bounded summary, not an accumulating dump of potentially sensitive corrupt bytes.
80
+ await publish(barrier, "recovery", { reason: "abandoned-mutation-metadata", recoveredAt: new Date().toISOString() });
81
+ await rm(owner, { force: true });
82
+ await rm(pending, { force: true });
83
+ }
84
+ await publish(barrier, "owner", { generation: randomUUID(), acquiredAt: new Date().toISOString() });
85
+ try {
86
+ options.signal?.throwIfAborted();
87
+ operationStarted = true;
88
+ return await operation();
89
+ }
90
+ finally {
91
+ // Concurrency: authority is still held; no successor can publish until after this removal completes.
92
+ await rm(owner, { force: true });
93
+ }
94
+ }
95
+ catch (error) {
96
+ if (operationStarted)
97
+ throw error;
98
+ if (error instanceof Error && (error.name === "AbortError" || ("code" in error && String(error.code).startsWith("worktree-"))))
99
+ throw error;
100
+ throw failure("worktree-coordination-io", "worktree coordination I/O failed; check A1 data-directory permissions and filesystem availability, then retry", error);
101
+ }
102
+ finally {
103
+ gate.release();
104
+ }
105
+ }
106
+ async function publish(directory, name, value) {
107
+ const pending = join(directory, `${name}.next`);
108
+ await rm(pending, { force: true });
109
+ const handle = await open(pending, "wx", 0o600);
110
+ try {
111
+ await handle.writeFile(`${JSON.stringify(value)}\n`);
112
+ await handle.sync();
113
+ }
114
+ finally {
115
+ await handle.close();
116
+ }
117
+ await rename(pending, join(directory, `${name}.json`));
118
+ }
119
+ async function exists(path) {
120
+ try {
121
+ await lstat(path);
122
+ return true;
123
+ }
124
+ catch (error) {
125
+ if (hasCode(error, "ENOENT"))
126
+ return false;
127
+ throw error;
128
+ }
129
+ }
130
+ function hasCode(error, code) { return error instanceof Error && "code" in error && error.code === code; }
131
+ function failure(code, message, cause) { return Object.assign(new Error(message, { cause }), { code }); }
@@ -4,6 +4,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { PRODUCT_IDENTITY } from "../../product-identity.js";
6
6
  import { resolveProductPaths } from "./paths.js";
7
+ import { claimMutationPending, withSessionClaimLock } from "./session-claim-lock.js";
7
8
  const CONTEXT_SCHEMA = "a1-session-repository-context-v1";
8
9
  const RUNTIME_SCHEMA = "a1-session-runtime-v1";
9
10
  const CLAIM_SCHEMA = "a1-session-worktree-claim-v1";
@@ -11,8 +12,6 @@ const MAX_RECORD_BYTES = 16 * 1024;
11
12
  const MAX_HEADER_BYTES = 64 * 1024;
12
13
  const MAX_WORKTREES = 256;
13
14
  const GIT_TIMEOUT_MS = 5_000;
14
- const LOCK_TIMEOUT_MS = 2_000;
15
- const LOCK_RETRY_MS = 20;
16
15
  /** Records without an agent id were written by a single-agent release and belong to its one agent. */
17
16
  export const PRIMARY_SESSION_AGENT_ID = "primary";
18
17
  const RECORD_FORMAT_VERSION = 2;
@@ -444,104 +443,10 @@ async function executeGit(arguments_, cwd, maxBuffer, signal) {
444
443
  return await execute("git", [...arguments_], { cwd, windowsHide: true, timeout: GIT_TIMEOUT_MS, maxBuffer, encoding: "utf8", signal });
445
444
  }
446
445
  async function mutationLockExists(options) {
447
- try {
448
- await lstat(join(claimStoreRoot(options), "mutation.lock"));
449
- return true;
450
- }
451
- catch (error) {
452
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
453
- return false;
454
- return true;
455
- }
446
+ return await claimMutationPending(claimStoreRoot(options));
456
447
  }
457
448
  async function withClaimLock(options, operation) {
458
- const directory = claimStoreRoot(options);
459
- await mkdir(directory, { recursive: true, mode: 0o700 });
460
- const path = join(directory, "mutation.lock");
461
- const deadline = Date.now() + (options.lockTimeoutMs ?? LOCK_TIMEOUT_MS);
462
- const holder = await lockHolderIdentity(options);
463
- let handle;
464
- for (let attempt = 0; handle === undefined; attempt++) {
465
- try {
466
- handle = await open(path, "wx", 0o600);
467
- }
468
- catch (error) {
469
- if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
470
- throw error;
471
- if (attempt === 0 && await evictDeadLockHolder(path, options))
472
- continue;
473
- if (Date.now() >= deadline)
474
- throw contextError("worktree claim mutation is busy", "worktree-claim-busy");
475
- await new Promise(resolvePromise => setTimeout(resolvePromise, LOCK_RETRY_MS));
476
- }
477
- }
478
- try {
479
- const record = {
480
- pid: process.pid,
481
- ...(holder === null ? {} : { startIdentity: holder.startIdentity }),
482
- acquiredAt: new Date().toISOString(),
483
- nonce: randomUUID(),
484
- };
485
- await handle.writeFile(`${JSON.stringify(record)}\n`);
486
- return await operation();
487
- }
488
- finally {
489
- await handle.close().catch(() => undefined);
490
- await rm(path, { force: true }).catch(() => undefined);
491
- }
492
- }
493
- const lockHolderIdentities = new WeakMap();
494
- /** This process's identity as recorded in the lock; null leaves the lock unevictable, never wrongly evictable. */
495
- async function lockHolderIdentity(options) {
496
- if (options.runtimeIdentity?.pid === process.pid)
497
- return options.runtimeIdentity;
498
- const inspection = options.inspectProcess;
499
- if (inspection === undefined)
500
- return null;
501
- let identity = lockHolderIdentities.get(inspection);
502
- if (identity === undefined) {
503
- identity = inspection(process.pid)
504
- .then(observed => observed !== null && observed.pid === process.pid ? observed : null)
505
- .catch(() => null);
506
- lockHolderIdentities.set(inspection, identity);
507
- }
508
- return await identity;
509
- }
510
- /**
511
- * Evict the mutation lock only when its recorded holder is proven dead: the pid is gone or now names a process
512
- * with another start identity. A lock without a holder identity, or a holder that cannot be inspected, is kept.
513
- */
514
- async function evictDeadLockHolder(path, options) {
515
- const inspection = options.inspectProcess;
516
- if (inspection === undefined)
517
- return false;
518
- const holder = await readJsonRecord(path, isMutationLockRecord);
519
- if (holder === null || holder.startIdentity === undefined || holder.pid === process.pid)
520
- return false;
521
- let observed;
522
- try {
523
- observed = await inspection(holder.pid);
524
- }
525
- catch {
526
- return false;
527
- }
528
- if (observed !== null && observed.pid === holder.pid && observed.startIdentity === holder.startIdentity)
529
- return false;
530
- const evicted = `${path}.${randomUUID()}.evicted`;
531
- try {
532
- await rename(path, evicted);
533
- }
534
- catch {
535
- return false;
536
- }
537
- const moved = await readJsonRecord(evicted, isMutationLockRecord);
538
- if (moved?.nonce !== holder.nonce) {
539
- // Concurrency: another waiter evicted first and a live holder took the lock since; hand it back untouched.
540
- await rename(evicted, path).catch(() => undefined);
541
- return false;
542
- }
543
- await rm(evicted, { force: true });
544
- return true;
449
+ return await withSessionClaimLock(claimStoreRoot(options), options, operation);
545
450
  }
546
451
  async function atomicWrite(path, value) {
547
452
  const directory = dirname(path);
@@ -668,12 +573,6 @@ function isStoredClaimRecord(value) {
668
573
  && typeof value.startIdentity === "string" && value.startIdentity.length > 0 && value.startIdentity.length <= 512
669
574
  && !value.startIdentity.includes("\0") && typeof value.updatedAt === "string";
670
575
  }
671
- function isMutationLockRecord(value) {
672
- return isRecord(value) && Number.isSafeInteger(value.pid) && Number(value.pid) > 0
673
- && (value.startIdentity === undefined || (typeof value.startIdentity === "string" && value.startIdentity.length > 0
674
- && value.startIdentity.length <= 512 && !value.startIdentity.includes("\0")))
675
- && typeof value.acquiredAt === "string" && typeof value.nonce === "string" && value.nonce.length > 0;
676
- }
677
576
  function validRecordVersion(value) {
678
577
  if (value.formatVersion === undefined)
679
578
  return value.agentId === undefined;
@@ -1,7 +1,7 @@
1
1
  import { type AvailableRelease, type ReleaseChannel } from "./latest-release.js";
2
2
  export declare const AUTOMATIC_UPDATE_STATE_SCHEMA = 1;
3
3
  export declare const AUTOMATIC_UPDATE_RETRY_MS: number;
4
- export declare const AUTOMATIC_UPDATE_ACTIVATION_RETRY_MS: number;
4
+ export declare const AUTOMATIC_UPDATE_ACTIVATION_RETRY_MS = 15000;
5
5
  export declare const ACTIVE_RELEASE_FALLBACK_MS = 5000;
6
6
  export interface AutomaticUpdateState {
7
7
  readonly schema: typeof AUTOMATIC_UPDATE_STATE_SCHEMA;
@@ -15,7 +15,7 @@ import { UpdateTransactionStore } from "./update-transaction.js";
15
15
  import { runSelfUpdate, UPDATE_ACTIVATION_BLOCKED_EXIT_CODE, UPDATE_ALREADY_RUNNING_EXIT_CODE } from "./update.js";
16
16
  export const AUTOMATIC_UPDATE_STATE_SCHEMA = 1;
17
17
  export const AUTOMATIC_UPDATE_RETRY_MS = 24 * 60 * 60_000;
18
- export const AUTOMATIC_UPDATE_ACTIVATION_RETRY_MS = 15 * 60_000;
18
+ export const AUTOMATIC_UPDATE_ACTIVATION_RETRY_MS = 15_000;
19
19
  const AUTOMATIC_UPDATE_STATE_FILENAME = "automatic-update.json";
20
20
  const MAX_STATE_BYTES = 16_384;
21
21
  const OBSERVER_POLL_MS = 250;
@@ -26,7 +26,7 @@ export type { ProcessRequest, ProcessResult, SelfUpdateOptions, UpdateActivation
26
26
  export { UPDATE_ACTIVATION_CONTRACT, UPDATE_ACTIVATION_ENTRY, UPDATE_ACTIVATION_MANIFEST_FIELD, UpdateActivationBlockedError, activateInstalledRelease, delegateActivation, readActivationContracts, runActivationEntry, } from "./update-activation.js";
27
27
  export type { UpdateActivationCallbacks, UpdateActivationEvent, UpdateActivationRequest } from "./update-activation.js";
28
28
  export { selectSupervisorLaunchReleaseId, selectUpdateLaunchRelease } from "./update-launch.js";
29
- export { UPDATE_RECOVERY_SCHEMA, cleanupUpdateRecoveryCapsules, inspectUpdateLauncherSet, npmPrefixForGlobalRoot, prepareUpdateRecoveryCapsule, readUpdateRecoveryCapsule, removeUpdateRecoveryCapsule, runProtectedPackageReplacement, updateLauncherPaths, updateNpmInstallArguments, } from "./update-recovery.js";
29
+ export { UPDATE_RECOVERY_SCHEMA, cleanupUpdateRecoveryCapsules, inspectUpdateLauncherSet, npmPrefixForGlobalRoot, prepareUpdateRecoveryCapsule, readUpdateRecoveryCapsule, removeUpdateRecoveryCapsule, resolveNpmCli, runProtectedPackageReplacement, updateLauncherPaths, updateNpmInstallArguments, } from "./update-recovery.js";
30
30
  export type { ProtectedPackageReplacementOptions, ProtectedPackageReplacementResult, UpdateRecoveryCapsule, UpdateRecoveryResult } from "./update-recovery.js";
31
31
  export { UPDATE_JOURNAL_SCHEMA, UpdateTransactionStore } from "./update-transaction.js";
32
32
  export type { UpdateRecoveryState, UpdateTransaction, UpdateTransactionPhase } from "./update-transaction.js";
@@ -13,6 +13,6 @@ export { STABLE_RELEASE_SCHEMA, createStableReleaseEvidence, verifyStableRegistr
13
13
  export { PACKAGE_UNLOCK_PATIENCE_MS, UPDATE_ACTIVATION_BLOCKED_EXIT_CODE, UPDATE_ALREADY_RUNNING_EXIT_CODE, PRODUCT_PACKAGE, assertUpdatePerformanceBudget, createNpmProcessRunner, createUpdateLifecycleCoordinator, isPackageLockError, lockedPackageDiagnostic, planUpdateOwnership, renderUpdateProgressBar, runSelfUpdate, } from "./update.js";
14
14
  export { UPDATE_ACTIVATION_CONTRACT, UPDATE_ACTIVATION_ENTRY, UPDATE_ACTIVATION_MANIFEST_FIELD, UpdateActivationBlockedError, activateInstalledRelease, delegateActivation, readActivationContracts, runActivationEntry, } from "./update-activation.js";
15
15
  export { selectSupervisorLaunchReleaseId, selectUpdateLaunchRelease } from "./update-launch.js";
16
- export { UPDATE_RECOVERY_SCHEMA, cleanupUpdateRecoveryCapsules, inspectUpdateLauncherSet, npmPrefixForGlobalRoot, prepareUpdateRecoveryCapsule, readUpdateRecoveryCapsule, removeUpdateRecoveryCapsule, runProtectedPackageReplacement, updateLauncherPaths, updateNpmInstallArguments, } from "./update-recovery.js";
16
+ export { UPDATE_RECOVERY_SCHEMA, cleanupUpdateRecoveryCapsules, inspectUpdateLauncherSet, npmPrefixForGlobalRoot, prepareUpdateRecoveryCapsule, readUpdateRecoveryCapsule, removeUpdateRecoveryCapsule, resolveNpmCli, runProtectedPackageReplacement, updateLauncherPaths, updateNpmInstallArguments, } from "./update-recovery.js";
17
17
  export { UPDATE_JOURNAL_SCHEMA, UpdateTransactionStore } from "./update-transaction.js";
18
18
  export { warmMaterializedRelease } from "./warmup.js";
@@ -757,7 +757,7 @@ export async function runSelfUpdate(options) {
757
757
  const message = errorMessage(error);
758
758
  if (error instanceof UpdateAutomaticDeferred)
759
759
  return 1;
760
- const blockedExit = await preparedActivationExit(error, transaction, mode, output, transactionStore);
760
+ const blockedExit = await preparedActivationExit(error, transaction, mode, output, transactionStore, targetVersion, paths.configDir);
761
761
  if (blockedExit !== null)
762
762
  return blockedExit;
763
763
  if (error instanceof UpdatePreflightFailure) {
@@ -769,7 +769,7 @@ export async function runSelfUpdate(options) {
769
769
  : await rollbackPriorCohort(paths.dataDir, environment, transaction?.priorActiveReleaseId ?? null).catch(rollbackError => `rollback failed: ${errorMessage(rollbackError)}`);
770
770
  if (transaction)
771
771
  await transactionStore.finish(rollback === "rolled back" ? "rolled-back" : "failed", `${message}; ${rollback}`);
772
- output.stderr(`${PRODUCT_TEXT.diagnostic(`update failed: ${message}. ${rollback}. Diagnostics: ${transactionStore.path}`)}\n`);
772
+ output.stderr(updateFailureLine(message, rollback, transactionStore.path));
773
773
  return error instanceof UpdateFailure ? error.exitCode : 1;
774
774
  }
775
775
  });
@@ -869,13 +869,22 @@ async function activateUpdateTransaction(lifecycle, transaction, packageRoot, ta
869
869
  else
870
870
  await lifecycle.activateInstalled(packageRoot, targetVersion, phase, materializing, warmup);
871
871
  }
872
- async function preparedActivationExit(error, transaction, mode, output, journal) {
872
+ async function preparedActivationExit(error, transaction, mode, output, journal, targetVersion, configDir) {
873
873
  if (!(error instanceof UpdateActivationBlockedError) || transaction === null || phaseBefore(transaction.phase, "certified"))
874
874
  return null;
875
- if (mode === "manual")
876
- output.stderr(`${PRODUCT_TEXT.diagnostic(`update prepared, but activation is blocked: ${error.message}. The prior client remains selected; run the update again after the resident blocker clears.`)}\n`);
877
875
  await journal.recordActiveError?.(error.message);
878
- return UPDATE_ACTIVATION_BLOCKED_EXIT_CODE;
876
+ if (mode === "automatic")
877
+ return UPDATE_ACTIVATION_BLOCKED_EXIT_CODE;
878
+ // Invariant: a certified candidate waiting only for resident continuity is an installed update;
879
+ // the next startup check retries activation without another installation.
880
+ await invalidateReleaseCheckCache(configDir).catch(() => undefined);
881
+ output.stdout(`${PRODUCT_TEXT.commandName} updated to ${targetVersion}; it takes over automatically once running Agents can switch\n`);
882
+ return 0;
883
+ }
884
+ /** One reason and one next step; routine rollback detail stays in the journal. */
885
+ function updateFailureLine(message, rollback, journalPath) {
886
+ const note = rollback.startsWith("rollback failed") ? ` (${rollback})` : "";
887
+ return `${PRODUCT_TEXT.diagnostic(`update failed: ${message}${note}. Run ${PRODUCT_TEXT.commandName} update to retry. Details: ${journalPath}`)}\n`;
879
888
  }
880
889
  function phaseBefore(current, target) {
881
890
  const phases = ["shutdown-intent", "ownership-released", "package-installed", "materialized", "certified", "active-reference-committed", "supervisor-verified"];
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Mirrors the native boot matching rule. Legacy `macos-boot:<sec>:<usec>` records drift on clock
3
+ * corrections, so two of them match within the tolerance; only the native host can prove one
4
+ * against a current identity.
5
+ */
6
+ export declare function sameBootIdentity(left: string, right: string): boolean;
@@ -0,0 +1,16 @@
1
+ /** Seconds a legacy clock-derived boot identity may drift and still name the same boot. */
2
+ const LEGACY_BOOT_TOLERANCE_SECONDS = 2;
3
+ const LEGACY_MACOS_BOOT = /^macos-boot:(\d+):\d+$/u;
4
+ /**
5
+ * Mirrors the native boot matching rule. Legacy `macos-boot:<sec>:<usec>` records drift on clock
6
+ * corrections, so two of them match within the tolerance; only the native host can prove one
7
+ * against a current identity.
8
+ */
9
+ export function sameBootIdentity(left, right) {
10
+ if (left === right)
11
+ return true;
12
+ const first = LEGACY_MACOS_BOOT.exec(left)?.[1];
13
+ const second = LEGACY_MACOS_BOOT.exec(right)?.[1];
14
+ return first !== undefined && second !== undefined
15
+ && Math.abs(Number(first) - Number(second)) <= LEGACY_BOOT_TOLERANCE_SECONDS;
16
+ }
@@ -2,6 +2,8 @@ import type { TerminalHostCommand } from "./ensure-terminal-host.js";
2
2
  export interface BoundedHostCommandResult {
3
3
  readonly code: number;
4
4
  readonly stdout: string;
5
+ /** The command's last diagnostic line, bounded and stripped of control characters; never parsed. */
6
+ readonly stderr: string;
5
7
  }
6
8
  export interface BoundedHostCommandLimits {
7
9
  /** Output beyond this many bytes fails the exchange instead of being parsed. */
@@ -1,4 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
+ /** Diagnostics beyond this many bytes are dropped rather than buffered. */
3
+ const MAX_STDERR_BYTES = 4096;
2
4
  /**
3
5
  * Runs one terminal-host command whose report is small. An oversized report kills the command and
4
6
  * reports a failed exchange; a command that outlives its deadline is killed and rejected.
@@ -8,9 +10,18 @@ export async function runBoundedHostCommand(command, arguments_, limits) {
8
10
  const child = spawn(command.executable, [command.entry, ...arguments_], {
9
11
  env: limits.environment,
10
12
  windowsHide: true,
11
- stdio: ["ignore", "pipe", "ignore"],
13
+ stdio: ["ignore", "pipe", "pipe"],
12
14
  });
13
15
  const stdout = [];
16
+ const stderr = [];
17
+ let stderrSize = 0;
18
+ child.stderr.on("data", (chunk) => {
19
+ if (stderrSize >= MAX_STDERR_BYTES)
20
+ return;
21
+ const kept = chunk.subarray(0, MAX_STDERR_BYTES - stderrSize);
22
+ stderrSize += kept.length;
23
+ stderr.push(Buffer.from(kept));
24
+ });
14
25
  let size = 0;
15
26
  let overflowed = false;
16
27
  child.stdout.on("data", chunk => {
@@ -26,9 +37,10 @@ export async function runBoundedHostCommand(command, arguments_, limits) {
26
37
  child.once("error", rejectPromise);
27
38
  child.once("close", code => {
28
39
  clearTimeout(timer);
40
+ const diagnostic = lastDiagnosticLine(Buffer.concat(stderr).toString("utf8"));
29
41
  resolvePromise(overflowed
30
- ? { code: code === 0 || code === null ? 1 : code, stdout: "" }
31
- : { code: code ?? 1, stdout: Buffer.concat(stdout).toString("utf8") });
42
+ ? { code: code === 0 || code === null ? 1 : code, stdout: "", stderr: diagnostic }
43
+ : { code: code ?? 1, stdout: Buffer.concat(stdout).toString("utf8"), stderr: diagnostic });
32
44
  });
33
45
  const timer = setTimeout(() => {
34
46
  child.kill("SIGKILL");
@@ -37,3 +49,14 @@ export async function runBoundedHostCommand(command, arguments_, limits) {
37
49
  timer.unref();
38
50
  });
39
51
  }
52
+ /** Keeps one terminal-safe line: the native host reports its reason last. */
53
+ function lastDiagnosticLine(text) {
54
+ const lines = text.split(/\r?\n/u)
55
+ // Security: diagnostics never carry terminal controls into a later message.
56
+ .map(line => line
57
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, "")
58
+ .replace(/[\u0000-\u001f\u007f-\u009f]/gu, "")
59
+ .trim())
60
+ .filter(line => line.length > 0);
61
+ return (lines.at(-1) ?? "").slice(0, 300);
62
+ }
@@ -10,7 +10,7 @@ export async function assertResidentContinuity(options) {
10
10
  const first = await inspect(context);
11
11
  const second = await inspect(context);
12
12
  if (first !== second && second !== await inspect(context)) {
13
- throw new Error("Resident workspace changed during update readiness; the prior client remains selected.");
13
+ throw new Error("Resident workspace changed during update readiness");
14
14
  }
15
15
  }
16
16
  /** Native inspection/import is read-only toward execution and never starts or stops a source. */
@@ -40,16 +40,24 @@ export async function inspectResidentContinuity(options) {
40
40
  "workspace-check", "--profile", options.profileRoot,
41
41
  "--data-dir", options.dataDir, "--runtime-dir", options.runtimeDir,
42
42
  ], { label: "resident continuity check", maxStdoutBytes: 4096, timeoutMs: 8_000, environment: options.environment });
43
+ const unverified = new Error(continuityFailure(result.stderr));
43
44
  let report;
44
45
  try {
45
46
  report = JSON.parse(result.stdout.trim());
46
47
  }
47
48
  catch {
48
- throw new Error("Resident control compatibility could not be verified; the prior client remains selected.");
49
+ throw unverified;
49
50
  }
50
51
  if (result.code !== 0 || report.schema !== "resident-workspace-check-v1" || report.compatible !== true
51
52
  || typeof report.fingerprint !== "string" || !/^[a-f0-9]{64}$/u.test(report.fingerprint)) {
52
- throw new Error("Resident control compatibility could not be verified; the prior client remains selected.");
53
+ throw unverified;
53
54
  }
54
55
  return report.fingerprint;
55
56
  }
57
+ /** Names the native reason when the host reported one, without the host's own prefix. */
58
+ function continuityFailure(diagnostic) {
59
+ const reason = diagnostic.replace(/^a1-terminal-host:\s*/u, "").replace(/[.\s]+$/u, "");
60
+ return reason.length > 0
61
+ ? `Resident control compatibility could not be verified: ${reason}`
62
+ : "Resident control compatibility could not be verified";
63
+ }
@@ -1,3 +1,4 @@
1
+ import { sameBootIdentity } from "./boot-identity.js";
1
2
  import { runTerminalHostMaintenance } from "./maintenance.js";
2
3
  /** Mandatory native control semantics; optional methods never establish source authority. */
3
4
  export const RUNTIME_CONTROL_API = "resident-control-v1";
@@ -21,7 +22,7 @@ export function assessRuntimeControl(value, expected) {
21
22
  return blocked("invalid-contract");
22
23
  }
23
24
  if (value.releaseId !== expected.releaseId || value.scope !== expected.scope
24
- || value.bootId !== expected.bootId || value.osSessionId !== expected.osSessionId)
25
+ || !sameBootIdentity(value.bootId, expected.bootId) || value.osSessionId !== expected.osSessionId)
25
26
  return blocked("source-mismatch");
26
27
  if (value.epoch !== expected.epoch)
27
28
  return blocked("stale-epoch");
@@ -7,7 +7,7 @@ import { KeybindingsManager } from "./upstream/adjacent/core/keybindings.js";
7
7
  import { StatusIndicator, WorkingStatusIndicator } from "./upstream/components/status-indicator.js";
8
8
  import { protectPiPromptChipPresentation } from "./prompt-chip-presentation.js";
9
9
  import { PINNED_PI_LAYOUT, piTheme, } from "./theme.js";
10
- import { createTuiFacade, ensureTheme, renderPiShellBlockedUpdateStatus, renderPiShellInstalledUpdateStatus, piShellVisibleWidth, } from "./shell-shared-facade.js";
10
+ import { createTuiFacade, ensureTheme, renderPiShellInstalledUpdateStatus, piShellVisibleWidth, } from "./shell-shared-facade.js";
11
11
  export function createPiShellHeader(options = {}) {
12
12
  ensureTheme();
13
13
  let expanded = options.expanded ?? false;
@@ -233,9 +233,7 @@ function appendInstalledUpdateStatus(rows, width, view, progressPresentation) {
233
233
  return rows;
234
234
  const status = view.diagnostics.some(diagnostic => diagnostic.code === "release-update-installed")
235
235
  ? renderPiShellInstalledUpdateStatus(width)
236
- : view.diagnostics.some(diagnostic => diagnostic.code === "release-update-blocked")
237
- ? renderPiShellBlockedUpdateStatus(width)
238
- : "";
236
+ : "";
239
237
  if (status.length === 0)
240
238
  return rows;
241
239
  const statusWidth = piShellVisibleWidth(status);
@@ -9,11 +9,8 @@ import { KeybindingsManager } from "./upstream/adjacent/core/keybindings.js";
9
9
  export declare const piShellVisibleWidth: typeof visibleWidth;
10
10
  export declare const piShellTruncateToWidth: (text: string, width: number) => string;
11
11
  export declare const INSTALLED_UPDATE_STATUS_TEXT = "Update installed, restart to update";
12
- export declare const BLOCKED_UPDATE_STATUS_TEXT = "Update prepared; activation blocked";
13
12
  /** Shared green restart notice for the pinned status and bare-A1 prompt overlay. */
14
13
  export declare function renderPiShellInstalledUpdateStatus(width: number): string;
15
- /** Sanitized warning for a certified candidate that cannot yet preserve resident continuity. */
16
- export declare function renderPiShellBlockedUpdateStatus(width: number): string;
17
14
  /** A clickable URL in pinned Pi's grammar: an OSC 8 link where the terminal supports one, the plain URL otherwise. */
18
15
  export declare const piShellHyperlink: (url: string, text?: string) => string;
19
16
  export interface PiShellComponentPort extends PresentationComponentPort {
@@ -5,7 +5,6 @@ import { ensurePiTheme, piTheme, } from "./theme.js";
5
5
  export const piShellVisibleWidth = visibleWidth;
6
6
  export const piShellTruncateToWidth = (text, width) => truncateToWidth(text, width, "");
7
7
  export const INSTALLED_UPDATE_STATUS_TEXT = "Update installed, restart to update";
8
- export const BLOCKED_UPDATE_STATUS_TEXT = "Update prepared; activation blocked";
9
8
  /** Shared green restart notice for the pinned status and bare-A1 prompt overlay. */
10
9
  export function renderPiShellInstalledUpdateStatus(width) {
11
10
  if (width <= 0)
@@ -13,13 +12,6 @@ export function renderPiShellInstalledUpdateStatus(width) {
13
12
  ensurePiTheme();
14
13
  return piShellTruncateToWidth(piTheme().fg("success", INSTALLED_UPDATE_STATUS_TEXT), width);
15
14
  }
16
- /** Sanitized warning for a certified candidate that cannot yet preserve resident continuity. */
17
- export function renderPiShellBlockedUpdateStatus(width) {
18
- if (width <= 0)
19
- return "";
20
- ensurePiTheme();
21
- return piShellTruncateToWidth(piTheme().fg("warning", BLOCKED_UPDATE_STATUS_TEXT), width);
22
- }
23
15
  /** A clickable URL in pinned Pi's grammar: an OSC 8 link where the terminal supports one, the plain URL otherwise. */
24
16
  export const piShellHyperlink = (url, text = url) => hyperlink(url, text);
25
17
  // Provenance: pinned from packages/coding-agent/src/core/slash-commands.ts at 914cf14.