@timurproko/a1 0.2.6 → 0.2.7-dev.746
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/bin/guardian.js +5 -0
- package/bin/tab-launch-context.d.ts +18 -0
- package/bin/tab-launch-context.js +36 -0
- package/bin/ui.js +14 -3
- package/dist/app/session-shell/helper-pool.d.ts +3 -5
- package/dist/app/session-shell/helper-pool.js +3 -5
- package/dist/app/session-shell/session-shell-root.d.ts +6 -1
- package/dist/app/session-shell/session-shell-root.js +3 -2
- package/dist/app/session-shell/session-shell.js +41 -24
- package/dist/app/session-shell/session-viewport-controller.d.ts +1 -1
- package/dist/app/session-shell/session-viewport-controller.js +3 -3
- package/dist/composition/owned-ui.d.ts +17 -0
- package/dist/composition/owned-ui.js +28 -1
- package/dist/foundation/launch-context/index.d.ts +6 -0
- package/dist/foundation/launch-context/index.js +15 -0
- package/dist/foundation/launch-guardian/main.d.ts +5 -0
- package/dist/foundation/launch-guardian/main.js +2 -1
- package/dist/foundation/release/bootstrap.d.ts +4 -0
- package/dist/foundation/release/bootstrap.js +33 -6
- package/dist/foundation/resident-tabs/bridge-client.d.ts +36 -0
- package/dist/foundation/resident-tabs/bridge-client.js +157 -0
- package/dist/foundation/resident-tabs/ensure-terminal-host.d.ts +28 -0
- package/dist/foundation/resident-tabs/ensure-terminal-host.js +115 -0
- package/dist/foundation/resident-tabs/index.d.ts +5 -0
- package/dist/foundation/resident-tabs/index.js +3 -0
- package/dist/foundation/resident-tabs/protocol.d.ts +12 -0
- package/dist/foundation/resident-tabs/protocol.js +195 -0
- package/dist/foundation/resident-tabs/routing.d.ts +10 -0
- package/dist/foundation/resident-tabs/routing.js +8 -0
- package/dist/foundation/resident-tabs/settings.d.ts +2 -0
- package/dist/foundation/resident-tabs/settings.js +17 -0
- package/dist/foundation/terminal-cleanup/index.d.ts +1 -1
- package/dist/foundation/terminal-cleanup/index.js +1 -1
- package/dist/foundation/terminal-cleanup/terminal-reset.d.ts +1 -0
- package/dist/foundation/terminal-cleanup/terminal-reset.js +1 -0
- package/dist/integrations/pi/components/owned-editor-ux.d.ts +1 -1
- package/dist/integrations/pi/components/owned-editor-ux.js +2 -2
- package/dist/integrations/pi/components/shell-shared-facade.d.ts +1 -1
- package/dist/native/darwin-arm64/manifest.json +1 -1
- package/dist/native/linux-x64/manifest.json +1 -1
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/dist/product-identity.d.ts +1 -1
- package/dist/product-identity.js +1 -1
- package/dist/product-identity.json +5 -0
- package/dist/ui/components/transcript-viewport.d.ts +6 -3
- package/dist/ui/components/transcript-viewport.js +13 -15
- package/dist/ui/settings/declarations.d.ts +13 -1
- package/dist/ui/settings/declarations.js +10 -1
- package/dist/ui/settings/index.d.ts +1 -1
- package/dist/ui/settings/index.js +1 -1
- package/dist/ui/settings/migrations.js +7 -0
- package/dist/ui/settings/resolution.d.ts +2 -0
- package/dist/ui/settings/resolution.js +13 -1
- package/docs/architecture/internal-naming.md +1 -1
- package/docs/architecture/resident-tabs-requirements.md +813 -0
- package/docs/architecture/resident-tabs.md +502 -0
- package/docs/architecture/terminal-host-proof-gate.md +2 -0
- package/docs/openspec-archive-automation.md +12 -11
- package/docs/validation.md +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { restoreAfterOwnedExit } from "../terminal-cleanup/index.js";
|
|
2
|
+
import { CLEAR_EXIT_INTERVAL_MS, restoreAfterOwnedExit } from "../terminal-cleanup/index.js";
|
|
3
3
|
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { connect } from "node:net";
|
|
5
5
|
import { resolve } from "node:path";
|
|
@@ -15,9 +15,10 @@ import { selectUpdateLaunchRelease } from "./update-launch.js";
|
|
|
15
15
|
import { consumeMaterializationProof, materializeRelease, readCertifiedReleaseManifest, readMaterializedRelease, resolveReleaseEntryPoint, verifyMaterializedRelease } from "./release-store.js";
|
|
16
16
|
import { scheduleReleaseCleanup } from "./release-gc.js";
|
|
17
17
|
import { createRestartSeal, readRestartCertifiedRelease, releaseCertificationDocument } from "./restart-certification.js";
|
|
18
|
-
import { PRODUCT_TEXT } from "../../product-identity.js";
|
|
18
|
+
import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
|
|
19
19
|
import { markStartupPhase } from "../startup/index.js";
|
|
20
|
-
import { launchContractTarget, readLaunchContext, withLaunchContext } from "../launch-context/index.js";
|
|
20
|
+
import { isCurrentLaunchContract, launchContractTarget, readLaunchContext, withLaunchContext } from "../launch-context/index.js";
|
|
21
|
+
import { ensureTerminalHost, readResidentTabsSetting, selectResidentHost } from "../resident-tabs/index.js";
|
|
21
22
|
const RELEASE_RESELECTION_MESSAGE = "a1-release-reselection";
|
|
22
23
|
export async function runBootstrap(options) {
|
|
23
24
|
return await runBootstrapAttempt(options, 1);
|
|
@@ -34,7 +35,31 @@ async function runBootstrapAttempt(options, retriesRemaining) {
|
|
|
34
35
|
const paths = resolveProductPaths(environment);
|
|
35
36
|
await mkdir(paths.runtimeDir, { recursive: true, mode: 0o700 });
|
|
36
37
|
const launchSelected = async (release) => {
|
|
37
|
-
|
|
38
|
+
let residentContext = {};
|
|
39
|
+
const resident = await selectResidentHost({
|
|
40
|
+
profileId: launchProfileId,
|
|
41
|
+
hasSessionSelection: sessionArgs.length > 0,
|
|
42
|
+
currentLaunchContract: isCurrentLaunchContract(release),
|
|
43
|
+
readSetting: () => (options.readResidentTabs ?? readResidentTabsSetting)(paths.configDir, launchProfileId),
|
|
44
|
+
ensureHost: () => (options.ensureTerminalHost ?? ensureTerminalHost)({
|
|
45
|
+
releaseRoot: release.releaseRoot,
|
|
46
|
+
profileRoot: environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir,
|
|
47
|
+
dataDir: paths.dataDir,
|
|
48
|
+
runtimeDir: paths.runtimeDir,
|
|
49
|
+
environment,
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
if (resident !== null) {
|
|
53
|
+
residentContext = resident.status === "ready" ? {
|
|
54
|
+
residentRootExecutable: resident.executable,
|
|
55
|
+
residentRootArguments: JSON.stringify([
|
|
56
|
+
"attach", "--endpoint", resident.endpoint, "--cwd", process.cwd(), "--clear-exit-ms", String(CLEAR_EXIT_INTERVAL_MS),
|
|
57
|
+
"--profile", environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir,
|
|
58
|
+
"--data-dir", paths.dataDir, "--release-root", release.releaseRoot, "--node-executable", process.execPath,
|
|
59
|
+
]),
|
|
60
|
+
} : { residentFallback: resident.reason };
|
|
61
|
+
}
|
|
62
|
+
const result = await launchUi(release, environment, sessionArgs, residentContext);
|
|
38
63
|
if (!result.releaseSuperseded)
|
|
39
64
|
return result.code;
|
|
40
65
|
if (retriesRemaining > 0)
|
|
@@ -327,13 +352,15 @@ export async function startSupervisor(release, environment) {
|
|
|
327
352
|
child.unref();
|
|
328
353
|
return { ...attempt, childOutcome };
|
|
329
354
|
}
|
|
330
|
-
async function launchUi(release, environment, sessionArgs) {
|
|
355
|
+
async function launchUi(release, environment, sessionArgs, residentContext = {}) {
|
|
331
356
|
await markStartupPhase(environment, "bootstrap-selected");
|
|
332
357
|
const entry = await resolveReleaseEntryPoint(release, "bin/guardian.js");
|
|
333
358
|
return await new Promise((resolvePromise, rejectPromise) => {
|
|
334
359
|
let releaseSuperseded = false;
|
|
360
|
+
const releaseEnv = releaseEnvironment(environment, release, readLaunchContext(environment, "profile").launchProfile);
|
|
361
|
+
const launchEnvironment = Object.keys(residentContext).length === 0 ? releaseEnv : withLaunchContext(releaseEnv, { ...readLaunchContext(releaseEnv), ...residentContext }, process.platform, launchContractTarget(release));
|
|
335
362
|
const child = spawn(process.execPath, [entry, ...sessionArgs], {
|
|
336
|
-
env:
|
|
363
|
+
env: launchEnvironment,
|
|
337
364
|
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
|
338
365
|
windowsHide: false,
|
|
339
366
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type ResidentTabStatus = "starting" | "working" | "idle";
|
|
2
|
+
export interface TabBridgeCredentials {
|
|
3
|
+
readonly tabId: string;
|
|
4
|
+
readonly token: string;
|
|
5
|
+
readonly endpoint: string;
|
|
6
|
+
}
|
|
7
|
+
export interface TabAttachment {
|
|
8
|
+
readonly clientId: string;
|
|
9
|
+
readonly controllerRevision: number;
|
|
10
|
+
}
|
|
11
|
+
interface BridgeTransport {
|
|
12
|
+
write(bytes: Uint8Array): boolean;
|
|
13
|
+
destroy(): void;
|
|
14
|
+
once(event: "connect" | "error" | "close", listener: (...args: unknown[]) => void): unknown;
|
|
15
|
+
on(event: "data", listener: (bytes: Buffer) => void): unknown;
|
|
16
|
+
}
|
|
17
|
+
export interface TabBridgeOptions {
|
|
18
|
+
readonly credentials: TabBridgeCredentials;
|
|
19
|
+
readonly pid?: number;
|
|
20
|
+
readonly startIdentity?: string;
|
|
21
|
+
readonly build?: string;
|
|
22
|
+
readonly connect?: (endpoint: string) => BridgeTransport;
|
|
23
|
+
}
|
|
24
|
+
/** Metadata-only advisory bridge. Terminal input and transcript bytes have no API here. */
|
|
25
|
+
export declare class TabBridgeClient {
|
|
26
|
+
#private;
|
|
27
|
+
constructor(options: TabBridgeOptions);
|
|
28
|
+
get available(): boolean;
|
|
29
|
+
captureAttachment(): TabAttachment | null;
|
|
30
|
+
start(timeoutMs?: number): Promise<boolean>;
|
|
31
|
+
status(state: ResidentTabStatus): void;
|
|
32
|
+
session(file: string | null, name: string | null): void;
|
|
33
|
+
requestDetach(origin?: TabAttachment | null, timeoutMs?: number): Promise<boolean>;
|
|
34
|
+
close(): void;
|
|
35
|
+
}
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { connect } from "node:net";
|
|
2
|
+
import { encodeResidentFrame, ResidentFrameDecoder } from "./protocol.js";
|
|
3
|
+
/** Metadata-only advisory bridge. Terminal input and transcript bytes have no API here. */
|
|
4
|
+
export class TabBridgeClient {
|
|
5
|
+
#options;
|
|
6
|
+
#transport = null;
|
|
7
|
+
#connected = false;
|
|
8
|
+
#available = true;
|
|
9
|
+
#sequence = 0;
|
|
10
|
+
#request = 0;
|
|
11
|
+
#attachment = null;
|
|
12
|
+
#closed = false;
|
|
13
|
+
#retry = null;
|
|
14
|
+
#latestStatus = null;
|
|
15
|
+
#latestSession = null;
|
|
16
|
+
#pending = new Map();
|
|
17
|
+
#startWaiter = null;
|
|
18
|
+
constructor(options) { this.#options = options; }
|
|
19
|
+
get available() { return this.#available && this.#connected; }
|
|
20
|
+
captureAttachment() { return this.#attachment === null ? null : { ...this.#attachment }; }
|
|
21
|
+
async start(timeoutMs = 4_000) {
|
|
22
|
+
if (this.#closed || this.#transport !== null)
|
|
23
|
+
return this.available;
|
|
24
|
+
const transport = (this.#options.connect ?? (endpoint => connect(endpoint)))(this.#options.credentials.endpoint);
|
|
25
|
+
this.#transport = transport;
|
|
26
|
+
const decoder = new ResidentFrameDecoder();
|
|
27
|
+
transport.on("data", bytes => {
|
|
28
|
+
try {
|
|
29
|
+
for (const message of decoder.push(bytes))
|
|
30
|
+
this.#receive(message);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
if (this.#transport === transport)
|
|
34
|
+
this.#fail();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
transport.once("close", () => { if (this.#transport === transport)
|
|
38
|
+
this.#fail(); });
|
|
39
|
+
transport.once("error", () => { if (this.#transport === transport)
|
|
40
|
+
this.#fail(); });
|
|
41
|
+
return await new Promise(resolvePromise => {
|
|
42
|
+
let settled = false;
|
|
43
|
+
let timer;
|
|
44
|
+
const finish = (value) => {
|
|
45
|
+
if (settled)
|
|
46
|
+
return;
|
|
47
|
+
settled = true;
|
|
48
|
+
if (timer !== undefined)
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
this.#startWaiter = null;
|
|
51
|
+
resolvePromise(value);
|
|
52
|
+
};
|
|
53
|
+
this.#startWaiter = finish;
|
|
54
|
+
transport.once("connect", () => {
|
|
55
|
+
this.#send("hello", {
|
|
56
|
+
role: "bridge", generation: 1, build: this.#options.build ?? "a1", features: ["bridge-status", "bridge-detach"],
|
|
57
|
+
token: [...Buffer.from(this.#options.credentials.token, "base64")], tabId: this.#options.credentials.tabId,
|
|
58
|
+
pid: this.#options.pid ?? process.pid, startIdentity: this.#options.startIdentity ?? `${process.pid}:node`,
|
|
59
|
+
}, ++this.#request);
|
|
60
|
+
});
|
|
61
|
+
transport.once("error", () => finish(false));
|
|
62
|
+
timer = setTimeout(() => { this.#fail(); finish(false); }, timeoutMs);
|
|
63
|
+
timer.unref();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
status(state) {
|
|
67
|
+
this.#latestStatus = state;
|
|
68
|
+
this.#send("bridge.status", { seq: ++this.#sequence, state });
|
|
69
|
+
}
|
|
70
|
+
session(file, name) {
|
|
71
|
+
this.#latestSession = { file, name };
|
|
72
|
+
this.#send("bridge.session", { file, name });
|
|
73
|
+
}
|
|
74
|
+
async requestDetach(origin = this.captureAttachment(), timeoutMs = 1_000) {
|
|
75
|
+
if (!this.available || origin === null)
|
|
76
|
+
return false;
|
|
77
|
+
const requestId = ++this.#request;
|
|
78
|
+
const result = new Promise(resolvePromise => this.#pending.set(requestId, resolvePromise));
|
|
79
|
+
this.#send("bridge.request", { action: "detach", clientId: origin.clientId, controllerRevision: origin.controllerRevision }, requestId);
|
|
80
|
+
const timeout = new Promise(resolvePromise => {
|
|
81
|
+
const timer = setTimeout(() => { this.#pending.delete(requestId); resolvePromise(false); }, timeoutMs);
|
|
82
|
+
timer.unref();
|
|
83
|
+
});
|
|
84
|
+
return await Promise.race([result, timeout]);
|
|
85
|
+
}
|
|
86
|
+
close() {
|
|
87
|
+
this.#closed = true;
|
|
88
|
+
if (this.#retry !== null)
|
|
89
|
+
clearTimeout(this.#retry);
|
|
90
|
+
this.#retry = null;
|
|
91
|
+
const transport = this.#transport;
|
|
92
|
+
this.#transport = null;
|
|
93
|
+
transport?.destroy();
|
|
94
|
+
this.#fail(false);
|
|
95
|
+
}
|
|
96
|
+
#send(type, body, requestId) {
|
|
97
|
+
if (this.#transport === null || (!this.#connected && type !== "hello"))
|
|
98
|
+
return;
|
|
99
|
+
try {
|
|
100
|
+
this.#transport.write(encodeResidentFrame({ type, ...(requestId === undefined ? {} : { requestId }), body }));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
this.#fail();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
#receive(message) {
|
|
107
|
+
if (message.type === "welcome" && message.body["generation"] === 1) {
|
|
108
|
+
this.#connected = true;
|
|
109
|
+
this.#available = true;
|
|
110
|
+
this.#startWaiter?.(true);
|
|
111
|
+
if (this.#latestStatus !== null)
|
|
112
|
+
this.#send("bridge.status", { seq: ++this.#sequence, state: this.#latestStatus });
|
|
113
|
+
if (this.#latestSession !== null)
|
|
114
|
+
this.#send("bridge.session", this.#latestSession);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (message.type === "incompatible-generation") {
|
|
118
|
+
this.#fail();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (message.type === "attachment") {
|
|
122
|
+
const clientId = message.body["clientId"];
|
|
123
|
+
const revision = message.body["controllerRevision"];
|
|
124
|
+
if (typeof clientId === "string" && typeof revision === "number")
|
|
125
|
+
this.#attachment = { clientId, controllerRevision: revision };
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if ((message.type === "bridge.result" || message.type === "result") && typeof message.requestId === "number") {
|
|
129
|
+
const resolvePromise = this.#pending.get(message.requestId);
|
|
130
|
+
if (resolvePromise) {
|
|
131
|
+
this.#pending.delete(message.requestId);
|
|
132
|
+
resolvePromise(message.body["accepted"] === true);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
#fail(retry = true) {
|
|
137
|
+
const transport = this.#transport;
|
|
138
|
+
this.#available = false;
|
|
139
|
+
this.#connected = false;
|
|
140
|
+
this.#attachment = null;
|
|
141
|
+
this.#transport = null;
|
|
142
|
+
transport?.destroy();
|
|
143
|
+
this.#startWaiter?.(false);
|
|
144
|
+
this.#startWaiter = null;
|
|
145
|
+
for (const resolvePromise of this.#pending.values())
|
|
146
|
+
resolvePromise(false);
|
|
147
|
+
this.#pending.clear();
|
|
148
|
+
if (retry && !this.#closed && this.#retry === null) {
|
|
149
|
+
this.#retry = setTimeout(() => {
|
|
150
|
+
this.#retry = null;
|
|
151
|
+
void this.start().then(connected => { if (!connected)
|
|
152
|
+
this.#fail(); });
|
|
153
|
+
}, 250);
|
|
154
|
+
this.#retry.unref();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type TerminalHostFallbackReason = "unsupported platform" | "terminal host not installed" | "host probe failed" | "incompatible generation" | "registry unreadable" | "recovery blocked" | "launch not verified" | "host startup timed out";
|
|
2
|
+
export type EnsureTerminalHostResult = {
|
|
3
|
+
readonly status: "ready";
|
|
4
|
+
readonly executable: string;
|
|
5
|
+
readonly endpoint: string;
|
|
6
|
+
} | {
|
|
7
|
+
readonly status: "fallback";
|
|
8
|
+
readonly reason: TerminalHostFallbackReason;
|
|
9
|
+
};
|
|
10
|
+
interface HostCommandResult {
|
|
11
|
+
readonly code: number;
|
|
12
|
+
readonly stdout: string;
|
|
13
|
+
readonly stderr: string;
|
|
14
|
+
}
|
|
15
|
+
export interface EnsureTerminalHostOptions {
|
|
16
|
+
readonly releaseRoot: string;
|
|
17
|
+
readonly profileRoot: string;
|
|
18
|
+
readonly dataDir: string;
|
|
19
|
+
readonly runtimeDir: string;
|
|
20
|
+
readonly environment: NodeJS.ProcessEnv;
|
|
21
|
+
readonly platform?: NodeJS.Platform;
|
|
22
|
+
readonly architecture?: string;
|
|
23
|
+
readonly timeoutMs?: number;
|
|
24
|
+
readonly run?: (executable: string, arguments_: readonly string[], environment: NodeJS.ProcessEnv, timeoutMs: number) => Promise<HostCommandResult>;
|
|
25
|
+
}
|
|
26
|
+
export declare function resolveTerminalHostPath(releaseRoot: string, environment?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, architecture?: string): string;
|
|
27
|
+
export declare function ensureTerminalHost(options: EnsureTerminalHostOptions): Promise<EnsureTerminalHostResult>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { PRODUCT_IDENTITY } from "../../product-identity.js";
|
|
6
|
+
export function resolveTerminalHostPath(releaseRoot, environment = process.env, platform = process.platform, architecture = process.arch) {
|
|
7
|
+
const override = environment[PRODUCT_IDENTITY.environment.terminalHostPath]?.trim();
|
|
8
|
+
if (override)
|
|
9
|
+
return resolve(override);
|
|
10
|
+
const executable = platform === "win32" ? "a1-terminal-host.exe" : "a1-terminal-host";
|
|
11
|
+
return resolve(releaseRoot, "dist", "native", `${platform}-${architecture}`, executable);
|
|
12
|
+
}
|
|
13
|
+
export async function ensureTerminalHost(options) {
|
|
14
|
+
const platform = options.platform ?? process.platform;
|
|
15
|
+
const architecture = options.architecture ?? process.arch;
|
|
16
|
+
if (!supported(platform, architecture))
|
|
17
|
+
return { status: "fallback", reason: "unsupported platform" };
|
|
18
|
+
const executable = resolveTerminalHostPath(options.releaseRoot, options.environment, platform, architecture);
|
|
19
|
+
try {
|
|
20
|
+
await access(executable, constants.R_OK | (platform === "win32" ? 0 : constants.X_OK));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return { status: "fallback", reason: "terminal host not installed" };
|
|
24
|
+
}
|
|
25
|
+
const run = options.run ?? runHostCommand;
|
|
26
|
+
const timeoutMs = options.timeoutMs ?? 8_000;
|
|
27
|
+
const common = ["--profile", options.profileRoot, "--data-dir", options.dataDir, "--runtime-dir", options.runtimeDir];
|
|
28
|
+
const environment = { ...options.environment };
|
|
29
|
+
try {
|
|
30
|
+
const probe = await run(executable, ["probe", ...common], environment, Math.min(4_000, timeoutMs));
|
|
31
|
+
const existing = parseResult(probe.stdout);
|
|
32
|
+
if (probe.code === 0) {
|
|
33
|
+
if (existing?.generation === 1 && typeof existing.endpoint === "string") {
|
|
34
|
+
return { status: "ready", executable, endpoint: existing.endpoint };
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
status: "fallback",
|
|
38
|
+
reason: typeof existing?.generation === "number" ? "incompatible generation" : "host probe failed",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (probe.code === 4 || existing?.outcome === "incompatible-generation")
|
|
42
|
+
return { status: "fallback", reason: "incompatible generation" };
|
|
43
|
+
if (existing?.outcome !== "not-running" && probe.code !== 3)
|
|
44
|
+
return { status: "fallback", reason: classifiedReason(existing?.reason, "host probe failed") };
|
|
45
|
+
const start = await run(executable, ["server", "--detach", ...common], environment, timeoutMs);
|
|
46
|
+
const started = parseResult(start.stdout);
|
|
47
|
+
if (start.code !== 0)
|
|
48
|
+
return { status: "fallback", reason: classifiedReason(started?.reason, "launch not verified") };
|
|
49
|
+
if (started?.verified !== true || typeof started.endpoint !== "string")
|
|
50
|
+
return { status: "fallback", reason: "launch not verified" };
|
|
51
|
+
const deadline = Date.now() + timeoutMs;
|
|
52
|
+
while (Date.now() < deadline) {
|
|
53
|
+
const result = await run(executable, ["probe", ...common], environment, Math.min(1_000, Math.max(1, deadline - Date.now())));
|
|
54
|
+
const ready = parseResult(result.stdout);
|
|
55
|
+
if (result.code === 0) {
|
|
56
|
+
if (ready?.generation === 1 && typeof ready.endpoint === "string") {
|
|
57
|
+
return { status: "ready", executable, endpoint: ready.endpoint };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
status: "fallback",
|
|
61
|
+
reason: typeof ready?.generation === "number" ? "incompatible generation" : "host probe failed",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (result.code === 4 || ready?.outcome === "incompatible-generation")
|
|
65
|
+
return { status: "fallback", reason: "incompatible generation" };
|
|
66
|
+
await new Promise(resolvePromise => setTimeout(resolvePromise, 40));
|
|
67
|
+
}
|
|
68
|
+
return { status: "fallback", reason: "host startup timed out" };
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
return { status: "fallback", reason: isTimeout(error) ? "host startup timed out" : "host probe failed" };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function supported(platform, architecture) {
|
|
75
|
+
return (platform === "win32" || platform === "linux") && architecture === "x64"
|
|
76
|
+
|| platform === "darwin" && architecture === "arm64";
|
|
77
|
+
}
|
|
78
|
+
function parseResult(output) {
|
|
79
|
+
const line = output.trim().split(/\r?\n/).filter(Boolean).at(-1);
|
|
80
|
+
if (!line)
|
|
81
|
+
return null;
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(line);
|
|
84
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function classifiedReason(value, fallback) {
|
|
91
|
+
if (value === "registry-unreadable")
|
|
92
|
+
return "registry unreadable";
|
|
93
|
+
if (value === "blocked")
|
|
94
|
+
return "recovery blocked";
|
|
95
|
+
if (value === "incompatible-generation")
|
|
96
|
+
return "incompatible generation";
|
|
97
|
+
return fallback;
|
|
98
|
+
}
|
|
99
|
+
function isTimeout(error) { return error instanceof Error && "code" in error && error.code === "ETIMEDOUT"; }
|
|
100
|
+
async function runHostCommand(executable, arguments_, environment, timeoutMs) {
|
|
101
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
102
|
+
const child = spawn(executable, [...arguments_], { env: environment, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
103
|
+
const stdout = [];
|
|
104
|
+
const stderr = [];
|
|
105
|
+
child.stdout.on("data", chunk => stdout.push(Buffer.from(chunk)));
|
|
106
|
+
child.stderr.on("data", chunk => stderr.push(Buffer.from(chunk)));
|
|
107
|
+
child.once("error", rejectPromise);
|
|
108
|
+
child.once("close", code => { clearTimeout(timer); resolvePromise({ code: code ?? 1, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }); });
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
child.kill("SIGKILL");
|
|
111
|
+
rejectPromise(Object.assign(new Error("terminal host command timed out"), { code: "ETIMEDOUT" }));
|
|
112
|
+
}, timeoutMs);
|
|
113
|
+
timer.unref();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ensureTerminalHost, resolveTerminalHostPath } from "./ensure-terminal-host.js";
|
|
2
|
+
export type { EnsureTerminalHostOptions, EnsureTerminalHostResult, TerminalHostFallbackReason } from "./ensure-terminal-host.js";
|
|
3
|
+
export { readResidentTabsSetting } from "./settings.js";
|
|
4
|
+
export { selectResidentHost } from "./routing.js";
|
|
5
|
+
export type { ResidentHostSelectionOptions } from "./routing.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ResidentEnvelope<T = Readonly<Record<string, unknown>>> {
|
|
2
|
+
readonly type: string;
|
|
3
|
+
readonly requestId?: number;
|
|
4
|
+
readonly body: T;
|
|
5
|
+
}
|
|
6
|
+
/** Generation-1 MessagePack named-map codec. It intentionally supports only bridge-safe values. */
|
|
7
|
+
export declare function encodeResidentFrame(value: ResidentEnvelope): Buffer;
|
|
8
|
+
export declare function decodeResidentPayload(payload: Uint8Array): ResidentEnvelope;
|
|
9
|
+
export declare class ResidentFrameDecoder {
|
|
10
|
+
#private;
|
|
11
|
+
push(chunk: Uint8Array): readonly ResidentEnvelope[];
|
|
12
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
const MAX_FRAME_BYTES = 2 * 1024 * 1024;
|
|
2
|
+
const MAX_DEPTH = 64;
|
|
3
|
+
/** Generation-1 MessagePack named-map codec. It intentionally supports only bridge-safe values. */
|
|
4
|
+
export function encodeResidentFrame(value) {
|
|
5
|
+
const payload = Buffer.from(encodeValue(value, 0));
|
|
6
|
+
if (payload.length > MAX_FRAME_BYTES)
|
|
7
|
+
throw new Error("resident frame exceeds 2 MiB");
|
|
8
|
+
const frame = Buffer.allocUnsafe(payload.length + 4);
|
|
9
|
+
frame.writeUInt32LE(payload.length, 0);
|
|
10
|
+
payload.copy(frame, 4);
|
|
11
|
+
return frame;
|
|
12
|
+
}
|
|
13
|
+
export function decodeResidentPayload(payload) {
|
|
14
|
+
if (payload.byteLength > MAX_FRAME_BYTES)
|
|
15
|
+
throw new Error("resident frame exceeds 2 MiB");
|
|
16
|
+
const state = { bytes: payload, offset: 0 };
|
|
17
|
+
const value = decodeValue(state, 0);
|
|
18
|
+
if (state.offset !== payload.byteLength || !isRecord(value) || typeof value.type !== "string" || !isRecord(value.body)) {
|
|
19
|
+
throw new Error("invalid resident envelope");
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
export class ResidentFrameDecoder {
|
|
24
|
+
#buffer = Buffer.alloc(0);
|
|
25
|
+
push(chunk) {
|
|
26
|
+
this.#buffer = Buffer.concat([this.#buffer, chunk]);
|
|
27
|
+
const messages = [];
|
|
28
|
+
while (this.#buffer.length >= 4) {
|
|
29
|
+
const length = this.#buffer.readUInt32LE(0);
|
|
30
|
+
if (length > MAX_FRAME_BYTES)
|
|
31
|
+
throw new Error("resident frame exceeds 2 MiB");
|
|
32
|
+
if (this.#buffer.length < length + 4)
|
|
33
|
+
break;
|
|
34
|
+
messages.push(decodeResidentPayload(this.#buffer.subarray(4, length + 4)));
|
|
35
|
+
this.#buffer = this.#buffer.subarray(length + 4);
|
|
36
|
+
}
|
|
37
|
+
return messages;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function encodeValue(value, depth) {
|
|
41
|
+
if (depth > MAX_DEPTH)
|
|
42
|
+
throw new Error("resident value exceeds nesting limit");
|
|
43
|
+
if (value === null)
|
|
44
|
+
return [0xc0];
|
|
45
|
+
if (value === false)
|
|
46
|
+
return [0xc2];
|
|
47
|
+
if (value === true)
|
|
48
|
+
return [0xc3];
|
|
49
|
+
if (typeof value === "number" && Number.isSafeInteger(value))
|
|
50
|
+
return encodeInteger(value);
|
|
51
|
+
if (typeof value === "string") {
|
|
52
|
+
const bytes = [...Buffer.from(value, "utf8")];
|
|
53
|
+
if (bytes.length < 32)
|
|
54
|
+
return [0xa0 | bytes.length, ...bytes];
|
|
55
|
+
if (bytes.length <= 0xff)
|
|
56
|
+
return [0xd9, bytes.length, ...bytes];
|
|
57
|
+
if (bytes.length <= 0xffff)
|
|
58
|
+
return [0xda, bytes.length >>> 8, bytes.length & 0xff, ...bytes];
|
|
59
|
+
return [0xdb, ...u32(bytes.length), ...bytes];
|
|
60
|
+
}
|
|
61
|
+
if (value instanceof Uint8Array) {
|
|
62
|
+
const bytes = [...value];
|
|
63
|
+
if (bytes.length <= 0xff)
|
|
64
|
+
return [0xc4, bytes.length, ...bytes];
|
|
65
|
+
if (bytes.length <= 0xffff)
|
|
66
|
+
return [0xc5, bytes.length >>> 8, bytes.length & 0xff, ...bytes];
|
|
67
|
+
return [0xc6, ...u32(bytes.length), ...bytes];
|
|
68
|
+
}
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
const head = value.length < 16 ? [0x90 | value.length] : value.length <= 0xffff ? [0xdc, value.length >>> 8, value.length & 0xff] : [0xdd, ...u32(value.length)];
|
|
71
|
+
return [...head, ...value.flatMap(entry => encodeValue(entry, depth + 1))];
|
|
72
|
+
}
|
|
73
|
+
if (isRecord(value)) {
|
|
74
|
+
const entries = Object.entries(value).filter(([, child]) => child !== undefined);
|
|
75
|
+
const head = entries.length < 16 ? [0x80 | entries.length] : entries.length <= 0xffff ? [0xde, entries.length >>> 8, entries.length & 0xff] : [0xdf, ...u32(entries.length)];
|
|
76
|
+
return [...head, ...entries.flatMap(([key, child]) => [...encodeValue(key, depth + 1), ...encodeValue(child, depth + 1)])];
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`unsupported resident value: ${typeof value}`);
|
|
79
|
+
}
|
|
80
|
+
function encodeInteger(value) {
|
|
81
|
+
if (value >= 0 && value <= 0x7f)
|
|
82
|
+
return [value];
|
|
83
|
+
if (value < 0 && value >= -32)
|
|
84
|
+
return [0x100 + value];
|
|
85
|
+
if (value >= 0 && value <= 0xff)
|
|
86
|
+
return [0xcc, value];
|
|
87
|
+
if (value >= 0 && value <= 0xffff)
|
|
88
|
+
return [0xcd, value >>> 8, value & 0xff];
|
|
89
|
+
if (value >= -0x80 && value < 0)
|
|
90
|
+
return [0xd0, value & 0xff];
|
|
91
|
+
if (value >= -0x8000 && value < 0)
|
|
92
|
+
return [0xd1, (value >>> 8) & 0xff, value & 0xff];
|
|
93
|
+
if (value >= 0 && value <= 0xffffffff)
|
|
94
|
+
return [0xce, ...u32(value)];
|
|
95
|
+
return [0xd3, ...i64(value)];
|
|
96
|
+
}
|
|
97
|
+
function decodeValue(state, depth) {
|
|
98
|
+
if (depth > MAX_DEPTH)
|
|
99
|
+
throw new Error("resident value exceeds nesting limit");
|
|
100
|
+
const marker = byte(state);
|
|
101
|
+
if (marker <= 0x7f)
|
|
102
|
+
return marker;
|
|
103
|
+
if (marker >= 0xe0)
|
|
104
|
+
return marker - 0x100;
|
|
105
|
+
if ((marker & 0xe0) === 0xa0)
|
|
106
|
+
return string(state, marker & 0x1f);
|
|
107
|
+
if ((marker & 0xf0) === 0x90)
|
|
108
|
+
return array(state, marker & 0x0f, depth);
|
|
109
|
+
if ((marker & 0xf0) === 0x80)
|
|
110
|
+
return map(state, marker & 0x0f, depth);
|
|
111
|
+
switch (marker) {
|
|
112
|
+
case 0xc0: return null;
|
|
113
|
+
case 0xc2: return false;
|
|
114
|
+
case 0xc3: return true;
|
|
115
|
+
case 0xc4: return binary(state, uint(state, 1));
|
|
116
|
+
case 0xc5: return binary(state, uint(state, 2));
|
|
117
|
+
case 0xc6: return binary(state, uint(state, 4));
|
|
118
|
+
case 0xcc: return uint(state, 1);
|
|
119
|
+
case 0xcd: return uint(state, 2);
|
|
120
|
+
case 0xce: return uint(state, 4);
|
|
121
|
+
case 0xcf: return safeInteger(state, false);
|
|
122
|
+
case 0xd0: return signed(state, 1);
|
|
123
|
+
case 0xd1: return signed(state, 2);
|
|
124
|
+
case 0xd2: return signed(state, 4);
|
|
125
|
+
case 0xd3: return safeInteger(state, true);
|
|
126
|
+
case 0xd9: return string(state, uint(state, 1));
|
|
127
|
+
case 0xda: return string(state, uint(state, 2));
|
|
128
|
+
case 0xdb: return string(state, uint(state, 4));
|
|
129
|
+
case 0xdc: return array(state, uint(state, 2), depth);
|
|
130
|
+
case 0xdd: return array(state, uint(state, 4), depth);
|
|
131
|
+
case 0xde: return map(state, uint(state, 2), depth);
|
|
132
|
+
case 0xdf: return map(state, uint(state, 4), depth);
|
|
133
|
+
default: throw new Error(`unsupported resident MessagePack marker 0x${marker.toString(16)}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function byte(state) {
|
|
137
|
+
const value = state.bytes[state.offset++];
|
|
138
|
+
if (value === undefined)
|
|
139
|
+
throw new Error("incomplete resident value");
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
function uint(state, width) {
|
|
143
|
+
let value = 0;
|
|
144
|
+
for (let index = 0; index < width; index++)
|
|
145
|
+
value = value * 256 + byte(state);
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
function signed(state, width) {
|
|
149
|
+
const value = uint(state, width);
|
|
150
|
+
return value >= 2 ** (width * 8 - 1) ? value - 2 ** (width * 8) : value;
|
|
151
|
+
}
|
|
152
|
+
function safeInteger(state, signedValue) {
|
|
153
|
+
const high = uint(state, 4);
|
|
154
|
+
const low = uint(state, 4);
|
|
155
|
+
const value = high * 0x1_0000_0000 + low;
|
|
156
|
+
const resolved = signedValue && high >= 0x80000000 ? value - 0x1_0000_0000_0000_0000 : value;
|
|
157
|
+
if (!Number.isSafeInteger(resolved))
|
|
158
|
+
throw new Error("resident integer exceeds JavaScript safe range");
|
|
159
|
+
return resolved;
|
|
160
|
+
}
|
|
161
|
+
function string(state, length) {
|
|
162
|
+
return Buffer.from(binary(state, length)).toString("utf8");
|
|
163
|
+
}
|
|
164
|
+
function binary(state, length) {
|
|
165
|
+
const end = state.offset + length;
|
|
166
|
+
if (end > state.bytes.byteLength)
|
|
167
|
+
throw new Error("incomplete resident value");
|
|
168
|
+
const value = state.bytes.slice(state.offset, end);
|
|
169
|
+
state.offset = end;
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
function array(state, length, depth) {
|
|
173
|
+
if (length > 65_536)
|
|
174
|
+
throw new Error("resident collection exceeds limit");
|
|
175
|
+
return Array.from({ length }, () => decodeValue(state, depth + 1));
|
|
176
|
+
}
|
|
177
|
+
function map(state, length, depth) {
|
|
178
|
+
if (length > 65_536)
|
|
179
|
+
throw new Error("resident collection exceeds limit");
|
|
180
|
+
const value = {};
|
|
181
|
+
for (let index = 0; index < length; index++) {
|
|
182
|
+
const key = decodeValue(state, depth + 1);
|
|
183
|
+
if (typeof key !== "string" || Object.hasOwn(value, key))
|
|
184
|
+
throw new Error("invalid resident map key");
|
|
185
|
+
value[key] = decodeValue(state, depth + 1);
|
|
186
|
+
}
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
function u32(value) { return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]; }
|
|
190
|
+
function i64(value) {
|
|
191
|
+
const integer = BigInt(value);
|
|
192
|
+
const encoded = integer < 0 ? (1n << 64n) + integer : integer;
|
|
193
|
+
return Array.from({ length: 8 }, (_, index) => Number((encoded >> BigInt((7 - index) * 8)) & 0xffn));
|
|
194
|
+
}
|
|
195
|
+
function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EnsureTerminalHostResult } from "./ensure-terminal-host.js";
|
|
2
|
+
export interface ResidentHostSelectionOptions {
|
|
3
|
+
readonly profileId: "a1" | "pi";
|
|
4
|
+
readonly hasSessionSelection: boolean;
|
|
5
|
+
readonly currentLaunchContract: boolean;
|
|
6
|
+
readonly readSetting: () => Promise<boolean>;
|
|
7
|
+
readonly ensureHost: () => Promise<EnsureTerminalHostResult>;
|
|
8
|
+
}
|
|
9
|
+
/** Invariant: every non-bare or default-off launch stays outside resident discovery and startup. */
|
|
10
|
+
export declare function selectResidentHost(options: ResidentHostSelectionOptions): Promise<EnsureTerminalHostResult | null>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Invariant: every non-bare or default-off launch stays outside resident discovery and startup. */
|
|
2
|
+
export async function selectResidentHost(options) {
|
|
3
|
+
if (options.profileId !== "a1" || options.hasSessionSelection || !options.currentLaunchContract)
|
|
4
|
+
return null;
|
|
5
|
+
if (!await options.readSetting())
|
|
6
|
+
return null;
|
|
7
|
+
return await options.ensureHost();
|
|
8
|
+
}
|