@timurproko/a1 0.2.7-dev.1241 → 0.2.7-dev.1245

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 (72) 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/process-containment/addon.d.ts +5 -0
  22. package/dist/foundation/release/automatic-update.d.ts +1 -1
  23. package/dist/foundation/release/automatic-update.js +1 -1
  24. package/dist/foundation/release/bootstrap.js +34 -31
  25. package/dist/foundation/release/cohort-selection.d.ts +0 -6
  26. package/dist/foundation/release/cohort-selection.js +5 -13
  27. package/dist/foundation/release/cohort-state.d.ts +2 -0
  28. package/dist/foundation/release/endpoint-lock.d.ts +2 -0
  29. package/dist/foundation/release/endpoint-lock.js +37 -0
  30. package/dist/foundation/release/endpoint-recovery.d.ts +10 -0
  31. package/dist/foundation/release/endpoint-recovery.js +45 -0
  32. package/dist/foundation/release/endpoints.d.ts +10 -2
  33. package/dist/foundation/release/endpoints.js +42 -24
  34. package/dist/foundation/release/index.d.ts +3 -1
  35. package/dist/foundation/release/index.js +3 -1
  36. package/dist/foundation/release/process-cleanup.js +12 -2
  37. package/dist/foundation/release/supervisor-identity.d.ts +15 -0
  38. package/dist/foundation/release/supervisor-identity.js +63 -0
  39. package/dist/foundation/release/update.js +17 -8
  40. package/dist/foundation/resident-tabs/boot-identity.d.ts +6 -0
  41. package/dist/foundation/resident-tabs/boot-identity.js +16 -0
  42. package/dist/foundation/resident-tabs/bounded-host-command.d.ts +2 -0
  43. package/dist/foundation/resident-tabs/bounded-host-command.js +26 -3
  44. package/dist/foundation/resident-tabs/continuity.js +11 -3
  45. package/dist/foundation/resident-tabs/runtime-control.js +2 -1
  46. package/dist/foundation/storage/control-store.d.ts +5 -3
  47. package/dist/foundation/storage/control-store.js +8 -7
  48. package/dist/foundation/supervision/main.js +3 -1
  49. package/dist/foundation/supervision/server.js +75 -53
  50. package/dist/integrations/pi/components/shell-footer-status.js +2 -4
  51. package/dist/integrations/pi/components/shell-shared-facade.d.ts +0 -3
  52. package/dist/integrations/pi/components/shell-shared-facade.js +0 -8
  53. package/dist/native/darwin-arm64/manifest.json +3 -3
  54. package/dist/native/darwin-arm64/process-guardian.node +0 -0
  55. package/dist/native/darwin-arm64/terminal-host.manifest.json +3 -3
  56. package/dist/native/darwin-arm64/terminal-host.node +0 -0
  57. package/dist/native/darwin-x64/manifest.json +3 -3
  58. package/dist/native/darwin-x64/process-guardian.node +0 -0
  59. package/dist/native/darwin-x64/terminal-host.manifest.json +3 -3
  60. package/dist/native/darwin-x64/terminal-host.node +0 -0
  61. package/dist/native/linux-x64/manifest.json +3 -3
  62. package/dist/native/linux-x64/process-guardian.node +0 -0
  63. package/dist/native/linux-x64/terminal-host.manifest.json +3 -3
  64. package/dist/native/linux-x64/terminal-host.node +0 -0
  65. package/dist/native/win32-x64/manifest.json +3 -3
  66. package/dist/native/win32-x64/process-guardian.node +0 -0
  67. package/dist/native/win32-x64/terminal-host.manifest.json +3 -3
  68. package/dist/native/win32-x64/terminal-host.node +0 -0
  69. package/docs/architecture/project-structure.md +4 -0
  70. package/docs/launch-recovery.md +23 -0
  71. package/docs/worktree-link-recovery.md +37 -0
  72. package/package.json +6 -3
package/README.md CHANGED
@@ -113,13 +113,19 @@ a1 tabs doctor
113
113
 
114
114
  ## Update
115
115
 
116
- Install the release:
116
+ Update on the channel you already use (release stays on release, develop stays on develop):
117
117
 
118
118
  ```text
119
119
  a1 update
120
120
  ```
121
121
 
122
- Install develop:
122
+ Switch to the release:
123
+
124
+ ```text
125
+ a1 update --release
126
+ ```
127
+
128
+ Switch to develop:
123
129
 
