@timurproko/a1 0.2.7-dev.1232 → 0.2.7-dev.1233

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 (41) hide show
  1. package/dist/composition/owned-ui.js +13 -79
  2. package/dist/composition/release-update-observation.d.ts +22 -0
  3. package/dist/composition/release-update-observation.js +91 -0
  4. package/dist/composition/resident-tab-bridge.d.ts +16 -0
  5. package/dist/composition/resident-tab-bridge.js +45 -16
  6. package/dist/composition/startup-route.d.ts +8 -0
  7. package/dist/composition/startup-route.js +20 -0
  8. package/dist/contracts/owned-ui/session-workflows.d.ts +3 -0
  9. package/dist/foundation/release/bootstrap.js +46 -36
  10. package/dist/foundation/release/update-activation.js +8 -2
  11. package/dist/foundation/resident-tabs/bridge-client.d.ts +3 -0
  12. package/dist/foundation/resident-tabs/bridge-client.js +7 -1
  13. package/dist/foundation/resident-tabs/continuity.d.ts +12 -0
  14. package/dist/foundation/resident-tabs/continuity.js +55 -0
  15. package/dist/foundation/resident-tabs/index.d.ts +3 -0
  16. package/dist/foundation/resident-tabs/index.js +4 -0
  17. package/dist/foundation/resident-tabs/maintenance.d.ts +1 -1
  18. package/dist/foundation/resident-tabs/release-holds.js +11 -8
  19. package/dist/foundation/resident-tabs/runtime-control.d.ts +38 -0
  20. package/dist/foundation/resident-tabs/runtime-control.js +73 -0
  21. package/dist/integrations/pi/engine/adapter.js +6 -1
  22. package/dist/integrations/pi/engine/diagnostics-state.d.ts +9 -0
  23. package/dist/integrations/pi/engine/diagnostics-state.js +34 -0
  24. package/dist/integrations/pi/engine/view-state.d.ts +2 -1
  25. package/dist/integrations/pi/engine/view-state.js +4 -16
  26. package/dist/native/darwin-arm64/manifest.json +1 -1
  27. package/dist/native/darwin-arm64/terminal-host.manifest.json +3 -3
  28. package/dist/native/darwin-arm64/terminal-host.node +0 -0
  29. package/dist/native/darwin-x64/manifest.json +1 -1
  30. package/dist/native/darwin-x64/terminal-host.manifest.json +3 -3
  31. package/dist/native/darwin-x64/terminal-host.node +0 -0
  32. package/dist/native/linux-x64/manifest.json +1 -1
  33. package/dist/native/linux-x64/terminal-host.manifest.json +3 -3
  34. package/dist/native/linux-x64/terminal-host.node +0 -0
  35. package/dist/native/win32-x64/manifest.json +2 -2
  36. package/dist/native/win32-x64/process-guardian.node +0 -0
  37. package/dist/native/win32-x64/terminal-host.manifest.json +3 -3
  38. package/dist/native/win32-x64/terminal-host.node +0 -0
  39. package/docs/architecture/native-fixture-isolation.md +23 -0
  40. package/docs/architecture/resident-tabs.md +23 -4
  41. package/package.json +8 -3
@@ -1,4 +1,3 @@
1
- import { SuggestionDiagnosticCapture } from "../features/prompt-suggestions/diagnostics.js";
2
1
  import { PRODUCT_IDENTITY } from "../product-identity.js";
3
2
  import { PromptHistoryService } from "../features/prompt-history/service.js";
4
3
  import { PromptImageSidecar } from "../features/prompt-history/image-sidecar.js";
@@ -19,9 +18,9 @@ import { createOwnedRouteHost } from "./settings-route-host.js";
19
18
  import { renderPiShellChangelogLines } from "../integrations/pi/components/shell-presenters-info.js";
20
19
  import { nativeHyperlinkStyle } from "../ui/components/spans.js";
21
20
  import { ResidentTabRecovery } from "../foundation/resident-tabs/recovery.js";
22
- import { bindResidentTabBridge, closeResidentUtilityRoute, correlateResidentTabSubmission, currentBackendSessionFile, residentRouteHostOptions, residentTabKeybindings } from "./resident-tab-bridge.js";
21
+ import { bindResidentPresentation, createResidentClientIdentity, closeResidentUtilityRoute, correlateResidentTabSubmission, currentBackendSessionFile, residentRouteHostOptions, residentTabKeybindings } from "./resident-tab-bridge.js";
23
22
  import { SessionRuntimeRegistry } from "./session-runtime-registry.js";
24
- import { readPackageVersion, StartupReleaseNote } from "./startup-route.js";
23
+ import { deferStartupReleaseUpdateObservation, readPackageVersion, StartupReleaseNote } from "./startup-route.js";
25
24
  import { createOwnedSettingsManager, watchSharedSettings } from "./owned-settings.js";
