@timurproko/a1 0.2.7-dev.1222 → 0.2.7-dev.1226

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 (36) hide show
  1. package/README.md +8 -6
  2. package/bin/ui.js +1 -0
  3. package/dist/app/session-shell/exit-notice.d.ts +2 -2
  4. package/dist/app/session-shell/exit-notice.js +4 -4
  5. package/dist/app/session-shell/session-lifecycle.d.ts +2 -1
  6. package/dist/app/session-shell/session-lifecycle.js +4 -2
  7. package/dist/app/session-shell/session-shell-root.d.ts +2 -0
  8. package/dist/app/session-shell/session-shell.js +5 -0
  9. package/dist/cli/dispatch.js +15 -2
  10. package/dist/composition/owned-ui.d.ts +2 -0
  11. package/dist/composition/owned-ui.js +39 -9
  12. package/dist/features/launch/intent.js +2 -2
  13. package/dist/foundation/release/automatic-update.js +90 -22
  14. package/dist/foundation/release/bootstrap.js +3 -3
  15. package/dist/foundation/release/latest-release.d.ts +5 -1
  16. package/dist/foundation/release/latest-release.js +74 -31
  17. package/dist/foundation/release/update.js +1 -1
  18. package/dist/foundation/resident-tabs/settings.d.ts +4 -0
  19. package/dist/foundation/resident-tabs/settings.js +3 -1
  20. package/dist/native/darwin-arm64/manifest.json +1 -1
  21. package/dist/native/darwin-arm64/terminal-host.manifest.json +3 -3
  22. package/dist/native/darwin-arm64/terminal-host.node +0 -0
  23. package/dist/native/darwin-x64/manifest.json +1 -1
  24. package/dist/native/darwin-x64/terminal-host.manifest.json +3 -3
  25. package/dist/native/darwin-x64/terminal-host.node +0 -0
  26. package/dist/native/linux-x64/manifest.json +1 -1
  27. package/dist/native/linux-x64/terminal-host.manifest.json +3 -3
  28. package/dist/native/linux-x64/terminal-host.node +0 -0
  29. package/dist/native/win32-x64/manifest.json +2 -2
  30. package/dist/native/win32-x64/process-guardian.node +0 -0
  31. package/dist/native/win32-x64/terminal-host.manifest.json +3 -3
  32. package/dist/native/win32-x64/terminal-host.node +0 -0
  33. package/dist/ui/settings/declarations.d.ts +1 -1
  34. package/dist/ui/settings/declarations.js +1 -1
  35. package/docs/features/launch-profiles.md +7 -4
  36. package/package.json +3 -3
package/README.md CHANGED
@@ -63,6 +63,8 @@ Launch A1 in single agent mode:
63
63
  a1 solo
64
64
  ```
65
65
 
66
+ When a solo session exits, its printed `a1 solo --session <id>` command resumes that same conversation in solo mode; a custom session store is included in the printed command. Resident tabs are different: they keep running after detach, and bare `a1` returns to them.
67
+
66
68
  Show all commands:
67
69
 
68
70
  ```text
@@ -92,7 +94,7 @@ a1 tabs
92
94
  a1 tabs stop <id>
93
95
  ```
94
96
 
95
- Stop every tab and the resident host, for example before going back to single agent mode with `a1 solo`:
97
+ Stop every tab and the resident host when you are finished with them (not required to run `a1 solo`):
96
98
 
97
99
  ```text
98
100
  a1 tabs host stop
@@ -145,13 +147,13 @@ Stuck on `0.2.2` and `a1 update` fails? Run the installer once, then use `a1 upd
145
147
  npm x -y -- @timurproko/a1-install
146
148
  ```
147
149
 
148
- The **Update** setting controls the at-most-daily startup check:
150
+ The **Update** setting controls release discovery:
149
151
 
150
- - **Auto** (default) silently installs a verified update for the running channel, then shows `Update installed · Restart to update`.
151
- - **Notify** keeps the `Update Available` notice and leaves installation manual.
152
- - **No** performs no startup update check; updates remain available through the commands above.
152
+ - **Auto** (default) checks the running release's stable or development channel at startup and every 15 minutes while open. It silently installs a verified update, then shows `Update installed · Restart to update`; existing sessions keep running until restarted.
153
+ - **Notify** checks at startup (at most daily), keeps the `Update Available` notice, and leaves installation manual.
154
+ - **No** performs no update check; updates remain available through the commands above.
153
155
 
154
- `A1_SKIP_VERSION_CHECK=1` also disables the startup check. Manual stable, development, and exact-version update commands remain available in every mode.
156
+ `A1_SKIP_VERSION_CHECK=1` disables both Auto and Notify discovery. Manual stable, development, and exact-version update commands remain available in every mode.
155
157
 
156
158
  <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>
157
159
 