124
130
  ```text
125
131
  a1 update --develop
@@ -159,6 +165,22 @@ The **Update** setting controls release discovery:
159
165
 
160
166
  <p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/assets/readme/separator-dark.svg"><img src="docs/assets/readme/separator.svg" alt="" width="280"></picture></p>
161
167
 
168
+ ## Uninstall
169
+
170
+ Remove A1 from this machine. It stops running tabs and asks before deleting your sessions and settings in `~/.a1`:
171
+
172
+ ```text
173
+ a1 uninstall
174
+ ```
175
+
176
+ Remove everything without questions:
177
+
178
+ ```text
179
+ a1 uninstall --yes --purge
180
+ ```
181
+
182
+ <p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/assets/readme/separator-dark.svg"><img src="docs/assets/readme/separator.svg" alt="" width="280"></picture></p>
183
+
162
184
  ## Extensions
163
185
 
164
186
  Install a package:
package/bin/cli.js CHANGED
@@ -73,6 +73,23 @@ process.exitCode = await dispatchCli(process.argv.slice(2), {
73
73
  const { runTabsCommand } = await import("../dist/cli/tabs.js");
74
74
  return await runTabsCommand(request, { packageRoot: fileURLToPath(packageRoot) });
75
75
  },
76
+ uninstall: async request => {
77
+ const [{ runUninstall }, { createInterface }] = await Promise.all([
78
+ import("../dist/cli/uninstall.js"),
79
+ import("node:readline/promises"),
80
+ ]);
81
+ const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
82
+ return await runUninstall(request, {
83
+ packageRoot: fileURLToPath(packageRoot),
84
+ output: { stdout: message => process.stdout.write(message), stderr: message => process.stderr.write(message) },
85
+ ...(interactive ? {
86
+ confirm: async question => {
87
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
88
+ try { return /^y(?:es)?$/iu.test((await prompt.question(question)).trim()); } finally { prompt.close(); }
89
+ },
90
+ } : {}),
91
+ });
92
+ },
76
93
  }, {
77
94
  stdout: message => process.stdout.write(message),
78
95
  stderr: message => process.stderr.write(message),
@@ -1,7 +1,7 @@
1
1
  import { COPY_ACKNOWLEDGEMENT_DURATION_MS } from "../../ui/components/selection-copy.js";
2
2
  import { renderPiShellCommandMessage, renderPiShellReleaseUpdateBanner, renderPiShellStatusText, } from "../../integrations/pi/components/shell-presenters-info.js";
3
3
  import { PINNED_PI_LAYOUT, piTheme } from "../../integrations/pi/components/upstream/theme/theme.js";
4
- import { piShellTruncateToWidth, renderPiShellBlockedUpdateStatus, renderPiShellInstalledUpdateStatus } from "../../integrations/pi/components/shell-shared-facade.js";
4
+ import { piShellTruncateToWidth, renderPiShellInstalledUpdateStatus } from "../../integrations/pi/components/shell-shared-facade.js";
5
5
  import { parseReleaseUpdateDiagnostic } from "./transcript-document.js";
6
6
  /**
7
7
  * The transient chrome around bare A1's dock: the one command notice above the editor, the release
@@ -87,9 +87,6 @@ export class DockChrome {
87
87
  if (diagnostics.some(diagnostic => diagnostic.code === "release-update-installed")) {
88
88
  return renderPiShellInstalledUpdateStatus(width) || undefined;
89
89
  }
90
- if (diagnostics.some(diagnostic => diagnostic.code === "release-update-blocked")) {
91
- return renderPiShellBlockedUpdateStatus(width) || undefined;
92
- }
93
90
  return undefined;
94
91
  }
95
92
  renderReleaseBanner(diagnostics, width) {
@@ -1,6 +1,8 @@
1
1
  /** The Pi comparison launch is a prerelease development instrument, not product. */
2
2
  export interface CliCapabilities {
3
3
  readonly developmentComparison: boolean;
4
+ /** Bare `update` stays on this build's channel; absent means stable. */
5
+ readonly runningChannel?: "stable" | "next";
4
6
  }
5
7
  export declare function cliCapabilities(version: string): CliCapabilities;
6
8
  export declare function isPrereleaseVersion(version: string): boolean;
@@ -1,6 +1,7 @@
1
1
  import { prerelease } from "semver";
2
2
  export function cliCapabilities(version) {
3
- return Object.freeze({ developmentComparison: isPrereleaseVersion(version) });
3
+ const development = isPrereleaseVersion(version);
4
+ return Object.freeze({ developmentComparison: development, runningChannel: development ? "next" : "stable" });
4
5
  }
5
6
  export function isPrereleaseVersion(version) {
6
7
  return (prerelease(version)?.length ?? 0) > 0;
@@ -12,6 +12,11 @@ export interface CliHandlers {
12
12
  readonly packages: (request: PackageCommandRequest) => Promise<number>;
13
13
  readonly sessionContext: (request: SessionContextRequest) => Promise<number>;
14
14
  readonly tabs: (request: TabsRequest) => Promise<number>;
15
+ /** Removes A1 from the machine; absent in hosts that cannot uninstall. */
16
+ readonly uninstall?: (request: {
17
+ readonly yes: boolean;
18
+ readonly purge: boolean;
19
+ }) => Promise<number>;
15
20
  }
16
21
  export interface CliOutput {
17
22
  readonly stdout: (message: string) => void;
@@ -56,6 +61,12 @@ export type CliCommand = {
56
61
  readonly request: TabsRequest;
57
62
  } | {
58
63
  readonly kind: "tabs-help";
64
+ } | {
65
+ readonly kind: "uninstall";
66
+ readonly request: {
67
+ readonly yes: boolean;
68
+ readonly purge: boolean;
69
+ };
59
70
  } | {
60
71
  readonly kind: "error";
61
72
  readonly message: string;
@@ -13,12 +13,14 @@ export function cliUsage(capabilities) {
13
13
  "help",
14
14
  "version",
15
15
  "update",
16
+ "update --release",
16
17
  "update --develop [preview-or-version]",
17
18
  "update --models",
18
19
  "update --extensions",
19
20
  "update <source>",
20
21
  "install <source>",
21
22
  "remove <source>",
23
+ "uninstall [--yes] [--purge]",
22
24
  "uninstall <source>",
23
25
  "list",
24
26
  "session worktrees",
@@ -46,6 +48,7 @@ export function cliHelp(capabilities) {
46
48
  "",
47
49
  "Update:",
48
50
  ` ${command} update`,
51
+ ` ${command} update --release`,
49
52
  ` ${command} update --develop [preview-or-version]`,
50
53
  ` ${command} update --models`,
51
54
  ` ${command} update --extensions`,
@@ -54,6 +57,7 @@ export function cliHelp(capabilities) {
54
57
  "Packages:",
55
58
  ` ${command} install <source>`,
56
59
  ` ${command} remove <source>`,
60
+ ` ${command} uninstall [--yes] [--purge]`,
57
61
  ` ${command} uninstall <source>`,
58
62
  ` ${command} list`,
59
63
  "",
@@ -122,6 +126,8 @@ export async function dispatchCli(arguments_, handlers, output, capabilities) {
122
126
  }
123
127
  if (command.kind === "tabs")
124
128
  return await handlers.tabs(command.request);
129
+ if (command.kind === "uninstall")
130
+ return handlers.uninstall ? await handlers.uninstall(command.request) : 2;
125
131
  return await handlers.update(command.channel, command.target);
126
132
  }
127
133
  export function parseCliCommand(arguments_, capabilities) {
@@ -141,6 +147,11 @@ export function parseCliCommand(arguments_, capabilities) {
141
147
  return withoutArguments(rest, { kind: "help" });
142
148
  if (command === "version" || command === "--version" || command === "-v")
143
149
  return withoutArguments(rest, { kind: "version" });
150
+ // Compatibility: a source keeps `uninstall` as package removal; without one it removes A1.
151
+ if (command === "uninstall" && rest.every(value => value === "--yes" || value === "--purge")
152
+ && new Set(rest).size === rest.length) {
153
+ return { kind: "uninstall", request: { yes: rest.includes("--yes"), purge: rest.includes("--purge") } };
154
+ }
144
155
  if (command === "install" || command === "remove" || command === "uninstall" || command === "list") {
145
156
  return parsePackageCommand(arguments_, "direct");
146
157
  }
@@ -161,7 +172,7 @@ export function parseCliCommand(arguments_, capabilities) {
161
172
  }
162
173
  }
163
174
  if (command === "update")
164
- return parseUpdate(rest);
175
+ return parseUpdate(rest, capabilities.runningChannel ?? "stable");
165
176
  if (command === "session")
166
177
  return parseSessionContext(rest);
167
178
  if (command === "tabs")
@@ -224,17 +235,22 @@ function parseSessionContext(rest) {
224
235
  return { kind: "error", message: PRODUCT_TEXT.diagnostic("session accepts worktrees, link-worktree <path>, or unlink-worktree.") };
225
236
  }
226
237
  /**
227
- * Stable update is the empty form. `--develop` selects the moving development
228
- * head or one immutable numbered preview, while model refresh remains a separate
229
- * operation that cannot be combined with either update channel.
238
+ * The empty form stays on the running channel; `--release` moves to stable. `--develop` selects
239
+ * the moving development head or one immutable numbered preview, while model refresh remains a
240
+ * separate operation that cannot be combined with either update channel.
230
241
  */
231
- function parseUpdate(rest) {
242
+ function parseUpdate(rest, runningChannel) {
232
243
  if (rest.some(value => value === "--help" || value === "-h")) {
233
244
  return { kind: "package-help", verb: "update", namespace: "direct" };
234
245
  }
235
246
  if (rest.length === 0)
236
- return { kind: "update", channel: "stable" };
247
+ return { kind: "update", channel: runningChannel };
237
248
  const [selector, ...values] = rest;
249
+ if (selector === "--release") {
250
+ if (values.length > 0)
251
+ return updateGrammarError("--release cannot be combined with another update selector.");
252
+ return { kind: "update", channel: "stable" };
253
+ }
238
254
  // Compatibility: removed notation is unsupported rather than deprecated.
239
255
  if (selector === "self")
240
256
  return { kind: "noop" };
@@ -37,6 +37,7 @@ export function packageCommandHelp(verb, namespace = "pi", style = chalk) {
37
37
  const header = verb === "update" && namespace === "direct"
38
38
  ? `${style.bold("Usage:")}\n`
39
39
  + ` ${command} update\n`
40
+ + ` ${command} update --release\n`
40
41
  + ` ${command} update --develop [preview-or-version]\n`
41
42
  + ` ${command} update --models\n`
42
43
  + ` ${command} update --extensions\n`
@@ -58,7 +59,7 @@ Examples:
58
59
  case "remove":
59
60
  return header + `Remove a package and its source from settings.
