@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.
- package/README.md +8 -6
- package/bin/ui.js +1 -0
- package/dist/app/session-shell/exit-notice.d.ts +2 -2
- package/dist/app/session-shell/exit-notice.js +4 -4
- package/dist/app/session-shell/session-lifecycle.d.ts +2 -1
- package/dist/app/session-shell/session-lifecycle.js +4 -2
- package/dist/app/session-shell/session-shell-root.d.ts +2 -0
- package/dist/app/session-shell/session-shell.js +5 -0
- package/dist/cli/dispatch.js +15 -2
- package/dist/composition/owned-ui.d.ts +2 -0
- package/dist/composition/owned-ui.js +39 -9
- package/dist/features/launch/intent.js +2 -2
- package/dist/foundation/release/automatic-update.js +90 -22
- package/dist/foundation/release/bootstrap.js +3 -3
- package/dist/foundation/release/latest-release.d.ts +5 -1
- package/dist/foundation/release/latest-release.js +74 -31
- package/dist/foundation/release/update.js +1 -1
- package/dist/foundation/resident-tabs/settings.d.ts +4 -0
- package/dist/foundation/resident-tabs/settings.js +3 -1
- package/dist/native/darwin-arm64/manifest.json +1 -1
- package/dist/native/darwin-arm64/terminal-host.manifest.json +3 -3
- package/dist/native/darwin-arm64/terminal-host.node +0 -0
- package/dist/native/darwin-x64/manifest.json +1 -1
- package/dist/native/darwin-x64/terminal-host.manifest.json +3 -3
- package/dist/native/darwin-x64/terminal-host.node +0 -0
- package/dist/native/linux-x64/manifest.json +1 -1
- package/dist/native/linux-x64/terminal-host.manifest.json +3 -3
- package/dist/native/linux-x64/terminal-host.node +0 -0
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.node +0 -0
- package/dist/native/win32-x64/terminal-host.manifest.json +3 -3
- package/dist/native/win32-x64/terminal-host.node +0 -0
- package/dist/ui/settings/declarations.d.ts +1 -1
- package/dist/ui/settings/declarations.js +1 -1
- package/docs/features/launch-profiles.md +7 -4
- 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
|
|
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
|
|
150
|
+
The **Update** setting controls release discovery:
|
|
149
151
|
|
|
150
|
-
- **Auto** (default) silently installs a verified update
|
|
151
|
-
- **Notify** keeps the `Update Available` notice and leaves installation manual.
|
|
152
|
-
- **No** performs no
|
|
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`
|
|
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
|
});
|
package/dist/cli/dispatch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
|
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() {
|
|
375
|
-
|
|
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 &&
|
|
5
|
-
throw new Error("session selection requires the
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 &&
|
|
35
|
-
throw new Error("session selection requires the
|
|
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 =
|
|
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 =
|
|
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" },
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
|
173
|
-
return
|
|
174
|
-
|
|
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
|
|
212
|
+
return { version: CACHE_VERSION };
|
|
178
213
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
189
|
-
const age = now -
|
|
190
|
-
return age >= 0 && age <
|
|
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 =
|
|
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-
|
|
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-
|
|
12
|
+
"builtAt": "2026-10-29T18:58:27.392Z",
|
|
13
13
|
"artifact": {
|
|
14
14
|
"filename": "terminal-host.node",
|
|
15
|
-
"sha256": "
|
|
16
|
-
"size":
|
|
15
|
+
"sha256": "4c5b6471dd48f1f571c226022b01038dbba09798880ead564bd174617e60fc00",
|
|
16
|
+
"size": 6019520
|
|
17
17
|
},
|
|
18
18
|
"entry": {
|
|
19
19
|
"filename": "dist/foundation/resident-tabs/host-entry.js",
|
|
Binary file
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"platform": "darwin",
|
|
8
8
|
"architecture": "x64",
|
|
9
9
|
"capability": "supported",
|
|
10
|
-
"builtAt": "2026-10-
|
|
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-
|
|
12
|
+
"builtAt": "2026-10-29T19:00:47.347Z",
|
|
13
13
|
"artifact": {
|
|
14
14
|
"filename": "terminal-host.node",
|
|
15
|
-
"sha256": "
|
|
16
|
-
"size":
|
|
15
|
+
"sha256": "c9b2b998c06bb582db4b90639b8dbacdc698ef6c4b0361a2a0d19fbe9adfadbf",
|
|
16
|
+
"size": 6562328
|
|
17
17
|
},
|
|
18
18
|
"entry": {
|
|
19
19
|
"filename": "dist/foundation/resident-tabs/host-entry.js",
|
|
Binary file
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"platform": "linux",
|
|
8
8
|
"architecture": "x64",
|
|
9
9
|
"capability": "supported",
|
|
10
|
-
"builtAt": "2026-10-
|
|
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-
|
|
12
|
+
"builtAt": "2026-10-29T18:58:26.007Z",
|
|
13
13
|
"artifact": {
|
|
14
14
|
"filename": "terminal-host.node",
|
|
15
|
-
"sha256": "
|
|
16
|
-
"size":
|
|
15
|
+
"sha256": "5ab7264cbbd505e2455b05d11b2ca2a8378d007f20e0730e25cb84a6e98096cf",
|
|
16
|
+
"size": 7302056
|
|
17
17
|
},
|
|
18
18
|
"entry": {
|
|
19
19
|
"filename": "dist/foundation/resident-tabs/host-entry.js",
|
|
Binary file
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
"platform": "win32",
|
|
8
8
|
"architecture": "x64",
|
|
9
9
|
"capability": "supported",
|
|
10
|
-
"builtAt": "2026-10-
|
|
10
|
+
"builtAt": "2026-10-29T18:55:37.021Z",
|
|
11
11
|
"artifact": {
|
|
12
12
|
"filename": "process-guardian.node",
|
|
13
|
-
"sha256": "
|
|
13
|
+
"sha256": "b11440d8ba81b69e18061dd2e73934ce4723ef3250c19a47a1563378d045268a",
|
|
14
14
|
"size": 402432
|
|
15
15
|
},
|
|
16
16
|
"provenance": {
|
|
Binary file
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
"architecture": "x64",
|
|
10
10
|
"target": "x86_64-pc-windows-msvc",
|
|
11
11
|
"capability": "supported",
|
|
12
|
-
"builtAt": "2026-10-
|
|
12
|
+
"builtAt": "2026-10-29T18:59:32.923Z",
|
|
13
13
|
"artifact": {
|
|
14
14
|
"filename": "terminal-host.node",
|
|
15
|
-
"sha256": "
|
|
16
|
-
"size":
|
|
15
|
+
"sha256": "f6e448a810795d4d7db437d591e43fbf998e05b63f6cac37f56d3270e422c861",
|
|
16
|
+
"size": 6393344
|
|
17
17
|
},
|
|
18
18
|
"entry": {
|
|
19
19
|
"filename": "dist/foundation/resident-tabs/host-entry.js",
|
|
Binary file
|
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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": "
|
|
122
|
-
"pullRequest":
|
|
121
|
+
"source": "9fccb714f2912d0acdd9e7a4ed05d6d8423a82bd",
|
|
122
|
+
"pullRequest": 851
|
|
123
123
|
}
|
|
124
124
|
}
|