package/bin/ui.js CHANGED
@@ -103,6 +103,7 @@ if (utilityRoute !== null) {
103
103
  ownedSurfaces,
104
104
  notificationVersion: launchContext.updateVersion,
105
105
  ...(hidesTabSettings(launchContext) ? { hideTabSettings: true } : {}),
106
+ soloResumeHint: profile === "a1" && launchContext.launchMode === "solo",
106
107
  ...(processInspector === null ? {} : { inspectProcess: pid => processInspector.observe(pid) }),
107
108
  projectTrustPrompt: createConsoleProjectTrustPrompt({ presentation: profile === "a1" ? "bare" : "comparison" }),
108
109
  sessionForkPrompt: createConsoleSessionForkPrompt(),
@@ -13,6 +13,6 @@ export interface ExitNoticeSource {
13
13
  export interface ArmedExitNotice {
14
14
  clear(): void;
15
15
  }
16
- export declare function uncleanExitNoticeLines(resume: SessionResumeCommandMetadata | null): string[];
16
+ export declare function uncleanExitNoticeLines(resume: SessionResumeCommandMetadata | null, soloResumeHint?: boolean): string[];
17
17
  /** Arms the notice and keeps its resume command following the bound session until cleared. */
18
- export declare function armExitNotice(source: ExitNoticeSource, path: string): ArmedExitNotice;
18
+ export declare function armExitNotice(source: ExitNoticeSource, path: string, soloResumeHint?: boolean): ArmedExitNotice;
@@ -7,14 +7,14 @@
7
7
  import { renameSync, rmSync, writeFileSync } from "node:fs";
8
8
  import { PRODUCT_IDENTITY } from "../../product-identity.js";
9
9
  import { formatSessionResumeCommand } from "./session-lifecycle.js";
10
- export function uncleanExitNoticeLines(resume) {
10
+ export function uncleanExitNoticeLines(resume, soloResumeHint = false) {
11
11
  return [
12
12
  `${PRODUCT_IDENTITY.displayName} was stopped unexpectedly.`,
13
- ...(resume === null ? [] : [`To resume this session: ${formatSessionResumeCommand(resume)}`]),
13
+ ...(resume === null ? [] : [`To resume this session: ${formatSessionResumeCommand(resume, soloResumeHint)}`]),
14
14
  ];
15
15
  }
16
16
  /** Arms the notice and keeps its resume command following the bound session until cleared. */
17
- export function armExitNotice(source, path) {
17
+ export function armExitNotice(source, path, soloResumeHint = false) {
18
18
  let current = null;
19
19
  let cleared = false;
20
20
  let generation = source.identity.sessionGeneration;
@@ -22,7 +22,7 @@ export function armExitNotice(source, path) {
22
22
  if (cleared)
23
23
  return;
24
24
  // Protocol: plain text only; the guardian strips control characters, so styling would be lost.
25
- const text = `${uncleanExitNoticeLines(source.identity.currentSessionResumeMetadata()).join("\n")}\n`;
25
+ const text = `${uncleanExitNoticeLines(source.identity.currentSessionResumeMetadata(), soloResumeHint).join("\n")}\n`;
26
26
  if (text === current)
27
27
  return;
28
28
  try {
@@ -3,13 +3,14 @@ export interface SessionResumeCommandMetadata {
3
3
  readonly sessionDir: string;
4
4
  readonly usesDefaultSessionDir: boolean;
5
5
  }
6
- export declare function formatSessionResumeCommand(metadata: SessionResumeCommandMetadata): string;
6
+ export declare function formatSessionResumeCommand(metadata: SessionResumeCommandMetadata, solo?: boolean): string;
7
7
  export declare function quoteCommandArgument(value: string): string;
8
8
  export interface ExitTextInput {
9
9
  readonly terminalMode: "regular" | "fullscreen";
10
10
  readonly customViewport: boolean;
11
11
  readonly exitMode: "transcript" | "resume-hint";
12
12
  readonly resume: SessionResumeCommandMetadata | null;
13
+ readonly soloResumeHint?: boolean;
13
14
  /** The styled transcript, rendered only when the pinned profile asks for it. */
14
15
  readonly transcript: () => string;
15
16
  }
@@ -1,6 +1,8 @@
1
1
  import { PRODUCT_TEXT } from "../../product-identity.js";
2
- export function formatSessionResumeCommand(metadata) {
2
+ export function formatSessionResumeCommand(metadata, solo = false) {
3
3
  const args = [PRODUCT_TEXT.commandName];
4
+ if (solo)
5
+ args.push("solo");
4
6
  if (!metadata.usesDefaultSessionDir)
5
7
  args.push("--session-dir", quoteCommandArgument(metadata.sessionDir));
6
8
  args.push("--session", metadata.sessionId);
@@ -18,7 +20,7 @@ export function quoteCommandArgument(value) {
18
20
  export function composeExitText(input) {
19
21
  if (input.terminalMode !== "fullscreen")
20
22
  return "";
21
- const resumeHint = input.resume === null ? "" : `${dim("To resume this session:")} ${formatSessionResumeCommand(input.resume)}`;
23
+ const resumeHint = input.resume === null ? "" : `${dim("To resume this session:")} ${formatSessionResumeCommand(input.resume, input.soloResumeHint)}`;
22
24
  // Invariant: bare A1 leaves only the hint behind; the pinned comparison profile still
23
25
  // honors fullscreenExitOutput, including the styled transcript.
24
26
  if (input.customViewport || input.exitMode === "resume-hint")
@@ -73,6 +73,8 @@ export interface OwnedUiShellEngineOptions {
73
73
  /** Terminal, startup, viewport, and presentation-cadence seams; production leaves most at their defaults. */
74
74
  export interface OwnedUiShellPresentationOptions {
75
75
  readonly terminal?: OwnedUiTerminalPort;
76
+ /** Explicit direct solo launch, not a resident fallback or comparison launch. */
77
+ readonly soloResumeHint?: boolean;
76
78
  readonly startup?: OwnedUiStartupOptions;
77
79
  /** Live profile-local settings, supplied only to the bare-A1 composition. */
78
80
  readonly viewportSettings?: OwnedUiViewportSettingsPort;
@@ -28,6 +28,8 @@ import { writeSystemClipboardText, } from "./system-clipboard.js";
28
28
  import { OwnedUiSessionShellRoot, shellResourceEntries, } from "./session-shell-root.js";
29
29
  // Invariant: a disposed shell leaves the engine a host that holds no reference back to it.
30
30
  const DETACHED_WORKFLOW_INTERACTION = { prompt: async () => null, notify() { } };
31
+ // Invariant: the resume mode belongs to the presenter instance, not the persisted session or profile.
32
+ const SOLO_RESUME_HINTS = new WeakSet();
31
33
  /** Resident Agents refuse the commands their tab bar serves instead, naming where they live. */
32
34
  const RESIDENT_UTILITY_TAB_REFUSALS = Object.freeze({
33
35
  settings: "Settings is available from the global Settings tab.",
@@ -109,6 +111,8 @@ export class OwnedUiSessionPresenter {
109
111
  this.#sessionBindingGeneration = this.backend.identity.sessionBindingGeneration;
110
112
  this.#suggestionModelKey = suggestionModelKey(this.backend.session.view());
111
113
  this.#customViewport = sessionLayout === "custom-viewport";
114
+ if (options.presentation?.soloResumeHint)
115
+ SOLO_RESUME_HINTS.add(this);
112
116
  this.#inputCoordination = inputPresentation?.coordination !== false;
113
117
  this.#host = host;
114
118
  this.#terminal = host.connect(this);
@@ -1084,6 +1088,7 @@ export class OwnedUiSessionPresenter {
1084
1088
  customViewport: this.#customViewport,
1085
1089
  exitMode: this.backend.identity.disposed ? this.#fullscreenExitOutput : this.backend.settings.snapshot().fullscreenExitOutput,
1086
1090
  resume: this.backend.identity.currentSessionResumeMetadata(),
1091
+ soloResumeHint: SOLO_RESUME_HINTS.has(this),
1087
1092
  transcript: () => this.root.exitTranscript(terminal.columns),
1088
1093
  });
1089
1094
  });
@@ -7,6 +7,8 @@ export function cliUsage(capabilities) {
7
7
  "--session <path|id>",
8
8
  "--session-dir <dir> --session <path|id>",
9
9
  "solo",
10
+ "solo --session <path|id>",
11
+ "solo --session-dir <dir> --session <path|id>",
10
12
  ...(capabilities.developmentComparison ? ["pi"] : []),
11
13
  "help",
12
14
  "version",
@@ -36,6 +38,8 @@ export function cliHelp(capabilities) {
36
38
  ` ${command} --session <path|id>`,
37
39
  ` ${command} --session-dir <dir> --session <path|id>`,
38
40
  ` ${command} solo`,
41
+ ` ${command} solo --session <path|id>`,
42
+ ` ${command} solo --session-dir <dir> --session <path|id>`,
39
43
  ...(capabilities.developmentComparison ? [` ${command} pi`] : []),
40
44
  ` ${command} help`,
41
45
  ` ${command} version`,
@@ -145,8 +149,17 @@ export function parseCliCommand(arguments_, capabilities) {
145
149
  return parsePackageCommand(rest, "pi");
146
150
  return capabilities.developmentComparison ? { kind: "launch", profileId: "pi", launchMode: "solo" } : { kind: "noop" };
147
151
  }
148
- if (command === "solo")
149
- return withoutArguments(rest, { kind: "launch", profileId: "a1", launchMode: "solo" });
152
+ if (command === "solo") {
153
+ if (rest.length === 0)
154
+ return { kind: "launch", profileId: "a1", launchMode: "solo" };
155
+ try {
156
+ const sessionSelection = parseSessionSelection(rest);
157
+ return { kind: "launch", profileId: "a1", launchMode: "solo", ...(sessionSelection === undefined ? {} : { sessionSelection }) };
158
+ }
159
+ catch (error) {
160
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic(`could not parse solo session launch: ${error instanceof Error ? error.message : String(error)}`) };
161
+ }
162
+ }
150
163
  if (command === "update")
151
164
  return parseUpdate(rest);
152
165
  if (command === "session")
@@ -24,6 +24,8 @@ export interface OwnedUiCompositionOptions {
24
24
  readonly profileId?: string;
25
25
  /** Hide resident-only tab controls for the explicit direct single-agent launch. */
26
26
  readonly hideTabSettings?: boolean;
27
+ /** The explicit solo launch owns a mode-specific resume hint; resident fallback keeps the tabbed form. */
28
+ readonly soloResumeHint?: boolean;
27
29
  /** `off` withholds A1-specific surfaces from comparison profiles. */
28
30
  readonly ownedSurfaces?: "on" | "off";
29
31
  readonly projectTrustPrompt?: PiProjectTrustPreflightPrompt;
@@ -210,7 +210,7 @@ export async function composeOwnedUi(options = {}) {
210
210
  },
211
211
  presentation: {
212
212
  ...(options.terminal === undefined ? {} : { terminal: createPiTerminalBridge(options.terminal) }),
213
- ...residentStartupPresentation(tabBridge !== null, options.residentFallbackNotice),
213
+ ...residentStartupPresentation(tabBridge !== null, options.residentFallbackNotice), soloResumeHint: options.soloResumeHint === true && tabBridge === null,
214
214
  ...(clipboardDiagnostics === null ? {} : { input: { onEvent: event => clipboardDiagnostics.runtime(event) } }),
215
215
  ...(viewportSettings === null ? {} : { viewportSettings }),
216
216
  ...(backgroundSettings === null ? {} : { backgroundSettings }),
@@ -286,7 +286,7 @@ export async function composeOwnedUi(options = {}) {
286
286
  // Performance: the guardian notice loads beside first paint, never on the startup path.
287
287
  if (exitNoticePath)
288
288
  exitNotice = import("../app/session-shell/exit-notice.js")
289
- .then(module => module.armExitNotice(backend, exitNoticePath), () => null);
289
+ .then(module => module.armExitNotice(backend, exitNoticePath, options.soloResumeHint === true && tabBridge === null), () => null);
290
290
  presenter.start();
291
291
  releaseUpdates.start();
292
292
  },
@@ -345,17 +345,34 @@ function createPromptHistoryConfiguration(settings, backend, ownedSurfaces) {
345
345
  }
346
346
  function createStartupReleaseUpdateObservation(input) {
347
347
  const controller = new AbortController();
348
+ let timer;
349
+ let announcedVersion = null;
350
+ let eligible = input.mode === "auto";
351
+ let cadence = null;
352
+ let nextDelay = null;
353
+ let started = false;
348
354
  const observe = async () => {
349
- const check = input.checkForNewerRelease
350
- ?? (await import("../foundation/release/latest-release.js")).checkForNewerRelease;
355
+ // Performance: registry code and its locks stay outside the first-frame startup graph.
356
+ const releases = await import("../foundation/release/latest-release.js");
357
+ cadence = releases.UPDATE_CHECK_INTERVAL_MS;
358
+ nextDelay = cadence;
359
+ const check = input.checkForNewerRelease ?? releases.checkForNewerRelease;
351
360
  const runningVersion = input.packageVersion ?? await readPackageVersion();
352
361
  const discoveryVersion = input.mode === "notify" ? input.notificationVersion ?? runningVersion : runningVersion;
353
- const release = await check({
362
+ const options = {
354
363
  runningVersion: discoveryVersion,
355
364
  configDir: input.configDir,
356
365
  interactive: process.stdout.isTTY === true,
357
366
  settingMode: input.mode,
358
- });
367
+ signal: controller.signal,
368
+ onNextCheckDelay: milliseconds => { nextDelay = milliseconds; },
369
+ };
370
+ // Invariant: opt-outs never arm a recurring timer, nor read the cache or probe the registry.
371
+ if (input.checkForNewerRelease === undefined && releases.startupReleaseCheckSkipReason(options) !== null) {
372
+ eligible = false;
373
+ return;
374
+ }
375
+ const release = await check(options);
359
376
  if (release === null || controller.signal.aborted)
360
377
  return;
361
378
  if (input.mode === "notify") {
@@ -365,14 +382,27 @@ function createStartupReleaseUpdateObservation(input) {
365
382
  if (input.mode === "auto") {
366
383
  const installed = await import("../foundation/release/automatic-update.js")
367
384
  .then(module => module.requestAutomaticUpdate({ release, runningVersion, signal: controller.signal }));
368
- if (installed !== null && !controller.signal.aborted) {
385
+ if (installed !== null && installed !== announcedVersion && !controller.signal.aborted) {
386
+ announcedVersion = installed;
369
387
  input.backend.extensions.announceReleaseUpdate({ status: "installed", version: installed });
370
388
  }
371
389
  }
372
390
  };
391
+ const run = async () => {
392
+ try {
393
+ await observe();
394
+ }
395
+ catch { /* Rationale: best effort; retry on the next interval. */ }
396
+ if (eligible && cadence !== null && !controller.signal.aborted) {
397
+ timer = setTimeout(() => { void run(); }, nextDelay ?? cadence);
398
+ timer.unref?.();
399
+ }
400
+ };
373
401
  return {
374
- start() { void observe().catch(() => undefined); },
375
- stop() { controller.abort(); },
402
+ start() { if (started || controller.signal.aborted)
403
+ return; started = true; void run(); },
404
+ stop() { controller.abort(); if (timer !== undefined)
405
+ clearTimeout(timer); },
376
406
  };
377
407
  }
378
408
  export { watchSharedSettings } from "./owned-settings.js";
@@ -1,8 +1,8 @@
1
1
  import { sessionSelectionArguments } from "../../foundation/lifecycle/index.js";
2
2
  export { parseSessionSelection } from "../../foundation/lifecycle/index.js";
3
3
  export function interactiveLaunchIntent(profileId, sessionSelection, launchMode = profileId === "a1" ? "tabs" : "solo") {
4
- if (sessionSelection !== undefined && (profileId !== "a1" || launchMode === "solo")) {
5
- throw new Error("session selection requires the normal tabbed A1 launch");
4
+ if (sessionSelection !== undefined && profileId !== "a1") {
5
+ throw new Error("session selection requires the A1 profile");
6
6
  }
7
7
  if (profileId === "pi" && launchMode !== "solo")
8
8
  throw new Error("the Pi comparison launch is always solo");
@@ -9,7 +9,8 @@ import { CohortStateStore } from "./cohort-state.js";
9
9
  import { processIsAlive } from "./process-cleanup.js";
10
10
  import { isNewerRelease, releaseChannelOf } from "./latest-release.js";
11
11
  import { readCertifiedReleaseManifest, resolveReleaseEntryPoint } from "./release-store.js";
12
- import { readUpdateExecutionOwner } from "./update-execution-lease.js";
12
+ import { acquireUpdateExecutionLease, readUpdateExecutionOwner } from "./update-execution-lease.js";
13
+ import { UpdateTransactionStore } from "./update-transaction.js";
13
14
  import { runSelfUpdate, UPDATE_ALREADY_RUNNING_EXIT_CODE } from "./update.js";
14
15
  export const AUTOMATIC_UPDATE_STATE_SCHEMA = 1;
15
16
  export const AUTOMATIC_UPDATE_RETRY_MS = 24 * 60 * 60_000;
@@ -72,10 +73,37 @@ export async function requestAutomaticUpdate(options) {
72
73
  catch {
73
74
  return null;
74
75
  }
75
- const readActiveVersion = options.activeVersion ?? (async () => await activeNewerVersion(paths.dataDir, options.runningVersion));
76
+ const readActiveVersion = options.activeVersion ?? (async () => await activeNewerVersion(paths.dataDir, options.runningVersion, options.release));
76
77
  const active = await readActiveVersion();
77
78
  if (active !== null)
78
79
  return active;
80
+ let owner;
81
+ try {
82
+ owner = await readUpdateExecutionOwner(paths.dataDir);
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ let transaction;
88
+ try {
89
+ transaction = await new UpdateTransactionStore(paths.dataDir).read();
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ // Invariant: recovery of a foreign in-progress transaction belongs to its own owner.
95
+ if (transaction?.status === "active" && (transaction.targetVersion !== options.release.version
96
+ || transaction.channel !== (options.release.channel === "stable" ? "stable" : "next")))
97
+ return null;
98
+ if (existing?.status === "running" && now() - existing.updatedAt >= WORKER_START_GRACE_MS
99
+ && (owner === null || !processIsAlive(owner.pid))) {
100
+ // Invariant: a coordinator record is not ownership. Settle only when no transaction
101
+ // remains to recover; never overwrite or roll back a foreign in-progress target.
102
+ if (transaction === null) {
103
+ existing = { ...existing, status: "failed", updatedAt: now(), failure: "worker-exited" };
104
+ await store.write(existing).catch(() => undefined);
105
+ }
106
+ }
79
107
  if (existing?.status === "failed" && existing.channel === options.release.channel && now() - existing.attemptedAt >= 0
80
108
  && now() - existing.attemptedAt < AUTOMATIC_UPDATE_RETRY_MS
81
109
  && !isNewerRelease(options.release.version, existing.targetVersion))
@@ -100,15 +128,16 @@ export async function requestAutomaticUpdate(options) {
100
128
  "--target-version", options.release.version,
101
129
  "--data-dir", paths.dataDir,
102
130
  ];
103
- let owner;
104
- try {
105
- owner = await readUpdateExecutionOwner(paths.dataDir);
106
- }
107
- catch {
108
- return null;
109
- }
110
131
  if (owner === null || !processIsAlive(owner.pid)) {
111
- await (options.spawnWorker ?? spawnAutomaticUpdateWorker)(worker.entry, arguments_, environment).catch(() => undefined);
132
+ try {
133
+ await (options.spawnWorker ?? spawnAutomaticUpdateWorker)(worker.entry, arguments_, environment);
134
+ }
135
+ catch {
136
+ // Invariant: no worker began this attempt. Do not invent a successful install or an owner.
137
+ await store.write(state({ packageRoot: worker.packageRoot, channel: options.release.channel,
138
+ targetVersion: options.release.version, dataDir: paths.dataDir }, "failed", now(), null, "worker-start-failed")).catch(() => undefined);
139
+ return null;
140
+ }
112
141
  }
113
142
  const startedAt = now();
114
143
  const sleep = options.sleep ?? (async (durationMs) => await new Promise(resolvePromise => {
@@ -134,15 +163,22 @@ export async function runAutomaticUpdateWorker(argv, environment = process.env)
134
163
  const paths = resolveProductPaths(environment);
135
164
  if (!samePath(parsed.dataDir, paths.dataDir))
136
165
  return 1;
137
- const launch = readLaunchContext(environment, "release");
138
- const cohort = await new CohortStateStore(paths.dataDir).read();
139
- const record = launch.releaseId === undefined ? undefined : cohort.releases[launch.releaseId];
140
- if (!record || record.approval !== "approved" || !samePath(record.releaseRoot, launch.releaseRoot ?? "")
141
- || record.contentDigest !== launch.releaseDigest)
142
- return 1;
143
- const selected = await readCertifiedReleaseManifest(record, resolve(paths.dataDir, "releases"));
144
- if (!samePath(selected.packageRoot, parsed.packageRoot))
166
+ let verified = false;
167
+ try {
168
+ const launch = readLaunchContext(environment, "release");
169
+ const cohort = await new CohortStateStore(paths.dataDir).read();
170
+ const record = launch.releaseId === undefined ? undefined : cohort.releases[launch.releaseId];
171
+ if (record?.approval === "approved" && samePath(record.releaseRoot, launch.releaseRoot ?? "")
172
+ && record.contentDigest === launch.releaseDigest) {
173
+ const selected = await readCertifiedReleaseManifest(record, resolve(paths.dataDir, "releases"));
174
+ verified = samePath(selected.packageRoot, parsed.packageRoot);
175
+ }
176
+ }
177
+ catch { /* Security: invalid provenance fails closed without running the updater. */ }
178
+ if (!verified) {
179
+ await recordAutomaticPreflightFailure(parsed, paths.dataDir);
145
180
  return 1;
181
+ }
146
182
  const store = new AutomaticUpdateStateStore(paths.dataDir);
147
183
  const attemptedAt = Date.now();
148
184
  const output = { stdout() { }, stderr() { } };
@@ -166,7 +202,10 @@ export async function runAutomaticUpdateWorker(argv, environment = process.env)
166
202
  return 0;
167
203
  if (code !== 0) {
168
204
  await runningWrite;
169
- await store.write(state(parsed, "failed", attemptedAt, null, `exit-${code}`));
205
+ if (acquired)
206
+ await store.write(state(parsed, "failed", attemptedAt, null, `exit-${code}`));
207
+ else
208
+ await recordAutomaticPreflightFailure(parsed, paths.dataDir);
170
209
  return code;
171
210
  }
172
211
  const active = await activeRelease(paths.dataDir);
@@ -176,6 +215,8 @@ export async function runAutomaticUpdateWorker(argv, environment = process.env)
176
215
  await runningWrite;
177
216
  await store.write(state(parsed, "failed", attemptedAt, null, "target-not-active"));
178
217
  }
218
+ else
219
+ await recordAutomaticPreflightFailure(parsed, paths.dataDir);
179
220
  return 1;
180
221
  }
181
222
  await runningWrite;
@@ -184,10 +225,34 @@ export async function runAutomaticUpdateWorker(argv, environment = process.env)
184
225
  }
185
226
  catch (error) {
186
227
  await runningWrite.catch(() => undefined);
187
- await store.write(state(parsed, "failed", attemptedAt, null, errorMessage(error).slice(0, 512))).catch(() => undefined);
228
+ if (acquired)
229
+ await store.write(state(parsed, "failed", attemptedAt, null, errorMessage(error).slice(0, 512))).catch(() => undefined);
230
+ else
231
+ await recordAutomaticPreflightFailure(parsed, paths.dataDir);
188
232
  return 1;
189
233
  }
190
234
  }
235
+ async function recordAutomaticPreflightFailure(parsed, dataDir) {
236
+ // Security: even a rejected worker must claim the ordinary update lease before recording
237
+ // failure, so it cannot overwrite another owner's result or an unfinished transaction.
238
+ const lease = await acquireUpdateExecutionLease({ dataDir, packageRoot: parsed.packageRoot,
239
+ channel: parsed.channel === "stable" ? "stable" : "next", targetVersion: parsed.targetVersion, mode: "automatic" }).catch(() => null);
240
+ if (lease?.status !== "acquired")
241
+ return;
242
+ try {
243
+ if (await new UpdateTransactionStore(dataDir).read() !== null)
244
+ return;
245
+ const store = new AutomaticUpdateStateStore(dataDir);
246
+ const existing = await store.read();
247
+ if (existing !== null && (existing.targetVersion !== parsed.targetVersion || existing.status === "installed"))
248
+ return;
249
+ await store.write(state(parsed, "failed", Date.now(), null, "worker-preflight-failed"));
250
+ }
251
+ catch { /* Rationale: preflight failure is best effort; never mask the rejected worker. */ }
252
+ finally {
253
+ await lease.release().catch(() => undefined);
254
+ }
255
+ }
191
256
  async function spawnAutomaticUpdateWorker(entry, arguments_, environment) {
192
257
  const child = spawn(process.execPath, [entry, ...arguments_], {
193
258
  detached: true,
@@ -201,9 +266,12 @@ async function spawnAutomaticUpdateWorker(entry, arguments_, environment) {
201
266
  });
202
267
  child.unref();
203
268
  }
204
- async function activeNewerVersion(dataDir, runningVersion) {
269
+ async function activeNewerVersion(dataDir, runningVersion, target) {
205
270
  const active = await activeRelease(dataDir);
206
- return active !== null && isNewerRelease(active.packageVersion, runningVersion) ? active.packageVersion : null;
271
+ return active !== null && releaseChannelOf(active.packageVersion) === target.channel
272
+ && isNewerRelease(active.packageVersion, runningVersion)
273
+ && (active.packageVersion === target.version || isNewerRelease(active.packageVersion, target.version))
274
+ ? active.packageVersion : null;
207
275
  }
208
276
  async function activeRelease(dataDir) {
209
277
  const cohort = await new CohortStateStore(dataDir).read();
@@ -31,8 +31,8 @@ async function runBootstrapAttempt(options, retriesRemaining) {
31
31
  await markStartupPhase(environment, "bootstrap-start");
32
32
  assertLaunchProfileId(launchProfileId);
33
33
  const sessionArgs = sessionSelectionArguments(options.launchIntent?.sessionSelection);
34
- if (sessionArgs.length > 0 && (launchProfileId !== "a1" || launchMode === "solo")) {
35
- throw new Error("session selection requires the normal tabbed A1 launch");
34
+ if (sessionArgs.length > 0 && launchProfileId !== "a1") {
35
+ throw new Error("session selection requires the A1 profile");
36
36
  }
37
37
  const output = options.output ?? process.stderr;
38
38
  const paths = resolveProductPaths(environment);
@@ -67,7 +67,7 @@ async function runBootstrapAttempt(options, retriesRemaining) {
67
67
  resident.executable.entry,
68
68
  "attach", "--endpoint", resident.endpoint, "--cwd", process.cwd(), "--clear-exit-ms", String(RESIDENT_DETACH_INTERVAL_MS),
69
69
  "--profile", environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir,
70
- "--settings-file", resolve(agentProfileDir, "settings.json"),
70
+ "--settings-file", resolve(agentProfileDir, "settings.json"), "--accent-color", (await residentLaunchSettings).accentColor,
71
71
  "--data-dir", paths.dataDir, "--runtime-dir", paths.runtimeDir,
72
72
  "--release-root", release.releaseRoot, "--node-executable", process.execPath,
73
73
  ]),
@@ -21,6 +21,7 @@ export interface AvailableRelease {
21
21
  }
22
22
  export declare const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
23
23
  export declare const UPDATE_CHECK_INTERVAL_MS: number;
24
+ export declare const NOTIFY_CHECK_INTERVAL_MS: number;
24
25
  /** One published version with the pull request its manifest records, when it records one. */
25
26
  export interface PublishedVersion {
26
27
  readonly version: string;
@@ -54,7 +55,7 @@ export declare function parsePublishedVersions(document: unknown): PublishedVers
54
55
  /** Forget the startup release check so the next interactive launch asks the registry again. */
55
56
  export declare function invalidateReleaseCheckCache(configDir: string): Promise<void>;
56
57
  export declare function unavailableDistTags(error: string): DistTagVersions;
57
- export declare function createRegistryFetcher(timeoutMs: number): RegistryFetcher;
58
+ export declare function createRegistryFetcher(timeoutMs: number, signal?: AbortSignal): RegistryFetcher;
58
59
  export interface StartupReleaseCheckOptions {
59
60
  readonly runningVersion: string;
60
61
  readonly configDir: string;
@@ -64,6 +65,9 @@ export interface StartupReleaseCheckOptions {
64
65
  /** The bare-A1 `updateCheck` setting; comparison profiles omit it and use auto. */
65
66
  readonly settingMode?: StartupUpdateMode;
66
67
  readonly fetcher?: RegistryFetcher;
68
+ readonly signal?: AbortSignal;
69
+ /** Remaining cache lifetime for a live Auto observer, when discovery succeeds. */
70
+ readonly onNextCheckDelay?: (milliseconds: number) => void;
67
71
  readonly now?: () => number;
68
72
  }
69
73
  /** Why the startup check is skipped before any cache read or network access, or null when it may run. */
@@ -2,12 +2,14 @@ import { randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { gt, prerelease, valid as validSemver } from "semver";
5
+ import lockfile from "proper-lockfile";
5
6
  import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
6
7
  export const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
7
- export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60_000;
8
+ export const UPDATE_CHECK_INTERVAL_MS = 15 * 60_000;
9
+ export const NOTIFY_CHECK_INTERVAL_MS = 24 * 60 * 60_000;
8
10
  const STARTUP_FETCH_TIMEOUT_MS = 3_000;
9
11
  const CACHE_FILENAME = "update-check.json";
10
- const CACHE_VERSION = 1;
12
+ const CACHE_VERSION = 2;
11
13
  const MAX_CACHE_BYTES = 4096;
12
14
  const MAX_PACKAGE_DOCUMENT_BYTES = 32 * 1024 * 1024;
13
15
  const DEVELOPMENT_BUILD = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-dev\.(0|[1-9]\d*)$/u;
@@ -105,13 +107,14 @@ export function parsePublishedVersions(document) {
105
107
  }
106
108
  /** Forget the startup release check so the next interactive launch asks the registry again. */
107
109
  export async function invalidateReleaseCheckCache(configDir) {
108
- await rm(join(configDir, CACHE_FILENAME), { force: true });
110
+ await withCacheLock(configDir, async () => { await rm(join(configDir, CACHE_FILENAME), { force: true }); });
109
111
  }
110
112
  export function unavailableDistTags(error) {
111
113
  return { release: null, develop: null, error };
112
114
  }
113
- export function createRegistryFetcher(timeoutMs) {
114
- return async (url) => await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
115
+ export function createRegistryFetcher(timeoutMs, signal) {
116
+ return async (url) => await fetch(url, { headers: { accept: "application/json" },
117
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs) });
115
118
  }
116
119
  /** Why the startup check is skipped before any cache read or network access, or null when it may run. */
117
120
  export function startupReleaseCheckSkipReason(options) {
@@ -136,22 +139,33 @@ export function startupReleaseCheckSkipReason(options) {
136
139
  * answer replaces the cache. Every failure resolves to null: the notice is best-effort.
137
140
  */
138
141
  export async function checkForNewerRelease(options) {
139
- if (startupReleaseCheckSkipReason(options) !== null)
142
+ if (options.signal?.aborted || startupReleaseCheckSkipReason(options) !== null)
140
143
  return null;
141
144
  const channel = releaseChannelOf(options.runningVersion);
142
145
  const environment = options.environment ?? process.env;
143
146
  const now = options.now ?? Date.now;
144
147
  const cachePath = join(options.configDir, CACHE_FILENAME);
145
148
  try {
146
- let latest = await readFreshCache(cachePath, channel, now());
147
- if (latest === null) {
148
- const tags = await queryRegistryDistTags(options.fetcher ?? createRegistryFetcher(STARTUP_FETCH_TIMEOUT_MS), environment.npm_config_registry?.trim() || undefined);
149
- latest = channel === "stable" ? tags.release : tags.develop;
150
- if (tags.error !== null || latest === null)
149
+ const interval = options.settingMode === "notify" ? NOTIFY_CHECK_INTERVAL_MS : UPDATE_CHECK_INTERVAL_MS;
150
+ const latest = await withCacheLock(options.configDir, async () => {
151
+ if (options.signal?.aborted)
151
152
  return null;
152
- await writeCache(options.configDir, cachePath, { version: CACHE_VERSION, channel, latest, checkedAt: now() }).catch(() => undefined);
153
- }
154
- return isNewerRelease(latest, options.runningVersion) ? availableRelease(channel, latest) : null;
153
+ const document = await readCache(cachePath);
154
+ const cached = freshVersion(document[channel], now(), interval);
155
+ if (cached !== null)
156
+ return cached;
157
+ const tags = await queryRegistryDistTags(options.fetcher ?? createRegistryFetcher(STARTUP_FETCH_TIMEOUT_MS, options.signal), environment.npm_config_registry?.trim() || undefined);
158
+ const discovered = channel === "stable" ? tags.release : tags.develop;
159
+ if (tags.error !== null || discovered === null || options.signal?.aborted)
160
+ return null;
161
+ const entry = { latest: discovered, checkedAt: now() };
162
+ await writeCache(options.configDir, cachePath, { ...document, [channel]: entry }).catch(() => undefined);
163
+ return entry;
164
+ });
165
+ if (options.signal?.aborted || latest === null)
166
+ return null;
167
+ options.onNextCheckDelay?.(Math.min(interval, Math.max(1, interval - (now() - latest.checkedAt))));
168
+ return isNewerRelease(latest.latest, options.runningVersion) ? availableRelease(channel, latest.latest) : null;
155
169
  }
156
170
  catch {
157
171
  return null;
@@ -165,29 +179,58 @@ export function availableRelease(channel, version) {
165
179
  changelogUrl: channel === "stable" ? `${RELEASE_PAGE}${version}` : null,
166
180
  };
167
181
  }
168
- async function readFreshCache(path, channel, now) {
169
- let parsed;
182
+ function validEntry(value, channel) {
183
+ if (typeof value !== "object" || value === null)
184
+ return undefined;
185
+ const entry = value;
186
+ return typeof entry.latest === "string" && releaseChannelOf(entry.latest) === channel
187
+ && typeof entry.checkedAt === "number" && Number.isFinite(entry.checkedAt) ? { latest: entry.latest, checkedAt: entry.checkedAt } : undefined;
188
+ }
189
+ async function readCache(path) {
170
190
  try {
171
191
  const source = await readFile(path, "utf8");
172
- if (source.length > MAX_CACHE_BYTES)
173
- return null;
174
- parsed = JSON.parse(source);
192
+ if (Buffer.byteLength(source, "utf8") > MAX_CACHE_BYTES)
193
+ return { version: CACHE_VERSION };
194
+ const value = JSON.parse(source);
195
+ if (typeof value !== "object" || value === null || Array.isArray(value))
196
+ return { version: CACHE_VERSION };
197
+ const cache = value;
198
+ if (cache.version === 1) {
199
+ // Compatibility: an old cache has exactly one channel; do not infer a value for the other.
200
+ if (cache.channel !== "stable" && cache.channel !== "development")
201
+ return { version: CACHE_VERSION };
202
+ const entry = validEntry(cache, cache.channel);
203
+ return entry ? { version: CACHE_VERSION, [cache.channel]: entry } : { version: CACHE_VERSION };
204
+ }
205
+ if (cache.version !== CACHE_VERSION)
206
+ return { version: CACHE_VERSION };
207
+ const stable = validEntry(cache.stable, "stable");
208
+ const development = validEntry(cache.development, "development");
209
+ return { version: CACHE_VERSION, ...(stable ? { stable } : {}), ...(development ? { development } : {}) };
175
210
  }
176
211
  catch {
177
- return null;
212
+ return { version: CACHE_VERSION };
178
213
  }
179
- if (typeof parsed !== "object" || parsed === null)
180
- return null;
181
- const cache = parsed;
182
- if (cache.version !== CACHE_VERSION || cache.channel !== channel)
183
- return null;
184
- if (typeof cache.latest !== "string" || validSemver(cache.latest) === null)
185
- return null;
186
- if (typeof cache.checkedAt !== "number" || !Number.isFinite(cache.checkedAt))
214
+ }
215
+ function freshVersion(entry, now, interval) {
216
+ if (!entry)
187
217
  return null;
188
- // Invariant: a timestamp from the future (clock moved back) is stale rather than fresh forever.
189
- const age = now - cache.checkedAt;
190
- return age >= 0 && age < UPDATE_CHECK_INTERVAL_MS ? cache.latest : null;
218
+ // Invariant: a timestamp from the future is stale rather than fresh forever.
219
+ const age = now - entry.checkedAt;
220
+ return age >= 0 && age < interval ? entry : null;
221
+ }
222
+ async function withCacheLock(directory, operation) {
223
+ await mkdir(directory, { recursive: true });
224
+ const release = await lockfile.lock(join(directory, CACHE_FILENAME), {
225
+ realpath: false, stale: 30_000, update: 10_000,
226
+ retries: { retries: 20, factor: 1.2, minTimeout: 20, maxTimeout: 100, randomize: true },
227
+ });
228
+ try {
229
+ return await operation();
230
+ }
231
+ finally {
232
+ await release();
233
+ }
191
234
  }
192
235
  async function writeCache(directory, path, document) {
193
236
  await mkdir(directory, { recursive: true });
@@ -239,7 +239,7 @@ export function createUpdateLifecycleCoordinator(environment = process.env, file
239
239
  },
240
240
  };
241
241
  }
242
- const PROGRESS_BAR_WIDTH = 40;
242
+ const PROGRESS_BAR_WIDTH = 38;
243
243
  const PROGRESS_TICK_MS = 200;
244
244
  /**
245
245
  * Copying the release owns 78–92 and is reported file by file, so the bar crosses
@@ -1,8 +1,11 @@
1
+ import { OWNED_SETTING_DECLARATIONS } from "../../ui/settings/index.js";
1
2
  export declare const RESIDENT_TABS_MAX_CONCURRENT_STARTS = 2;
2
3
  export declare const RESIDENT_TABS_MAX_CONCURRENT_STARTS_CAP = 8;
3
4
  export declare const RESIDENT_TABS_PREWARM_COUNT = 1;
4
5
  export declare const RESIDENT_TABS_PREWARM_CAP = 1;
6
+ type ResidentAccentColor = (typeof OWNED_SETTING_DECLARATIONS.accentColor.allowedValues)[number];
5
7
  export interface ResidentTabLaunchSettings {
8
+ readonly accentColor: ResidentAccentColor;
6
9
  readonly tabsAutoName: boolean;
7
10
  readonly maxConcurrentStarts: number;
8
11
  readonly prewarmCount: number;
@@ -10,3 +13,4 @@ export interface ResidentTabLaunchSettings {
10
13
  }
11
14
  /** Read only resident-launch values before any UI or extension module is loaded. */
12
15
  export declare function readResidentTabLaunchSettings(profileDir: string, namespace?: string, legacyConfigDir?: string, profileId?: string): Promise<ResidentTabLaunchSettings>;
16
+ export {};
@@ -7,6 +7,7 @@ export const RESIDENT_TABS_MAX_CONCURRENT_STARTS_CAP = 8;
7
7
  export const RESIDENT_TABS_PREWARM_COUNT = 1;
8
8
  export const RESIDENT_TABS_PREWARM_CAP = 1;
9
9
  const DEFAULTS = Object.freeze({
10
+ accentColor: "blue",
10
11
  tabsAutoName: true,
11
12
  maxConcurrentStarts: RESIDENT_TABS_MAX_CONCURRENT_STARTS,
12
13
  prewarmCount: RESIDENT_TABS_PREWARM_COUNT,
@@ -44,12 +45,13 @@ export async function readResidentTabLaunchSettings(profileDir, namespace = "a1"
44
45
  }
45
46
  function resolveLaunchValues(document, migrations) {
46
47
  const resolution = resolveOwnedUiSettings({
47
- declarations: [OWNED_SETTING_DECLARATIONS.tabsAutoName],
48
+ declarations: [OWNED_SETTING_DECLARATIONS.accentColor, OWNED_SETTING_DECLARATIONS.tabsAutoName],
48
49
  migrations,
49
50
  document,
50
51
  currentVersion: OWNED_UI_SETTINGS_VERSION,
51
52
  });
52
53
  return {
54
+ accentColor: settingValue(resolution, "accentColor"),
53
55
  tabsAutoName: settingValue(resolution, "tabsAutoName") === true,
54
56
  maxConcurrentStarts: RESIDENT_TABS_MAX_CONCURRENT_STARTS,
55
57
  prewarmCount: RESIDENT_TABS_PREWARM_COUNT,
@@ -7,7 +7,7 @@
7
7
  "platform": "darwin",
8
8
  "architecture": "arm64",
9
9
  "capability": "supported",
10
- "builtAt": "2026-10-28T17:55:49.435Z",
10
+ "builtAt": "2026-10-29T18:55:24.681Z",
11
11
  "artifact": {
12
12
  "filename": "process-guardian.node",
13
13
  "sha256": "a87eaaffcd986b624c8b044b756446cc1bdaf4334a1775b97820542fe34f9fb7",
@@ -9,11 +9,11 @@
9
9
  "architecture": "arm64",
10
10
  "target": "aarch64-apple-darwin",
11
11
  "capability": "supported",
12
- "builtAt": "2026-10-28T17:59:01.060Z",
12
+ "builtAt": "2026-10-29T18:58:27.392Z",
13
13
  "artifact": {
14
14
  "filename": "terminal-host.node",
15
- "sha256": "5f1a93ca9a050c2769a4e322f4d765b00f2ccc9451bcde7ac978b573ee544bef",
16
- "size": 6019328
15
+ "sha256": "4c5b6471dd48f1f571c226022b01038dbba09798880ead564bd174617e60fc00",
16
+ "size": 6019520
17
17
  },
18
18
  "entry": {
19
19
  "filename": "dist/foundation/resident-tabs/host-entry.js",
@@ -7,7 +7,7 @@
7
7
  "platform": "darwin",
8
8
  "architecture": "x64",
9
9
  "capability": "supported",
10
- "builtAt": "2026-10-28T17:55:56.614Z",
10
+ "builtAt": "2026-10-29T18:55:32.496Z",
11
11
  "artifact": {
12
12
  "filename": "process-guardian.node",
13
13
  "sha256": "783a65f0de287a1c02cd5429dbe97bc8633f4cc8a7b92e3e961180b9bdcff814",
@@ -9,11 +9,11 @@
9
9
  "architecture": "x64",
10
10
  "target": "x86_64-apple-darwin",
11
11
  "capability": "supported",
12
- "builtAt": "2026-10-28T18:01:29.016Z",
12
+ "builtAt": "2026-10-29T19:00:47.347Z",
13
13
  "artifact": {
14
14
  "filename": "terminal-host.node",
15
- "sha256": "5b2b2de17a50a371271c32546cc3c89bc6b16a96c39a534174fe5bb4ca1d60d6",
16
- "size": 6562120
15
+ "sha256": "c9b2b998c06bb582db4b90639b8dbacdc698ef6c4b0361a2a0d19fbe9adfadbf",
16
+ "size": 6562328
17
17
  },
18
18
  "entry": {
19
19
  "filename": "dist/foundation/resident-tabs/host-entry.js",
@@ -7,7 +7,7 @@
7
7
  "platform": "linux",
8
8
  "architecture": "x64",
9
9
  "capability": "supported",
10
- "builtAt": "2026-10-28T17:55:36.629Z",
10
+ "builtAt": "2026-10-29T18:55:03.862Z",
11
11
  "artifact": {
12
12
  "filename": "process-guardian.node",
13
13
  "sha256": "118e5954e3864672bd8482ab7edf419cb27dcdf2533720316979b7f5cc5a53a5",
@@ -9,11 +9,11 @@
9
9
  "architecture": "x64",
10
10
  "target": "x86_64-unknown-linux-gnu",
11
11
  "capability": "supported",
12
- "builtAt": "2026-10-28T17:58:24.749Z",
12
+ "builtAt": "2026-10-29T18:58:26.007Z",
13
13
  "artifact": {
14
14
  "filename": "terminal-host.node",
15
- "sha256": "d55bf113092dcb926a6fb10961f30207b3624c3727357de0913a01a40026f594",
16
- "size": 7303320
15
+ "sha256": "5ab7264cbbd505e2455b05d11b2ca2a8378d007f20e0730e25cb84a6e98096cf",
16
+ "size": 7302056
17
17
  },
18
18
  "entry": {
19
19
  "filename": "dist/foundation/resident-tabs/host-entry.js",
@@ -7,10 +7,10 @@
7
7
  "platform": "win32",
8
8
  "architecture": "x64",
9
9
  "capability": "supported",
10
- "builtAt": "2026-10-28T17:56:27.071Z",
10
+ "builtAt": "2026-10-29T18:55:37.021Z",
11
11
  "artifact": {
12
12
  "filename": "process-guardian.node",
13
- "sha256": "f071fdb99dd7c653701160d127e6f5263bccc043fe8d28c133fdaac44e491cfa",
13
+ "sha256": "b11440d8ba81b69e18061dd2e73934ce4723ef3250c19a47a1563378d045268a",
14
14
  "size": 402432
15
15
  },
16
16
  "provenance": {
@@ -9,11 +9,11 @@
9
9
  "architecture": "x64",
10
10
  "target": "x86_64-pc-windows-msvc",
11
11
  "capability": "supported",
12
- "builtAt": "2026-10-28T17:58:56.213Z",
12
+ "builtAt": "2026-10-29T18:59:32.923Z",
13
13
  "artifact": {
14
14
  "filename": "terminal-host.node",
15
- "sha256": "5f5b7bf855be2b8948fffc4de53a3456672ce7646d2c0dfc3c0ae9eb847980cf",
16
- "size": 6392832
15
+ "sha256": "f6e448a810795d4d7db437d591e43fbf998e05b63f6cac37f56d3270e422c861",
16
+ "size": 6393344
17
17
  },
18
18
  "entry": {
19
19
  "filename": "dist/foundation/resident-tabs/host-entry.js",
@@ -30,7 +30,7 @@ export declare const OWNED_SETTING_DECLARATIONS: Readonly<{
30
30
  id: "generic";
31
31
  title: "Generic";
32
32
  }>;
33
- description: "Auto installs verified channel updates silently, Notify shows the current update notice, and No leaves updates manual. Checks use the public npm registry; applies on next start.";
33
+ description: "Auto checks the running channel every 15 minutes while open and installs verified updates silently. Notify checks on startup and shows an update notice; No leaves updates manual. Applies on next start.";
34
34
  application: "restart";
35
35
  defaultValue: "auto";
36
36
  allowedValues: readonly ["auto", "notify", "no"];
@@ -24,7 +24,7 @@ export const OWNED_SETTING_DECLARATIONS = Object.freeze({
24
24
  id: "updateCheck",
25
25
  label: "Update",
26
26
  section: GENERIC_SECTION,
27
- description: "Auto installs verified channel updates silently, Notify shows the current update notice, and No leaves updates manual. Checks use the public npm registry; applies on next start.",
27
+ description: "Auto checks the running channel every 15 minutes while open and installs verified updates silently. Notify checks on startup and shows an update notice; No leaves updates manual. Applies on next start.",
28
28
  application: "restart",
29
29
  defaultValue: "auto",
30
30
  allowedValues: Object.freeze(["auto", "notify", "no"]),
@@ -6,10 +6,11 @@
6
6
  |---|---|---|
7
7
  | `a1` | A1 | `~/.a1/agent` |
8
8
  | `a1 --session <path\|id>` | Resume a persisted A1 session | `~/.a1/agent` (or explicitly selected storage) |
9
- | `a1 solo` | A1 direct single-agent mode | `~/.a1/agent` |
9
+ | `a1 solo` | Fresh A1 direct single-agent mode | `~/.a1/agent` |
10
+ | `a1 solo --session <path\|id>` | Resume a persisted A1 session in direct single-agent mode | `~/.a1/agent` (or explicitly selected storage) |
10
11
  | `a1 pi` | Pi-compatible direct comparison surface using the ordinary Pi profile | ordinary `~/.pi/agent` |
11
12
 
12
- `a1 solo` is available in release and prerelease builds. It starts A1 in single agent mode, is argument-free, and omits both the tab strip and the Tabs settings section.
13
+ `a1 solo` is available in release and prerelease builds. Without arguments it starts a fresh single-agent session; with `--session` and optional `--session-dir` it resumes a saved session. Both forms omit the tab strip and the Tabs settings section.
13
14
 
14
15
  `a1 pi` is a development instrument for comparing A1 against pinned Pi. Prerelease builds — what `a1 update --develop` or `a1 update --develop <number>` installs — expose it. A release build does not recognize the launch form. Repository development can run the comparison directly with `npm start:pi`.
15
16
 
@@ -36,16 +37,18 @@ Run Pi’s normal `/login` independently in each profile that needs stored authe
36
37
 
37
38
  ## Resume a session
38
39
 
39
- After a conversation has been persisted, exit A1 and run the command it prints from the original project directory:
40
+ After a conversation has been persisted, exit A1 and run the command it prints from the original project directory. Exited solo sessions print the solo form; tabbed A1 sessions keep the tabbed form:
40
41
 
41
42
  ```sh
42
43
  a1 --session <id>
43
44
  a1 --session-dir '/path/to/session store' --session <id>
45
+ a1 solo --session <id>
46
+ a1 solo --session-dir '/path/to/session store' --session <id>
44
47
  ```
45
48
 
46
49
  `--session` also accepts a `.jsonl` file path; `--session-dir` may come before or after it. Explicit storage overrides `PI_CODING_AGENT_SESSION_DIR`, which overrides default storage. IDs are searched in the current project before other projects within the A1 store. A cross-project ID match asks before creating a fork with a new identity; an explicit file resumes directly using its stored working directory. Missing or invalid targets fail rather than create an empty replacement.
47
50
 
48
- Resume uses the shipped Pi's public restoration behavior, including the compaction format it writes. It sends no model prompt by itself and does not recover, convert, or modify an unrelated conversation. The selected session opens in a new A1 tab or focuses the tab already holding it. Pi picker/continue CLI aliases, `a1 solo --session`, and `a1 pi --session` are not supported; in-UI `/resume` is unchanged.
51
+ Resume uses the shipped Pi's public restoration behavior, including the compaction format it writes. It sends no model prompt by itself and does not recover, convert, or modify an unrelated conversation. A tabbed selection opens in a new A1 tab or focuses the tab already holding it; a solo selection opens in the direct strip-free UI without starting the resident host. A live session remains protected by its single-writer lease. Pi picker/continue CLI aliases and `a1 pi --session` are not supported; in-UI `/resume` is unchanged.
49
52
 
50
53
  Regression evidence creates disposable sessions through Pi 0.84.2, then executes the emitted command through the packed public entry, bootstrap, guardian, and owned UI. Default/custom storage, Windows Git Bash quoting, supervisor restart, failure cleanup, and simultaneous distinct selections are covered in `test/foundation/release/session-resume.integration.test.ts`. CI runs it in the resource-sensitive `package-smoke` scope, not the fast remainder. Unsupported synthetic retained-tail certification is separate deferred work.
51
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurproko/a1",
3
- "version": "0.2.7-dev.1222",
3
+ "version": "0.2.7-dev.1226",
4
4
  "description": "Standalone terminal workspace for supervised native and managed agents",
5
5
  "type": "module",
6
6
  "privateLaunchContract": "neutral-launch-v1",
@@ -118,7 +118,7 @@
118
118
  "@mariozechner/clipboard": "^0.3.9"
119
119
  },
120
120
  "publication": {
121
- "source": "0ec0a4fd915c5e452ca135159dcb58e810dea96f",
122
- "pullRequest": 847
121
+ "source": "9fccb714f2912d0acdd9e7a4ed05d6d8423a82bd",
122
+ "pullRequest": 851
123
123
  }
124
124
  }