60
61
  Alias: ${command} uninstall <source>
61
-
62
+ ${namespace === "direct" ? `\nRemove A1 from this machine (sessions and settings stay unless --purge):\n ${command} uninstall [--yes] [--purge]\n` : ""}
62
63
  Examples:
63
64
  ${command} remove npm:@foo/bar
64
65
  ${command} uninstall npm:@foo/bar
@@ -70,11 +71,11 @@ Examples:
70
71
  return header + `${namespace === "direct" ? "Update A1, installed packages, or model catalogs." : "Update installed packages or model catalogs."}
71
72
 
72
73
  Options:
73
- ${namespace === "direct" ? " --develop [target] Update to develop or one preview\n" : ""} --extensions Update installed packages only
74
+ ${namespace === "direct" ? " --release Update to the release\n --develop [target] Update to develop or one preview\n" : ""} --extensions Update installed packages only
74
75
  --models Refresh model catalogs only
75
76
 
76
77
  Short forms:
77
- ${namespace === "direct" ? ` ${command} update Update A1 to the release\n ${command} update --develop Update A1 to develop\n` : ""} ${command} update --extensions Update installed packages only
78
+ ${namespace === "direct" ? ` ${command} update Update A1 on its current channel\n ${command} update --release Update A1 to the release\n ${command} update --develop Update A1 to develop\n` : ""} ${command} update --extensions Update installed packages only
78
79
  ${command} update --models Refresh model catalogs only
79
80
  ${command} update <source> Update one package
80
81
 
@@ -29,7 +29,7 @@ export async function runSessionContextCommand(request, options = {}) {
29
29
  }
30
30
  catch (error) {
31
31
  const code = errorCode(error);
32
- const detail = error instanceof Error ? error.message : String(error);
32
+ const detail = recoveryDetail(code) ?? (error instanceof Error ? error.message : String(error));
33
33
  stderr(`${PRODUCT_TEXT.diagnostic(`could not update session context${code === null ? "" : ` (${code})`}: ${detail}`)}\n`);
34
34
  return 1;
35
35
  }
@@ -42,6 +42,23 @@ function readIdentity(environment) {
42
42
  return null;
43
43
  return { sessionId, sessionFile, runtimeId };
44
44
  }
45
+ /** Known coordination failures must not expose native paths or another session's identity. */
46
+ function recoveryDetail(code) {
47
+ const details = {
48
+ "worktree-claim-busy": "another mutation is still running; runtime registration retries automatically, so retry this link shortly",
49
+ "worktree-legacy-recovery-required": "an older-format worktree lock requires safe recovery; stop all older A1 processes, move only session-worktree-claims/mutation.lock to a backup while stopped, then start the updated A1; keep all sessions and worktree claims",
50
+ "worktree-coordination-unavailable": "native worktree coordination is unavailable; repair or update this A1 installation, then retry",
51
+ "worktree-coordination-io": "worktree coordination could not access its files; check A1 data-directory permissions and filesystem availability, then retry",
52
+ "worktree-coordination-boundary": "unexpected worktree coordination entry; retain it for inspection, and do not delete the claim store",
53
+ "process-inspection-unavailable": "native process inspection is unavailable; repair the A1 installation before retrying; no ownership was overridden",
54
+ "session-runtime-unverifiable": "runtime identity could not be verified; check native process inspection and retry; no ownership was overridden",
55
+ "session-runtime-unavailable": "this session has not registered yet; updated A1 retries registration automatically; retry shortly and follow any specific coordination error if it persists",
56
+ "session-runtime-stopped": "this shell belongs to a stopped runtime; resume in a running A1 session before linking",
57
+ "worktree-active-in-another-session": "another live agent owns this worktree; use a different worktree or wait for its owner to release it",
58
+ "worktree-owner-unverifiable": "worktree ownership cannot be verified; retain the claim and use another worktree rather than forcing recovery",
59
+ };
60
+ return code === null ? undefined : details[code];
61
+ }
45
62
  function errorCode(error) {
46
63
  if (error instanceof Error && "code" in error && typeof error.code === "string")
47
64
  return error.code;
@@ -0,0 +1,30 @@
1
+ import { type ProductPaths } from "../foundation/lifecycle/index.js";
2
+ export interface UninstallRequest {
3
+ /** Skips the program-removal question; user data is still kept unless `purge` is set. */
4
+ readonly yes: boolean;
5
+ /** Also removes sessions, settings, sign-ins, and prompt history. */
6
+ readonly purge: boolean;
7
+ }
8
+ export interface UninstallOutput {
9
+ readonly stdout: (message: string) => void;
10
+ readonly stderr: (message: string) => void;
11
+ }
12
+ export interface UninstallDependencies {
13
+ readonly packageRoot: string;
14
+ readonly output: UninstallOutput;
15
+ readonly environment?: NodeJS.ProcessEnv;
16
+ readonly platform?: NodeJS.Platform;
17
+ readonly home?: string;
18
+ /** Asks one yes/no question; absent when no terminal can answer. */
19
+ readonly confirm?: (question: string) => Promise<boolean>;
20
+ readonly stopResidentHost?: (releaseRoot: string, paths: ProductPaths, profileRoot: string) => Promise<void>;
21
+ readonly stopSupervisors?: (paths: ProductPaths) => Promise<void>;
22
+ readonly removePackage?: (packageRoot: string, environment: NodeJS.ProcessEnv) => Promise<void>;
23
+ readonly remove?: (path: string) => Promise<void>;
24
+ }
25
+ /**
26
+ * Removes A1 from this machine: resident hosts and supervisors stop first, through each
27
+ * release's own maintenance contract, then the global package and every A1-owned state root go.
28
+ * Sessions and settings live in the profile home and leave only with explicit consent.
29
+ */
30
+ export declare function runUninstall(request: UninstallRequest, dependencies: UninstallDependencies): Promise<number>;
@@ -0,0 +1,117 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readdir, rm } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, parse, resolve, sep } from "node:path";
5
+ import { resolveLaunchProfilePaths } from "../features/launch/index.js";
6
+ import { resolveProductPaths } from "../foundation/lifecycle/index.js";
7
+ import { cleanupVerifiedOwner, listRecordedEndpoints, npmPrefixForGlobalRoot, resolveNpmCli } from "../foundation/release/index.js";
8
+ import { runTerminalHostMaintenance } from "../foundation/resident-tabs/index.js";
9
+ import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../product-identity.js";
10
+ /**
11
+ * Removes A1 from this machine: resident hosts and supervisors stop first, through each
12
+ * release's own maintenance contract, then the global package and every A1-owned state root go.
13
+ * Sessions and settings live in the profile home and leave only with explicit consent.
14
+ */
15
+ export async function runUninstall(request, dependencies) {
16
+ const { output } = dependencies;
17
+ const environment = dependencies.environment ?? process.env;
18
+ const command = PRODUCT_TEXT.commandName;
19
+ if (environment[PRODUCT_IDENTITY.environment.tabId] !== undefined) {
20
+ output.stderr(`${PRODUCT_TEXT.diagnostic(`${command} uninstall stops every tab, including this one. Run it from a terminal outside ${command}.`)}\n`);
21
+ return 1;
22
+ }
23
+ const globalRoot = managedGlobalRoot(dependencies.packageRoot);
24
+ if (globalRoot === null) {
25
+ output.stderr(`${PRODUCT_TEXT.diagnostic(`${command} uninstall removes only an installed ${command}; this one runs from ${dependencies.packageRoot}.`)}\n`);
26
+ return 1;
27
+ }
28
+ const platform = dependencies.platform ?? process.platform;
29
+ const paths = resolveProductPaths(environment, platform);
30
+ const profileHome = resolveLaunchProfilePaths({ environment, platform, ...(dependencies.home === undefined ? {} : { home: dependencies.home }) }).managedStateRoot;
31
+ let purge = request.purge;
32
+ if (!request.yes) {
33
+ if (dependencies.confirm === undefined) {
34
+ output.stderr(`${PRODUCT_TEXT.diagnostic(`${command} uninstall needs a terminal to confirm. Run ${command} uninstall --yes to remove it.`)}\n`);
35
+ return 2;
36
+ }
37
+ if (!await dependencies.confirm(`Remove ${command} from this machine? Running Agents will stop. [y/N] `)) {
38
+ output.stdout(`${command} was not removed\n`);
39
+ return 1;
40
+ }
41
+ purge ||= await dependencies.confirm(`Also delete your sessions, settings, and sign-ins in ${profileHome}? [y/N] `);
42
+ }
43
+ const profileRoot = environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir;
44
+ const stopResidentHost = dependencies.stopResidentHost ?? stopReleaseResidentHost;
45
+ for (const releaseRoot of await installedReleaseRoots(paths.dataDir)) {
46
+ await stopResidentHost(releaseRoot, paths, profileRoot).catch(() => undefined);
47
+ }
48
+ await (dependencies.stopSupervisors ?? stopRecordedSupervisors)(paths).catch(() => undefined);
49
+ try {
50
+ await (dependencies.removePackage ?? removeGlobalPackage)(dependencies.packageRoot, environment);
51
+ }
52
+ catch (error) {
53
+ const reason = error instanceof Error ? error.message : String(error);
54
+ output.stderr(`${PRODUCT_TEXT.diagnostic(`${command} uninstall could not remove the package: ${reason}. Run npm uninstall --global ${PRODUCT_IDENTITY.packageName}, then ${command} uninstall again.`)}\n`);
55
+ return 1;
56
+ }
57
+ const remove = dependencies.remove ?? (async (path) => await rm(path, { recursive: true, force: true }));
58
+ const home = dependencies.home ?? environment.HOME ?? environment.USERPROFILE ?? homedir();
59
+ const owned = [paths.dataDir, paths.configDir, paths.runtimeDir, paths.endpointDirectory, ...(purge ? [profileHome] : [])];
60
+ for (const path of new Set(owned.filter((value) => value !== null))) {
61
+ if (removable(path, home))
62
+ await remove(path);
63
+ }
64
+ output.stdout(purge ? `${command} uninstalled\n` : `${command} uninstalled; your sessions and settings remain in ${profileHome}\n`);
65
+ return 0;
66
+ }
67
+ /** The npm global `node_modules` that holds this package, or null outside a managed install. */
68
+ function managedGlobalRoot(packageRoot) {
69
+ const root = resolve(packageRoot);
70
+ const scope = dirname(root);
71
+ const modules = dirname(scope);
72
+ const [scopeName, packageName] = PRODUCT_IDENTITY.packageName.split("/");
73
+ return root.endsWith(`${sep}${packageName}`) && scope.endsWith(`${sep}${scopeName}`) && modules.endsWith(`${sep}node_modules`)
74
+ ? modules
75
+ : null;
76
+ }
77
+ /** Invariant: a state-root override never turns removal into deleting a home or volume root. */
78
+ function removable(path, home) {
79
+ const target = resolve(path);
80
+ return target !== parse(target).root && target !== resolve(home) && !resolve(home).startsWith(`${target}${sep}`);
81
+ }
82
+ async function installedReleaseRoots(dataDir) {
83
+ const releases = resolve(dataDir, "releases");
84
+ const names = await readdir(releases).catch(() => []);
85
+ return names.map(name => resolve(releases, name));
86
+ }
87
+ async function stopReleaseResidentHost(releaseRoot, paths, profileRoot) {
88
+ // Invariant: each release stops only its own namespace, through its own verified host.
89
+ await runTerminalHostMaintenance({
90
+ action: "host-stop", profileRoot, dataDir: paths.dataDir, runtimeDir: paths.runtimeDir,
91
+ releaseRoots: [releaseRoot], environment: process.env, timeoutMs: 30_000,
92
+ });
93
+ }
94
+ async function stopRecordedSupervisors(paths) {
95
+ for (const endpoint of await listRecordedEndpoints(paths)) {
96
+ await cleanupVerifiedOwner(endpoint.metadata, { allowLiveInstances: true }).catch(() => undefined);
97
+ }
98
+ }
99
+ async function removeGlobalPackage(packageRoot, environment) {
100
+ const globalRoot = managedGlobalRoot(packageRoot);
101
+ if (globalRoot === null)
102
+ throw new Error("A1 is not a managed global installation");
103
+ const prefix = npmPrefixForGlobalRoot(globalRoot);
104
+ const npmCli = await resolveNpmCli(globalRoot, environment);
105
+ const code = await new Promise((resolvePromise, rejectPromise) => {
106
+ const child = spawn(process.execPath, [npmCli, "uninstall", "--global", "--prefix", prefix,
107
+ "--loglevel=error", "--no-fund", "--no-audit", PRODUCT_IDENTITY.packageName], {
108
+ env: { ...environment, NO_UPDATE_NOTIFIER: "1", npm_config_update_notifier: "false" },
109
+ stdio: ["ignore", "ignore", "inherit"],
110
+ windowsHide: true,
111
+ });
112
+ child.once("error", rejectPromise);
113
+ child.once("close", value => resolvePromise(value ?? 1));
114
+ });
115
+ if (code !== 0)
116
+ throw new Error(`npm could not remove ${PRODUCT_IDENTITY.packageName} (exit ${code})`);
117
+ }
@@ -76,6 +76,8 @@ class ReleaseUpdateObservation {
76
76
  const clientVersion = input.clientVersion?.();
77
77
  if (version !== null && clientVersion && !releases.isNewerRelease(version, clientVersion))
78
78
  return;
79
+ if (version !== null)
80
+ this.#nextDelay = module.AUTOMATIC_UPDATE_ACTIVATION_RETRY_MS;
79
81
  this.#announce(version === null ? "blocked-cleared" : "blocked", version ?? release.version);
80
82
  },
81
83
  }));
@@ -1,20 +1,17 @@
1
1
  import type { NativeProcessIdentity } from "../foundation/lifecycle/model.js";
2
2
  export interface SessionRuntimeRegistryOptions {
3
- /** This runtime's id from the environment; absent outside a runtime that claims worktrees. */
3
+ /** Absent outside claim-owning runtimes. */
4
4
  readonly runtimeId: string | undefined;
5
- /** Exact native process inspection; absent SDK seams publish no claims. */
5
+ /** Exact native inspection; absent SDK seams cannot claim. */
6
6
  readonly inspectProcess: ((pid: number) => Promise<NativeProcessIdentity | null>) | undefined;
7
7
  readonly refreshMs?: number;
8
8
  }
9
- /**
10
- * The live worktree claims this runtime's agents hold: each agent's session identity, the heartbeat
11
- * that keeps the claims fresh, and their release when the runtime is disposed.
12
- */
9
+ /** Lifecycle-owned publication and retries for each agent's current session, serialized with disposal. */
13
10
  export declare class SessionRuntimeRegistry {
14
11
  #private;
15
12
  constructor(options: SessionRuntimeRegistryOptions);
16
13
  /** The repository context reader for one agent; undefined names the primary agent. */
17
14
  readerFor(agentId: string | undefined): (sessionId: string, sessionFile: string, signal: AbortSignal) => Promise<import("../foundation/lifecycle/session-repository-context.js").SessionRepositoryContext | null>;
18
- /** Stops the heartbeat, waits for an in-flight refresh, and releases every claim this runtime holds. */
15
+ /** Drain publication before releasing claims; never republish after disposal. */
19
16
  dispose(): Promise<void>;
20
17
  }
@@ -1,47 +1,49 @@
1
- const SESSION_RUNTIME_REFRESH_MS = 30_000;
2
- /**
3
- * The live worktree claims this runtime's agents hold: each agent's session identity, the heartbeat
4
- * that keeps the claims fresh, and their release when the runtime is disposed.
5
- */
1
+ /** Lifecycle-owned publication and retries for each agent's current session, serialized with disposal. */
6
2
  export class SessionRuntimeRegistry {
7
3
  #options;
8
- // Invariant: one entry per agent of this runtime; each agent holds its own claim, and the runtime's exit releases all.
9
4
  #identities = new Map();
5
+ #abort = new AbortController();
10
6
  #owner = null;
11
7
  #timer = null;
12
- #refresh = Promise.resolve();
8
+ #work = Promise.resolve();
9
+ #refreshPending = false;
13
10
  #disposed = false;
14
- constructor(options) {
15
- this.#options = options;
16
- }
11
+ constructor(options) { this.#options = options; }
17
12
  /** The repository context reader for one agent; undefined names the primary agent. */
18
13
  readerFor(agentId) {
19
14
  return async (sessionId, sessionFile, signal) => {
20
15
  const { runtimeId, inspectProcess } = this.#options;
21
- const owner = await this.#ownerIdentity();
22
- if (owner === null || runtimeId === undefined || inspectProcess === undefined)
16
+ if (this.#disposed || runtimeId === undefined || inspectProcess === undefined)
23
17
  return null;
24
18
  const identity = { sessionId, sessionFile };
25
19
  this.#identities.set(agentId, identity);
26
- const { activateSessionRepositoryContext } = await import("../foundation/lifecycle/session-repository-context.js");
27
- const context = await activateSessionRepositoryContext(identity, runtimeId, owner, {
28
- signal,
29
- inspectProcess,
30
- ...(agentId === undefined ? {} : { agentId }),
31
- });
20
+ // Invariant: initial activation or inspection failure must not disable retries.
32
21
  this.#arm();
33
- return context;
22
+ return await this.#enqueue(async () => {
23
+ const owner = await this.#ownerIdentity();
24
+ if (owner === null || !this.#current(agentId, identity))
25
+ return null;
26
+ const { activateSessionRepositoryContext } = await import("../foundation/lifecycle/session-repository-context.js");
27
+ if (!this.#current(agentId, identity))
28
+ return null;
29
+ return await activateSessionRepositoryContext(identity, runtimeId, owner, {
30
+ signal: AbortSignal.any([signal, this.#abort.signal]),
31
+ inspectProcess,
32
+ ...(agentId === undefined ? {} : { agentId }),
33
+ });
34
+ });
34
35
  };
35
36
  }
36
- /** Stops the heartbeat, waits for an in-flight refresh, and releases every claim this runtime holds. */
37
+ /** Drain publication before releasing claims; never republish after disposal. */
37
38
  async dispose() {
38
39
  this.#disposed = true;
40
+ this.#abort.abort();
39
41
  if (this.#timer !== null)
40
42
  clearInterval(this.#timer);
41
43
  this.#timer = null;
42
- await this.#refresh;
44
+ await this.#work;
43
45
  const { runtimeId } = this.#options;
44
- const owner = await this.#ownerIdentity();
46
+ const owner = await this.#owner;
45
47
  if (runtimeId === undefined || owner === null)
46
48
  return;
47
49
  await import("../foundation/lifecycle/session-repository-context.js")
@@ -49,37 +51,51 @@ export class SessionRuntimeRegistry {
49
51
  .catch(() => undefined);
50
52
  }
51
53
  async #ownerIdentity() {
52
- const { runtimeId, inspectProcess } = this.#options;
53
- if (runtimeId === undefined || inspectProcess === undefined)
54
+ const { inspectProcess } = this.#options;
55
+ if (this.#disposed || inspectProcess === undefined)
54
56
  return null;
55
- this.#owner ??= inspectProcess(process.pid).catch(() => null);
56
- return await this.#owner;
57
+ const pending = this.#owner ??= Promise.resolve().then(() => inspectProcess(process.pid))
58
+ .then(owner => owner?.pid === process.pid && owner.startIdentity.length > 0 ? owner : null)
59
+ .catch(() => null);
60
+ const owner = await pending;
61
+ // Performance: cache success, not failure.
62
+ if (owner === null && this.#owner === pending)
63
+ this.#owner = null;
64
+ return owner;
65
+ }
66
+ #current(agentId, identity) {
67
+ return !this.#disposed && this.#identities.get(agentId) === identity;
68
+ }
69
+ #enqueue(operation) {
70
+ const next = this.#work.then(operation);
71
+ this.#work = next.then(() => undefined, () => undefined);
72
+ return next;
57
73
  }
58
74
  #arm() {
59
75
  if (this.#timer !== null)
60
76
  return;
61
- this.#timer = setInterval(() => this.#queueRefresh(), this.#options.refreshMs ?? SESSION_RUNTIME_REFRESH_MS);
77
+ this.#timer = setInterval(() => this.#queueRefresh(), this.#options.refreshMs ?? 30_000);
62
78
  this.#timer.unref();
63
79
  }
64
80
  #queueRefresh() {
65
81
  const { runtimeId, inspectProcess } = this.#options;
66
- if (this.#disposed || this.#identities.size === 0 || runtimeId === undefined || inspectProcess === undefined)
82
+ if (this.#disposed || this.#refreshPending || runtimeId === undefined || inspectProcess === undefined)
67
83
  return;
68
- this.#refresh = this.#refresh.then(async () => {
69
- if (this.#disposed)
70
- return;
84
+ this.#refreshPending = true;
85
+ void this.#enqueue(async () => {
71
86
  const owner = await this.#ownerIdentity();
72
- if (owner === null)
87
+ if (owner === null || this.#disposed)
73
88
  return;
74
89
  const { registerSessionRepositoryRuntime } = await import("../foundation/lifecycle/session-repository-context.js");
75
90
  for (const [agentId, identity] of this.#identities) {
76
- if (this.#disposed)
77
- return;
91
+ if (!this.#current(agentId, identity))
92
+ continue;
78
93
  await registerSessionRepositoryRuntime(identity, runtimeId, owner, {
79
94
  inspectProcess,
95
+ signal: this.#abort.signal,
80
96
  ...(agentId === undefined ? {} : { agentId }),
81
97
  }).catch(() => undefined);
82
98
  }
83
- }).catch(() => undefined);
99
+ }).catch(() => undefined).finally(() => { this.#refreshPending = false; });
84
100
  }
85
101
  }
@@ -13,19 +13,28 @@ export async function runExitWatcher(arguments_, platform = process.platform, lo
13
13
  }
14
14
  try {
15
15
  await addon.waitForProcessExit(options.guardianPid);
16
- if (platform === "win32") {
17
- // Rationale: job termination is asynchronous; keep the runtime's final frame ahead of the notice.
18
- await Promise.race([
19
- addon.waitForProcessExit(options.runtimePid),
20
- delay(2_000),
21
- ]);
16
+ let cleanupFailure = null;
17
+ try {
18
+ if (platform === "win32") {
19
+ // Rationale: job termination is asynchronous; keep the runtime's final frame ahead of the notice.
20
+ await Promise.race([
21
+ addon.waitForProcessExit(options.runtimePid),
22
+ delay(2_000),
23
+ ]);
24
+ }
25
+ else if (options.processGroup !== null) {
26
+ addon.stopProcessGroup(options.processGroup);
27
+ if (options.foregroundGroup !== null)
28
+ addon.restoreForegroundGroup(options.foregroundGroup);
29
+ }
22
30
  }
23
- else if (options.processGroup !== null) {
24
- addon.stopProcessGroup(options.processGroup);
25
- if (options.foregroundGroup !== null)
26
- addon.restoreForegroundGroup(options.foregroundGroup);
31
+ catch (error) {
32
+ cleanupFailure = error;
27
33
  }
34
+ // Invariant: an armed notice, such as the running-Agent reminder, survives a failed cleanup.
28
35
  addon.deliverExitNotice(options.noticePath, options.encodedModes);
36
+ if (cleanupFailure !== null)
37
+ throw cleanupFailure;
29
38
  return 0;
30
39
  }
31
40
  finally {