26
25
  export { bindResidentTabBridge, correlateResidentTabSubmission } from "./resident-tab-bridge.js";
27
26
  export function residentStartupPresentation(resident, fallbackNotice) {
@@ -97,6 +96,7 @@ export async function composeOwnedUi(options = {}) {
97
96
  await host.dispose();
98
97
  throw error;
99
98
  }
99
+ const clientIdentity = createResidentClientIdentity(update => backend.extensions.announceReleaseUpdate(update));
100
100
  let recoveryDegraded = false;
101
101
  let presentRecoveryDegraded = (_degraded) => { };
102
102
  const tabRecovery = options.tab === undefined ? null : new ResidentTabRecovery(options.tab.dataDir, {
@@ -162,7 +162,7 @@ export async function composeOwnedUi(options = {}) {
162
162
  };
163
163
  const diagnosticDestination = options.suggestionDiagnosticsPath ?? process.env[PRODUCT_IDENTITY.environment.suggestionDiagnostics];
164
164
  const suggestionDiagnostics = settings !== null && ownedSurfaces && diagnosticDestination?.trim()
165
- ? new SuggestionDiagnosticCapture({ enabled: true, destination: diagnosticDestination }) : null;
165
+ ? await import("../features/prompt-suggestions/diagnostics.js").then(module => new module.SuggestionDiagnosticCapture({ enabled: true, destination: diagnosticDestination })) : null;
166
166
  const promptSuggestions = settings === null || !ownedSurfaces ? null : {
167
167
  ...(suggestionDiagnostics === null ? {} : { diagnostics: suggestionDiagnostics }),
168
168
  generator: backend,
@@ -234,11 +234,6 @@ export async function composeOwnedUi(options = {}) {
234
234
  // Invariant: one terminal host owns the process terminal; the session presenter reaches it only through
235
235
  // the host and paints once attached.
236
236
  terminalHost = createdTerminalHost = new OwnedUiTerminalHost(sessionTerminalHostOptions(presenterOptions));
237
- if (tabBridge !== null) {
238
- removeControllerBoundaryInput = terminalHost.runtime.addPreInputListener(data => ({
239
- data: tabBridge.consumeControllerBoundaryInput(data),
240
- }));
241
- }
242
237
  presenter = new OwnedUiSessionPresenter(terminalHost, presenterOptions);
243
238
  presentRecoveryDegraded = degraded => {
244
239
  if (!degraded)
@@ -249,12 +244,11 @@ export async function composeOwnedUi(options = {}) {
249
244
  if (recoveryDegraded)
250
245
  presentRecoveryDegraded(true);
251
246
  terminalHost.attach(presenter);
252
- if (tabBridge !== null) {
253
- unsubscribeTabBridge = await bindResidentTabBridge(tabBridge, backend, tabRecovery, {
254
- autoName: settings?.value("tabsAutoName") ?? true,
255
- openRoute: route => presenter.openOwnedRoute(route),
256
- });
257
- }
247
+ ({ unsubscribeTabBridge, removeControllerBoundaryInput } = await bindResidentPresentation(tabBridge, backend, tabRecovery, {
248
+ autoName: settings?.value("tabsAutoName") ?? true,
249
+ openRoute: route => presenter.openOwnedRoute(route),
250
+ onClientVersion: clientIdentity.observe,
251
+ }, terminalHost.runtime));
258
252
  }
259
253
  catch (error) {
260
254
  await createdTerminalHost?.dispose().catch(() => undefined);
@@ -269,13 +263,15 @@ export async function composeOwnedUi(options = {}) {
269
263
  // Rationale: pinned Pi's version check is unreachable because the owned shell never runs its
270
264
  // InteractiveMode, so A1 checks its own channel. The result is never awaited on the startup path;
271
265
  // settings are read here because the runner resolves them only just before start.
272
- const releaseUpdates = createStartupReleaseUpdateObservation({
266
+ const releaseUpdates = deferStartupReleaseUpdateObservation({
273
267
  backend,
274
268
  configDir: productPaths.configDir,
275
269
  mode: settings === null || !ownedSurfaces ? "auto" : settings.value("updateCheck"),
276
- packageVersion: releaseNote.packageVersion, notificationVersion: options.notificationVersion,
270
+ packageVersion: releaseNote.packageVersion, readPackageVersion, notificationVersion: options.notificationVersion,
277
271
  checkForNewerRelease: options.checkForNewerRelease,
272
+ clientVersion: clientIdentity.current,
278
273
  });
274
+ clientIdentity.onChange(releaseUpdates.clientChanged);
279
275
  const exitNoticePath = process.env[PRODUCT_IDENTITY.environment.exitNoticePath];
280
276
  // Security: tools the agent runs must never see, or rewrite, this instance's notice.
281
277
  delete process.env[PRODUCT_IDENTITY.environment.exitNoticePath];
@@ -343,68 +339,6 @@ function createPromptHistoryConfiguration(settings, backend, ownedSurfaces) {
343
339
  ...forScope(scope, sessionId),
344
340
  };
345
341
  }
346
- function createStartupReleaseUpdateObservation(input) {
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;
354
- const observe = async () => {
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;
360
- const runningVersion = input.packageVersion ?? await readPackageVersion();
361
- const discoveryVersion = input.mode === "notify" ? input.notificationVersion ?? runningVersion : runningVersion;
362
- const options = {
363
- runningVersion: discoveryVersion,
364
- configDir: input.configDir,
365
- interactive: process.stdout.isTTY === true,
366
- settingMode: input.mode,
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);
376
- if (release === null || controller.signal.aborted)
377
- return;
378
- if (input.mode === "notify") {
379
- input.backend.extensions.announceReleaseUpdate(release);
380
- return;
381
- }
382
- if (input.mode === "auto") {
383
- const installed = await import("../foundation/release/automatic-update.js")
384
- .then(module => module.requestAutomaticUpdate({ release, runningVersion, signal: controller.signal }));
385
- if (installed !== null && installed !== announcedVersion && !controller.signal.aborted) {
386
- announcedVersion = installed;
387
- input.backend.extensions.announceReleaseUpdate({ status: "installed", version: installed });
388
- }
389
- }
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
- };
401
- return {
402
- start() { if (started || controller.signal.aborted)
403
- return; started = true; void run(); },
404
- stop() { controller.abort(); if (timer !== undefined)
405
- clearTimeout(timer); },
406
- };
407
- }
408
342
  export { watchSharedSettings } from "./owned-settings.js";
409
343
  function settingsRouteOptions(tabBridge, keybindings, agentDir, cwd) {
410
344
  return {
@@ -0,0 +1,22 @@
1
+ import type { AvailableRelease, StartupReleaseCheckOptions, StartupUpdateMode } from "../foundation/release/latest-release.js";
2
+ import type { ComposedSessionBackend } from "./session-backend.js";
3
+ export interface StartupReleaseUpdateObservationOptions {
4
+ readonly backend: Pick<ComposedSessionBackend, "extensions">;
5
+ readonly configDir: string;
6
+ readonly mode: StartupUpdateMode;
7
+ readonly packageVersion: string | null;
8
+ readonly readPackageVersion: () => Promise<string>;
9
+ readonly notificationVersion: string | undefined;
10
+ readonly checkForNewerRelease: ((options: StartupReleaseCheckOptions) => Promise<AvailableRelease | null>) | undefined;
11
+ readonly clientVersion?: () => string | null;
12
+ }
13
+ /** Client readiness is unrelated to the retained Agent's engine version or execution lifecycle. */
14
+ declare class ReleaseUpdateObservation {
15
+ #private;
16
+ constructor(input: StartupReleaseUpdateObservationOptions);
17
+ start(): void;
18
+ stop(): void;
19
+ clientChanged(): void;
20
+ }
21
+ export declare function createStartupReleaseUpdateObservation(input: StartupReleaseUpdateObservationOptions): ReleaseUpdateObservation;
22
+ export {};
@@ -0,0 +1,91 @@
1
+ /** Client readiness is unrelated to the retained Agent's engine version or execution lifecycle. */
2
+ class ReleaseUpdateObservation {
3
+ #input;
4
+ #controller = new AbortController();
5
+ #timer;
6
+ #announced = null;
7
+ #eligible;
8
+ #cadence = null;
9
+ #nextDelay = null;
10
+ #started = false;
11
+ #running = false;
12
+ #refresh = false;
13
+ constructor(input) { this.#input = input; this.#eligible = input.mode === "auto"; }
14
+ async #observe() {
15
+ // Performance: registry modules and their locks stay outside the first-frame startup graph.
16
+ const releases = await import("../foundation/release/latest-release.js");
17
+ this.#cadence = releases.UPDATE_CHECK_INTERVAL_MS;
18
+ this.#nextDelay = this.#cadence;
19
+ const input = this.#input;
20
+ const check = input.checkForNewerRelease ?? releases.checkForNewerRelease;
21
+ const client = input.clientVersion?.();
22
+ const runningVersion = client ?? input.packageVersion ?? await input.readPackageVersion();
23
+ const options = {
24
+ runningVersion: client ?? (input.mode === "notify" ? input.notificationVersion ?? runningVersion : runningVersion),
25
+ configDir: input.configDir, interactive: process.stdout.isTTY === true, settingMode: input.mode,
26
+ signal: this.#controller.signal, onNextCheckDelay: delay => { this.#nextDelay = delay; },
27
+ };
28
+ if (input.checkForNewerRelease === undefined && releases.startupReleaseCheckSkipReason(options) !== null) {
29
+ this.#eligible = false;
30
+ return;
31
+ }
32
+ const release = await check(options);
33
+ if (release === null || this.#controller.signal.aborted)
34
+ return;
35
+ const current = input.clientVersion?.();
36
+ if (current && !releases.isNewerRelease(release.version, current))
37
+ return;
38
+ if (input.mode === "notify") {
39
+ input.backend.extensions.announceReleaseUpdate(release);
40
+ return;
41
+ }
42
+ if (input.mode !== "auto")
43
+ return;
44
+ const installed = await import("../foundation/release/automatic-update.js")
45
+ .then(module => module.requestAutomaticUpdate({ release, runningVersion, signal: this.#controller.signal }));
46
+ const actual = input.clientVersion?.();
47
+ if (installed === null || installed === this.#announced || this.#controller.signal.aborted
48
+ || actual && !releases.isNewerRelease(installed, actual))
49
+ return;
50
+ this.#announced = installed;
51
+ input.backend.extensions.announceReleaseUpdate({ status: "installed", version: installed });
52
+ }
53
+ async #run() {
54
+ if (this.#running) {
55
+ this.#refresh = true;
56
+ return;
57
+ }
58
+ this.#running = true;
59
+ try {
60
+ await this.#observe();
61
+ }
62
+ catch { /* Rationale: observation is best effort and never interrupts execution. */ }
63
+ this.#running = false;
64
+ if (this.#refresh && !this.#controller.signal.aborted) {
65
+ this.#refresh = false;
66
+ void this.#run();
67
+ return;
68
+ }
69
+ if (this.#eligible && this.#cadence !== null && !this.#controller.signal.aborted) {
70
+ this.#timer = setTimeout(() => { void this.#run(); }, this.#nextDelay ?? this.#cadence);
71
+ this.#timer.unref?.();
72
+ }
73
+ }
74
+ start() {
75
+ if (this.#started || this.#controller.signal.aborted)
76
+ return;
77
+ this.#started = true;
78
+ void this.#run();
79
+ }
80
+ stop() { this.#controller.abort(); if (this.#timer !== undefined)
81
+ clearTimeout(this.#timer); }
82
+ clientChanged() {
83
+ this.#announced = null;
84
+ if (!this.#started || this.#controller.signal.aborted || this.#input.mode === "no")
85
+ return;
86
+ if (this.#timer !== undefined)
87
+ clearTimeout(this.#timer);
88
+ void this.#run();
89
+ }
90
+ }
91
+ export function createStartupReleaseUpdateObservation(input) { return new ReleaseUpdateObservation(input); }
@@ -1,3 +1,4 @@
1
+ import type { OwnedUiReleaseUpdate } from "../contracts/owned-ui/index.js";
1
2
  import type { TabBridgeClient } from "../foundation/resident-tabs/bridge-client.js";
2
3
  import { type ResidentTabRoute } from "../foundation/resident-tabs/routes.js";
3
4
  import type { ResidentTabRecovery } from "../foundation/resident-tabs/recovery.js";
@@ -20,6 +21,21 @@ export declare function closeResidentUtilityRoute(tabBridge: Pick<TabBridgeClien
20
21
  export declare function bindResidentTabBridge(tabBridge: TabBridgeClient, backend: Pick<ComposedSessionBackend, "identity" | "session" | "workflows" | "generateResidentTabName">, recovery?: Pick<ResidentTabRecovery, "settled"> | null, options?: {
21
22
  readonly autoName?: boolean;
22
23
  readonly openRoute?: (route: ResidentTabRoute) => void;
24
+ readonly onClientVersion?: (version: string) => void;
23
25
  }): Promise<() => void>;
26
+ export declare function createResidentClientIdentity(announce: (update: OwnedUiReleaseUpdate) => void): {
27
+ current: () => string | null;
28
+ onChange: (listener: () => void) => void;
29
+ observe: (next: string) => void;
30
+ };
31
+ /** Input ownership is established before the presenter starts, and released if bridge binding fails. */
32
+ export declare function bindResidentPresentation(bridge: TabBridgeClient | null, backend: Parameters<typeof bindResidentTabBridge>[1], recovery: Parameters<typeof bindResidentTabBridge>[2], options: Parameters<typeof bindResidentTabBridge>[3], input: {
33
+ addPreInputListener(listener: (data: string) => {
34
+ data: string;
35
+ }): () => void;
36
+ }): Promise<{
37
+ unsubscribeTabBridge: () => void;
38
+ removeControllerBoundaryInput: () => void;
39
+ }>;
24
40
  export declare function correlateResidentTabSubmission(recovery: Pick<ResidentTabRecovery, "correlated" | "settled">, backend: Pick<ComposedSessionBackend, "identity" | "session">, submissionId: string, entryId: string): Promise<void>;
25
41
  export declare function currentBackendSessionFile(backend: Pick<ComposedSessionBackend, "identity">): string | null;
@@ -112,22 +112,8 @@ export async function bindResidentTabBridge(tabBridge, backend, recovery = null,
112
112
  }), false, pendingRequests, queuedInput);
113
113
  };
114
114
  announceSession();
115
- const unsubscribeCommands = typeof tabBridge.onCommand === "function" ? tabBridge.onCommand(command => {
116
- // Invariant: visibility stays decoded for generation-1 peers but never pauses rendering; a hidden tab
117
- // keeps its resident terminal current while the holder withholds only its client surfaces.
118
- if (command.type === "rename") {
119
- void backend.workflows.executeWorkflow({ command: "name", argument: command.name }).catch(() => { });
120
- }
121
- else if (command.type === "route") {
122
- if (options.openRoute !== undefined)
123
- options.openRoute(command.route);
124
- else
125
- void backend.workflows.executeWorkflow({ command: command.route, argument: "" }).catch(() => { });
126
- }
127
- else if (command.type === "stop") {
128
- void backend.session.dispose().catch(() => { });
129
- }
130
- }) : () => { };
115
+ const unsubscribeCommands = typeof tabBridge.onCommand === "function"
116
+ ? tabBridge.onCommand(command => applyBridgeCommand(command, backend, options)) : () => { };
131
117
  const unsubscribeEvents = backend.session.onEvent(event => {
132
118
  if (event.type === "session-lifecycle") {
133
119
  const wasWorking = working;
@@ -163,6 +149,49 @@ export async function bindResidentTabBridge(tabBridge, backend, recovery = null,
163
149
  unsubscribeEvents();
164
150
  };
165
151
  }
152
+ function applyBridgeCommand(command, backend, options) {
153
+ // Invariant: visibility never pauses resident rendering or autonomous work.
154
+ if (command.type === "client")
155
+ options.onClientVersion?.(command.version);
156
+ else if (command.type === "rename")
157
+ void backend.workflows.executeWorkflow({ command: "name", argument: command.name }).catch(() => { });
158
+ else if (command.type === "route") {
159
+ if (options.openRoute !== undefined)
160
+ options.openRoute(command.route);
161
+ else
162
+ void backend.workflows.executeWorkflow({ command: command.route, argument: "" }).catch(() => { });
163
+ }
164
+ else if (command.type === "stop")
165
+ void backend.session.dispose().catch(() => { });
166
+ }
167
+ export function createResidentClientIdentity(announce) {
168
+ let version = null;
169
+ let changed;
170
+ return {
171
+ current: () => version,
172
+ onChange: (listener) => { changed = listener; },
173
+ observe: (next) => {
174
+ if (next === version)
175
+ return;
176
+ version = next;
177
+ announce({ status: "client-connected", version: next });
178
+ changed?.();
179
+ },
180
+ };
181
+ }
182
+ /** Input ownership is established before the presenter starts, and released if bridge binding fails. */
183
+ export async function bindResidentPresentation(bridge, backend, recovery, options, input) {
184
+ if (bridge === null)
185
+ return { unsubscribeTabBridge() { }, removeControllerBoundaryInput() { } };
186
+ const removeControllerBoundaryInput = input.addPreInputListener(data => ({ data: bridge.consumeControllerBoundaryInput(data) }));
187
+ try {
188
+ return { unsubscribeTabBridge: await bindResidentTabBridge(bridge, backend, recovery, options), removeControllerBoundaryInput };
189
+ }
190
+ catch (error) {
191
+ removeControllerBoundaryInput();
192
+ throw error;
193
+ }
194
+ }
166
195
  export async function correlateResidentTabSubmission(recovery, backend, submissionId, entryId) {
167
196
  await recovery.correlated(submissionId, entryId);
168
197
  // Concurrency: a normal prompt command can resolve after the ready transition already fired.
@@ -1,5 +1,6 @@
1
1
  import type { OwnedUiShellEngineOptions } from "../app/session-shell/index.js";
2
2
  import type { ReleaseNoteCatalog } from "../features/owned-ui/release-notes.js";
3
+ import type { StartupReleaseUpdateObservationOptions } from "./release-update-observation.js";
3
4
  type StartupRoute = NonNullable<OwnedUiShellEngineOptions["startupRoute"]>;
4
5
  export interface StartupReleaseNoteOptions {
5
6
  readonly ownedSurfaces: boolean;
@@ -11,6 +12,13 @@ export interface StartupReleaseNoteOptions {
11
12
  readonly configDir: string;
12
13
  }
13
14
  export declare function readPackageVersion(): Promise<string>;
15
+ type UpdateObservationModule = Pick<typeof import("./release-update-observation.js"), "createStartupReleaseUpdateObservation">;
16
+ /** Optional update checks load after first paint; disposal also fences an unfinished module load. */
17
+ export declare function deferStartupReleaseUpdateObservation(options: StartupReleaseUpdateObservationOptions, load?: () => Promise<UpdateObservationModule>): {
18
+ start(): void;
19
+ stop(): void;
20
+ clientChanged(): void;
21
+ };
14
22
  /**
15
23
  * The release notes bare A1 ships and the one note this version shows once per profile on startup:
16
24
  * the packaged catalog (or why it could not be read), the running version, and the claim that
@@ -6,6 +6,26 @@ export async function readPackageVersion() {
6
6
  throw new Error("A1 package version is unavailable");
7
7
  return version;
8
8
  }
9
+ /** Optional update checks load after first paint; disposal also fences an unfinished module load. */
10
+ export function deferStartupReleaseUpdateObservation(options, load = () => import("./release-update-observation.js")) {
11
+ let observation;
12
+ let started = false, stopped = false;
13
+ return {
14
+ start() {
15
+ if (started || stopped)
16
+ return;
17
+ started = true;
18
+ void load().then(module => {
19
+ if (stopped)
20
+ return;
21
+ observation = module.createStartupReleaseUpdateObservation(options);
22
+ observation.start();
23
+ }).catch(() => { });
24
+ },
25
+ stop() { stopped = true; observation?.stop(); },
26
+ clientChanged() { observation?.clientChanged(); },
27
+ };
28
+ }
9
29
  /**
10
30
  * The release notes bare A1 ships and the one note this version shows once per profile on startup:
11
31
  * the packaged catalog (or why it could not be read), the running version, and the claim that
@@ -339,6 +339,9 @@ export interface OwnedUiVisualExtensionSupport {
339
339
  }
340
340
  /** A newer release found by the startup check, or one installed for the next restart. */
341
341
  export type OwnedUiReleaseUpdate = {
342
+ readonly status: "client-connected";
343
+ readonly version: string;
344
+ } | {
342
345
  readonly status?: "available";
343
346
  readonly version: string;
344
347
  readonly command: string;
@@ -19,7 +19,7 @@ import { createRestartSeal, readRestartCertifiedRelease, releaseCertificationDoc
19
19
  import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
20
20
  import { markStartupPhase } from "../startup/index.js";
21
21
  import { PRIVATE_ENVIRONMENT, isCurrentLaunchContract, launchContractTarget, readLaunchContext, withLaunchContext } from "../launch-context/index.js";
22
- import { ensureTerminalHost, readResidentTabLaunchSettings, selectResidentHost } from "../resident-tabs/index.js";
22
+ import { assertResidentContinuity, ensureTerminalHost, readResidentTabLaunchSettings, selectResidentHost } from "../resident-tabs/index.js";
23
23
  const RELEASE_RESELECTION_MESSAGE = "a1-release-reselection";
24
24
  export async function runBootstrap(options) {
25
25
  return await runBootstrapAttempt(options, 1);
@@ -38,41 +38,7 @@ async function runBootstrapAttempt(options, retriesRemaining) {
38
38
  const paths = resolveProductPaths(environment);
39
39
  await mkdir(paths.runtimeDir, { recursive: true, mode: 0o700 });
40
40
  const launchSelected = async (release) => {
41
- let residentContext = {};
42
- // Compatibility: public launch preparation supplies PI_CODING_AGENT_DIR; the fallback keeps
43
- // direct SDK/bootstrap callers hermetic while retaining their former config-root read.
44
- const agentProfileDir = environment.PI_CODING_AGENT_DIR ?? paths.configDir;
45
- const residentLaunchSettings = launchProfileId === "a1"
46
- ? readResidentTabLaunchSettings(agentProfileDir, PRODUCT_IDENTITY.commandName, paths.configDir, launchProfileId)
47
- : null;
48
- const resident = await selectResidentHost({
49
- profileId: launchProfileId,
50
- currentLaunchContract: isCurrentLaunchContract(release),
51
- solo: launchMode === "solo",
52
- interactive: options.interactiveTerminal ?? (process.stdin.isTTY === true && process.stdout.isTTY === true),
53
- ensureHost: async () => (options.ensureTerminalHost ?? ensureTerminalHost)({
54
- releaseRoot: release.releaseRoot,
55
- profileRoot: environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir,
56
- dataDir: paths.dataDir,
57
- runtimeDir: paths.runtimeDir,
58
- environment,
59
- maxConcurrentStarts: (await residentLaunchSettings).maxConcurrentStarts,
60
- unresponsiveRestartSeconds: (await residentLaunchSettings).unresponsiveRestartSeconds,
61
- }),
62
- });
63
- if (resident !== null) {
64
- residentContext = resident.status === "ready" ? {
65
- residentRootExecutable: resident.executable.executable,
66
- residentRootArguments: JSON.stringify([
67
- resident.executable.entry,
68
- "attach", "--endpoint", resident.endpoint, "--cwd", process.cwd(), "--clear-exit-ms", String(RESIDENT_DETACH_INTERVAL_MS),
69
- "--profile", environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir,
70
- "--settings-file", resolve(agentProfileDir, "settings.json"), "--accent-color", (await residentLaunchSettings).accentColor,
71
- "--data-dir", paths.dataDir, "--runtime-dir", paths.runtimeDir,
72
- "--release-root", release.releaseRoot, "--node-executable", process.execPath,
73
- ]),
74
- } : { residentFallback: resident.reason };
75
- }
41
+ const residentContext = await residentLaunchContext(options, release, environment);
76
42
  const result = await launchUi(release, environment, sessionArgs, residentContext);
77
43
  if (!result.releaseSuperseded)
78
44
  return result.code;
@@ -218,6 +184,16 @@ async function runBootstrapAttempt(options, retriesRemaining) {
218
184
  const diagnosticsPath = await certifyMaterializedRelease(candidate, paths.dataDir);
219
185
  await stateStore.approve(candidate.releaseId, diagnosticsPath);
220
186
  }
187
+ try {
188
+ await assertResidentContinuity({ releaseRoot: candidate.releaseRoot, dataDir: paths.dataDir, environment,
189
+ profileRoot: environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir, runtimeDir: paths.runtimeDir });
190
+ }
191
+ catch (error) {
192
+ const fallback = await launchRetainedActive(await stateStore.read(), paths, environment, launchSelected, excludedRetainedReleaseId);
193
+ if (fallback !== null)
194
+ return fallback;
195
+ throw error;
196
+ }
221
197
  state = await stateStore.activateForLaunch(candidate.releaseId, !activeIsLaunchable ? activeId ?? undefined : undefined);
222
198
  if (state.references.active !== candidate.releaseId) {
223
199
  const retainedRecord = state.references.active === null ? undefined : state.releases[state.references.active];
@@ -312,6 +288,38 @@ async function runBootstrapAttempt(options, retriesRemaining) {
312
288
  await ensureSupervisor(selected, environment);
313
289
  return await launchSelected(selected);
314
290
  }
291
+ /** Select only client routing; a resident reconnect never asks execution to restart. */
292
+ async function residentLaunchContext(options, release, environment) {
293
+ const paths = resolveProductPaths(environment);
294
+ const profileId = options.launchIntent?.profileId ?? "a1";
295
+ const mode = options.launchIntent?.launchMode ?? (profileId === "a1" ? "tabs" : "solo");
296
+ const agentProfileDir = environment.PI_CODING_AGENT_DIR ?? paths.configDir;
297
+ const residentLaunchSettings = profileId === "a1" ? readResidentTabLaunchSettings(agentProfileDir, PRODUCT_IDENTITY.commandName, paths.configDir, profileId) : null;
298
+ const resident = await selectResidentHost({
299
+ profileId, currentLaunchContract: isCurrentLaunchContract(release), solo: mode === "solo",
300
+ interactive: options.interactiveTerminal ?? (process.stdin.isTTY === true && process.stdout.isTTY === true),
301
+ ensureHost: async () => (options.ensureTerminalHost ?? ensureTerminalHost)({
302
+ releaseRoot: release.releaseRoot, profileRoot: environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir,
303
+ dataDir: paths.dataDir, runtimeDir: paths.runtimeDir, environment,
304
+ maxConcurrentStarts: (await residentLaunchSettings).maxConcurrentStarts, unresponsiveRestartSeconds: (await residentLaunchSettings).unresponsiveRestartSeconds,
305
+ }),
306
+ });
307
+ if (resident === null)
308
+ return {};
309
+ if (resident.status !== "ready")
310
+ return { residentFallback: resident.reason };
311
+ return {
312
+ residentRootExecutable: resident.executable.executable,
313
+ residentRootArguments: JSON.stringify([
314
+ resident.executable.entry, "attach", "--workspace", "--endpoint", resident.endpoint,
315
+ "--cwd", process.cwd(), "--clear-exit-ms", String(RESIDENT_DETACH_INTERVAL_MS),
316
+ "--profile", environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir,
317
+ "--settings-file", resolve(agentProfileDir, "settings.json"), "--accent-color", (await residentLaunchSettings).accentColor,
318
+ "--data-dir", paths.dataDir, "--runtime-dir", paths.runtimeDir,
319
+ "--release-root", release.releaseRoot, "--node-executable", process.execPath,
320
+ ]),
321
+ };
322
+ }
315
323
  /**
316
324
  * Start the retained active cohort when the installed payload cannot be taken. The
317
325
  * release already carries certified evidence, so this reuses it rather than
@@ -493,6 +501,8 @@ async function activatePendingAfterBlockerExit(candidate, stateStore, paths, env
493
501
  return;
494
502
  const diagnosticsPath = await certifyMaterializedRelease(candidate, paths.dataDir);
495
503
  await stateStore.approve(candidate.releaseId, diagnosticsPath);
504
+ await assertResidentContinuity({ releaseRoot: candidate.releaseRoot, dataDir: paths.dataDir, environment,
505
+ profileRoot: environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir, runtimeDir: paths.runtimeDir });
496
506
  if (!await requestIdleOwnershipRelease(endpoint, candidate.releaseId, 2_000))
497
507
  return;
498
508
  if (!await releaseVerifiedIdleOwner(endpoint, paths.dataDir))
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { PRODUCT_TEXT } from "../../product-identity.js";
5
+ import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
6
6
  import { certifyMaterializedRelease, ensureSupervisor } from "./bootstrap.js";
7
7
  import { CohortStateStore } from "./cohort-state.js";
8
8
  import { encodeFrame, LineFrameDecoder } from "../protocol/index.js";
@@ -51,8 +51,14 @@ export async function activateInstalledRelease(request, callbacks) {
51
51
  callbacks.onWarmup?.("started");
52
52
  await warmMaterializedRelease(candidate, request.environment);
53
53
  callbacks.onWarmup?.("completed");
54
+ const { assertResidentContinuity } = await import("../resident-tabs/index.js");
55
+ const paths = resolveProductPaths(request.environment);
56
+ const continuity = { releaseRoot: candidate.releaseRoot, dataDir: request.dataDir, environment: request.environment,
57
+ profileRoot: request.environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir, runtimeDir: paths.runtimeDir };
58
+ await assertResidentContinuity(continuity);
54
59
  await ensureSupervisor(candidate, request.environment);
55
- // Invariant: warmup and authenticated readiness precede changing the active reference.
60
+ // Invariant: revalidate after supervision, before committing client activation or restart readiness.
61
+ await assertResidentContinuity(continuity);
56
62
  await stateStore.activate(candidate.releaseId);
57
63
  await callbacks.phase("active-reference-committed");
58
64
  }
@@ -14,6 +14,9 @@ export interface TabAttachment {
14
14
  export type TabBridgeCommand = {
15
15
  readonly type: "visibility";
16
16
  readonly visible: boolean;
17
+ } | {
18
+ readonly type: "client";
19
+ readonly version: string;
17
20
  } | {
18
21
  readonly type: "rename";
19
22
  readonly name: string;
@@ -71,7 +71,7 @@ export class TabBridgeClient {
71
71
  transport.once("connect", () => {
72
72
  this.#send("hello", {
73
73
  role: "bridge", generation: 1, build: this.#options.build ?? "a1", releaseId: this.#options.credentials.releaseId,
74
- features: ["bridge-status", "bridge-detach", "bridge-heartbeat", "bridge-restart", "bridge-visibility", "bridge-rename", "bridge-route", "bridge-stop", "bridge-shortcuts", "controller-boundary", "recovery-journal"],
74
+ features: ["bridge-status", "bridge-detach", "bridge-heartbeat", "bridge-restart", "bridge-visibility", "bridge-rename", "bridge-route", "bridge-stop", "bridge-shortcuts", "controller-boundary", "recovery-journal", "client-identity"],
75
75
  token: [...Buffer.from(this.#options.credentials.token, "base64")], tabId: this.#options.credentials.tabId,
76
76
  pid: this.#options.pid ?? process.pid, startIdentity: this.#options.startIdentity ?? `${process.pid}:node`,
77
77
  }, ++this.#request);
@@ -231,6 +231,12 @@ export class TabBridgeClient {
231
231
  this.#fail();
232
232
  return;
233
233
  }
234
+ if (message.type === "bridge.client" && typeof message.body["version"] === "string") {
235
+ const version = message.body["version"];
236
+ if (version.length <= 128 && /^\d+\.\d+\.\d+(?:[-+][a-z0-9.+-]+)?$/iu.test(version))
237
+ this.#emitCommand({ type: "client", version });
238
+ return;
239
+ }
234
240
  if (message.type === "bridge.visibility" && typeof message.body["visible"] === "boolean") {
235
241
  this.#emitCommand({ type: "visibility", visible: message.body["visible"] });
236
242
  return;
@@ -0,0 +1,12 @@
1
+ export interface ContinuityOptions {
2
+ readonly releaseRoot: string;
3
+ readonly dataDir: string;
4
+ readonly profileRoot: string;
5
+ readonly runtimeDir: string;
6
+ readonly environment: NodeJS.ProcessEnv;
7
+ readonly inspect?: (options: Omit<ContinuityOptions, "inspect">) => Promise<string>;
8
+ }
9
+ /** Activation authority requires a stable authenticated source-contract set, never an idle Agent. */
10
+ export declare function assertResidentContinuity(options: ContinuityOptions): Promise<void>;
11
+ /** Native inspection/import is read-only toward execution and never starts or stops a source. */
12
+ export declare function inspectResidentContinuity(options: Omit<ContinuityOptions, "inspect">): Promise<string>;