@timurproko/a1 0.2.7-dev.767 → 0.2.7-dev.779
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 +30 -0
- package/bin/cli.js +4 -0
- package/bin/tab-launch-context.d.ts +3 -0
- package/bin/tab-launch-context.js +52 -3
- package/bin/ui.js +6 -3
- package/dist/app/session-shell/session-shell-root.d.ts +2 -1
- package/dist/app/session-shell/session-shell-root.js +2 -2
- package/dist/app/session-shell/session-shell.js +4 -8
- package/dist/app/session-shell/shell-host.d.ts +2 -0
- package/dist/app/session-shell/shell-host.js +20 -1
- package/dist/cli/dispatch.d.ts +11 -1
- package/dist/cli/dispatch.js +79 -5
- package/dist/cli/tabs-request.d.ts +7 -0
- package/dist/cli/tabs-request.js +1 -0
- package/dist/cli/tabs.d.ts +14 -0
- package/dist/cli/tabs.js +140 -0
- package/dist/composition/owned-ui.d.ts +8 -2
- package/dist/composition/owned-ui.js +109 -12
- package/dist/features/launch/index.d.ts +1 -1
- package/dist/features/launch/intent.d.ts +3 -1
- package/dist/features/launch/intent.js +7 -4
- package/dist/features/resident-tab/auto-name.d.ts +11 -0
- package/dist/features/resident-tab/auto-name.js +42 -0
- package/dist/features/resident-tab/index.d.ts +3 -0
- package/dist/features/resident-tab/index.js +3 -0
- package/dist/features/resident-tab/shortcuts.d.ts +10 -0
- package/dist/features/resident-tab/shortcuts.js +35 -0
- package/dist/features/resident-tab/status-deriver.d.ts +9 -0
- package/dist/features/resident-tab/status-deriver.js +15 -0
- package/dist/foundation/launch-context/index.d.ts +4 -0
- package/dist/foundation/launch-context/index.js +10 -0
- package/dist/foundation/release/bootstrap.d.ts +4 -2
- package/dist/foundation/release/bootstrap.js +11 -8
- package/dist/foundation/release/cohort-state.d.ts +1 -1
- package/dist/foundation/release/cohort-state.js +1 -1
- package/dist/foundation/release/release-gc.d.ts +3 -0
- package/dist/foundation/release/release-gc.js +44 -8
- package/dist/foundation/resident-tabs/bounded-host-command.d.ts +16 -0
- package/dist/foundation/resident-tabs/bounded-host-command.js +35 -0
- package/dist/foundation/resident-tabs/bridge-client.d.ts +18 -3
- package/dist/foundation/resident-tabs/bridge-client.js +87 -7
- package/dist/foundation/resident-tabs/doctor.d.ts +29 -0
- package/dist/foundation/resident-tabs/doctor.js +166 -0
- package/dist/foundation/resident-tabs/ensure-terminal-host.d.ts +2 -1
- package/dist/foundation/resident-tabs/ensure-terminal-host.js +33 -7
- package/dist/foundation/resident-tabs/index.d.ts +7 -1
- package/dist/foundation/resident-tabs/index.js +4 -1
- package/dist/foundation/resident-tabs/maintenance.d.ts +42 -0
- package/dist/foundation/resident-tabs/maintenance.js +78 -0
- package/dist/foundation/resident-tabs/release-holds.d.ts +27 -0
- package/dist/foundation/resident-tabs/release-holds.js +77 -0
- package/dist/foundation/resident-tabs/routing.d.ts +4 -3
- package/dist/foundation/resident-tabs/routing.js +6 -4
- package/dist/foundation/resident-tabs/settings.d.ts +7 -2
- package/dist/foundation/resident-tabs/settings.js +17 -16
- package/dist/foundation/resident-tabs/terminal-host-artifact.d.ts +18 -0
- package/dist/foundation/resident-tabs/terminal-host-artifact.js +53 -0
- package/dist/foundation/resident-tabs/zip-archive.d.ts +9 -0
- package/dist/foundation/resident-tabs/zip-archive.js +56 -0
- package/dist/foundation/session-writer-lock/index.d.ts +5 -0
- package/dist/foundation/session-writer-lock/index.js +13 -9
- package/dist/integrations/pi/components/shell-footer-status.d.ts +1 -1
- package/dist/integrations/pi/components/shell-footer-status.js +2 -2
- package/dist/integrations/pi/components/shell-hotkey-sections.js +1 -1
- package/dist/integrations/pi/components/shell-presenters-info.d.ts +4 -2
- package/dist/integrations/pi/components/shell-presenters-info.js +38 -4
- package/dist/integrations/pi/components/upstream/components/session-footer.d.ts +2 -1
- package/dist/integrations/pi/components/upstream/components/session-footer.js +7 -4
- package/dist/integrations/pi/engine/adapter.d.ts +1 -0
- package/dist/integrations/pi/engine/adapter.js +3 -0
- package/dist/integrations/pi/engine/prompt-suggestions.d.ts +1 -0
- package/dist/integrations/pi/engine/prompt-suggestions.js +40 -0
- package/dist/native/darwin-arm64/a1_session_lock.node +0 -0
- package/dist/native/darwin-arm64/manifest.json +2 -2
- package/dist/native/darwin-arm64/process-guardian +0 -0
- package/dist/native/darwin-arm64/terminal-host +0 -0
- package/dist/native/darwin-arm64/terminal-host-THIRD-PARTY-NOTICES.txt +10500 -0
- package/dist/native/darwin-arm64/terminal-host.manifest.json +44 -0
- package/dist/native/darwin-x64/a1_session_lock.node +0 -0
- package/dist/native/darwin-x64/manifest.json +20 -0
- package/dist/native/darwin-x64/process-guardian +0 -0
- package/dist/native/darwin-x64/terminal-host +0 -0
- package/dist/native/darwin-x64/terminal-host-THIRD-PARTY-NOTICES.txt +10737 -0
- package/dist/native/darwin-x64/terminal-host.manifest.json +44 -0
- package/dist/native/linux-x64/a1_session_lock.node +0 -0
- package/dist/native/linux-x64/manifest.json +2 -2
- package/dist/native/linux-x64/process-guardian +0 -0
- package/dist/native/linux-x64/terminal-host +0 -0
- package/dist/native/linux-x64/terminal-host-THIRD-PARTY-NOTICES.txt +11226 -0
- package/dist/native/linux-x64/terminal-host.manifest.json +44 -0
- package/dist/native/win32-x64/a1_session_lock.node +0 -0
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/dist/native/win32-x64/terminal-host-THIRD-PARTY-NOTICES.txt +10757 -0
- package/dist/native/win32-x64/terminal-host.exe +0 -0
- package/dist/native/win32-x64/terminal-host.manifest.json +44 -0
- package/dist/ui/settings/declarations.d.ts +19 -43
- package/dist/ui/settings/declarations.js +19 -35
- package/dist/ui/settings/index.d.ts +1 -1
- package/dist/ui/settings/index.js +1 -1
- package/dist/ui/settings/manager.d.ts +1 -0
- package/dist/ui/settings/manager.js +9 -1
- package/dist/ui/settings/migrations.js +12 -0
- package/dist/ui/settings/resolution.d.ts +0 -2
- package/dist/ui/settings/resolution.js +1 -13
- package/dist/ui/settings/sections.js +0 -4
- package/docs/architecture/evidence/resident-tabs/linux-x64.md +37 -0
- package/docs/architecture/evidence/resident-tabs/macos-arm64.md +36 -0
- package/docs/architecture/evidence/resident-tabs/macos-x64.md +35 -0
- package/docs/architecture/evidence/resident-tabs/windows-x64.md +36 -0
- package/docs/architecture/internal-naming.md +2 -0
- package/docs/architecture/process-guardian-provenance.md +2 -0
- package/docs/architecture/resident-tabs-requirements.md +1 -563
- package/docs/architecture/resident-tabs.md +166 -94
- package/docs/architecture/terminal-host-provenance.md +13 -3
- package/docs/architecture/toolchain.md +1 -1
- package/docs/ci-release-runbook.md +2 -2
- package/docs/features/launch-profiles.md +11 -8
- package/docs/manual-resident-tabs-acceptance.md +51 -0
- package/docs/openspec-archive-automation.md +15 -3
- package/docs/validation.md +9 -1
- package/package.json +5 -1
|
@@ -8,6 +8,7 @@ export interface BootstrapOptions {
|
|
|
8
8
|
readonly launchIntent?: {
|
|
9
9
|
readonly kind: "interactive";
|
|
10
10
|
readonly profileId: LaunchProfileId;
|
|
11
|
+
readonly launchMode: "tabs" | "solo";
|
|
11
12
|
readonly sessionSelection?: SessionSelection;
|
|
12
13
|
};
|
|
13
14
|
readonly environment?: NodeJS.ProcessEnv;
|
|
@@ -24,9 +25,10 @@ export interface BootstrapOptions {
|
|
|
24
25
|
readonly releaseIsLaunchable?: (releaseRoot: string) => boolean;
|
|
25
26
|
/** Test seam for bounded maintenance scheduled after stale endpoint sweeping. */
|
|
26
27
|
readonly scheduleMaintenance?: (dataDir: string, paths: ReturnType<typeof resolveProductPaths>) => Promise<void>;
|
|
27
|
-
/** Test
|
|
28
|
-
readonly readResidentTabs?: (profileDir: string, namespace: string, legacyConfigDir: string, profileId: string) => Promise<boolean>;
|
|
28
|
+
/** Test seam for resident launch routing before the guardian starts. */
|
|
29
29
|
readonly ensureTerminalHost?: (options: EnsureTerminalHostOptions) => Promise<EnsureTerminalHostResult>;
|
|
30
|
+
/** Test seam for whether this process's standard input and output are terminals; absent reads them. */
|
|
31
|
+
readonly interactiveTerminal?: boolean;
|
|
30
32
|
}
|
|
31
33
|
export declare function runBootstrap(options: BootstrapOptions): Promise<number>;
|
|
32
34
|
export declare function certifyMaterializedRelease(release: MaterializedRelease, dataDir: string, verification?: VerifyMaterializedReleaseOptions): Promise<string>;
|
|
@@ -24,13 +24,15 @@ export async function runBootstrap(options) {
|
|
|
24
24
|
return await runBootstrapAttempt(options, 1);
|
|
25
25
|
}
|
|
26
26
|
async function runBootstrapAttempt(options, retriesRemaining) {
|
|
27
|
-
const environment = withLaunchContext(options.environment ?? process.env, { launchProfile: options.launchIntent?.profileId ?? "a1" });
|
|
28
|
-
await markStartupPhase(environment, "bootstrap-start");
|
|
29
27
|
const launchProfileId = options.launchIntent?.profileId ?? "a1";
|
|
28
|
+
const launchMode = options.launchIntent?.launchMode ?? (launchProfileId === "a1" ? "tabs" : "solo");
|
|
29
|
+
const environment = withLaunchContext(options.environment ?? process.env, { launchProfile: launchProfileId, launchMode });
|
|
30
|
+
await markStartupPhase(environment, "bootstrap-start");
|
|
30
31
|
assertLaunchProfileId(launchProfileId);
|
|
31
32
|
const sessionArgs = sessionSelectionArguments(options.launchIntent?.sessionSelection);
|
|
32
|
-
if (sessionArgs.length > 0 && launchProfileId !== "a1")
|
|
33
|
-
throw new Error("session selection requires the normal A1
|
|
33
|
+
if (sessionArgs.length > 0 && (launchProfileId !== "a1" || launchMode === "solo")) {
|
|
34
|
+
throw new Error("session selection requires the normal tabbed A1 launch");
|
|
35
|
+
}
|
|
34
36
|
const output = options.output ?? process.stderr;
|
|
35
37
|
const paths = resolveProductPaths(environment);
|
|
36
38
|
await mkdir(paths.runtimeDir, { recursive: true, mode: 0o700 });
|
|
@@ -44,11 +46,9 @@ async function runBootstrapAttempt(options, retriesRemaining) {
|
|
|
44
46
|
: null;
|
|
45
47
|
const resident = await selectResidentHost({
|
|
46
48
|
profileId: launchProfileId,
|
|
47
|
-
hasSessionSelection: sessionArgs.length > 0,
|
|
48
49
|
currentLaunchContract: isCurrentLaunchContract(release),
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
: () => options.readResidentTabs(agentProfileDir, PRODUCT_IDENTITY.commandName, paths.configDir, launchProfileId),
|
|
50
|
+
solo: launchMode === "solo",
|
|
51
|
+
interactive: options.interactiveTerminal ?? (process.stdin.isTTY === true && process.stdout.isTTY === true),
|
|
52
52
|
ensureHost: async () => (options.ensureTerminalHost ?? ensureTerminalHost)({
|
|
53
53
|
releaseRoot: release.releaseRoot,
|
|
54
54
|
profileRoot: environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir,
|
|
@@ -64,6 +64,7 @@ async function runBootstrapAttempt(options, retriesRemaining) {
|
|
|
64
64
|
residentRootExecutable: resident.executable,
|
|
65
65
|
residentRootArguments: JSON.stringify([
|
|
66
66
|
"attach", "--endpoint", resident.endpoint, "--cwd", process.cwd(), "--clear-exit-ms", String(CLEAR_EXIT_INTERVAL_MS),
|
|
67
|
+
"--tabs-max", String((await residentLaunchSettings).tabsMax),
|
|
67
68
|
"--profile", environment[PRODUCT_IDENTITY.environment.profileHome] ?? paths.configDir,
|
|
68
69
|
"--data-dir", paths.dataDir, "--runtime-dir", paths.runtimeDir,
|
|
69
70
|
"--release-root", release.releaseRoot, "--node-executable", process.execPath,
|
|
@@ -395,12 +396,14 @@ async function launchUi(release, environment, sessionArgs, residentContext = {})
|
|
|
395
396
|
* version instead of failing while an installation is being replaced.
|
|
396
397
|
*/
|
|
397
398
|
export function releaseEnvironment(environment, release, profile) {
|
|
399
|
+
const launchMode = readLaunchContext(environment).launchMode;
|
|
398
400
|
return withLaunchContext(environment, {
|
|
399
401
|
releaseId: release.releaseId,
|
|
400
402
|
releaseLayers: (release.dependencyLayers ?? []).map(layer => layer.layerId).join(","),
|
|
401
403
|
releaseRoot: release.releaseRoot,
|
|
402
404
|
releaseDigest: release.contentDigest,
|
|
403
405
|
...(profile === undefined ? {} : { launchProfile: profile }),
|
|
406
|
+
...(launchMode === undefined ? {} : { launchMode }),
|
|
404
407
|
}, process.platform, launchContractTarget(release));
|
|
405
408
|
}
|
|
406
409
|
export async function waitForVerifiedEndpoint(path, release, timeoutMs, startup) {
|
|
@@ -19,7 +19,7 @@ export interface ReleaseReferences {
|
|
|
19
19
|
readonly rollback: string | null;
|
|
20
20
|
readonly retention: readonly string[];
|
|
21
21
|
}
|
|
22
|
-
export type ExternalReleaseHoldAuthority = "agent" | "migration";
|
|
22
|
+
export type ExternalReleaseHoldAuthority = "agent" | "migration" | "resident";
|
|
23
23
|
/** A current hold supplied by an authority that must participate in every reconciliation. */
|
|
24
24
|
export interface ExternalReleaseHold {
|
|
25
25
|
readonly authority: ExternalReleaseHoldAuthority;
|
|
@@ -12,7 +12,7 @@ const RELEASE_ID_PATTERN = /^[0-9A-Za-z.+_-]+-[a-f0-9]{20}$/;
|
|
|
12
12
|
export function planProtectedReleases(state, inputs = {}) {
|
|
13
13
|
const holds = inputs.externalHolds ?? [];
|
|
14
14
|
for (const hold of holds) {
|
|
15
|
-
if (hold.authority !== "agent" && hold.authority !== "migration")
|
|
15
|
+
if (hold.authority !== "agent" && hold.authority !== "migration" && hold.authority !== "resident")
|
|
16
16
|
throw new Error(`unknown release hold authority: ${String(hold.authority)}`);
|
|
17
17
|
if (!state.releases[hold.releaseId] && !state.cleanup.pending[hold.releaseId]) {
|
|
18
18
|
throw new Error(`external ${hold.authority} hold names unknown release ${hold.releaseId}`);
|
|
@@ -2,6 +2,7 @@ import { rename, rm } from "node:fs/promises";
|
|
|
2
2
|
import { CohortStateStore, type ExternalReleaseHold, type OrphanReleaseDisposition } from "./cohort-state.js";
|
|
3
3
|
import type { ProductPaths } from "../lifecycle/index.js";
|
|
4
4
|
import { UpdateTransactionStore } from "./update-transaction.js";
|
|
5
|
+
import { type ResidentReleaseHoldReport } from "../resident-tabs/index.js";
|
|
5
6
|
export interface ReleaseCleanupLimits {
|
|
6
7
|
readonly maxItems: number;
|
|
7
8
|
readonly maxDurationMs: number;
|
|
@@ -21,6 +22,8 @@ export interface ReleaseCleanupOptions {
|
|
|
21
22
|
readonly operations?: ReleaseCleanupOperations;
|
|
22
23
|
/** Process-start seam for scheduler and continuation tests. */
|
|
23
24
|
readonly workerSpawner?: (entry: string, environment: NodeJS.ProcessEnv) => Promise<void>;
|
|
25
|
+
/** Resident-tab inspection seam; the default asks a verified installed terminal host. */
|
|
26
|
+
readonly residentHolds?: (dataDir: string, releaseRoots: readonly string[]) => Promise<ResidentReleaseHoldReport>;
|
|
24
27
|
}
|
|
25
28
|
export interface ReleaseCleanupWorkerOptions {
|
|
26
29
|
readonly cleanup?: ReleaseCleanupOptions;
|
|
@@ -14,6 +14,7 @@ import { UpdateTransactionStore } from "./update-transaction.js";
|
|
|
14
14
|
import { cleanupUpdateRecoveryCapsules } from "./update-recovery.js";
|
|
15
15
|
import { PRODUCT_IDENTITY } from "../../product-identity.js";
|
|
16
16
|
import { collectCompileCaches, startupCompileCachePath } from "../startup/index.js";
|
|
17
|
+
import { readResidentReleaseHolds } from "../resident-tabs/index.js";
|
|
17
18
|
const DEFAULT_CANDIDATE_AGE_MS = 60 * 60 * 1_000;
|
|
18
19
|
const DEFAULT_LIMITS = { maxItems: 8, maxDurationMs: 2_000, concurrency: 1 };
|
|
19
20
|
const DEFAULT_WORKER_DURATION_MS = 10 * 60 * 1_000;
|
|
@@ -30,7 +31,7 @@ export async function prepareReleaseCleanup(dataDir, paths, options = {}) {
|
|
|
30
31
|
const store = new CohortStateStore(dataDir);
|
|
31
32
|
const discovered = await discoverCleanupInputs(dataDir, store, options);
|
|
32
33
|
const transactionStore = options.transactionStore ?? new UpdateTransactionStore(dataDir);
|
|
33
|
-
await store.reconcileRetention(async () => await protectionInputs(paths, transactionStore, options.externalHolds ?? []), discovered.orphans);
|
|
34
|
+
await store.reconcileRetention(async () => await protectionInputs(dataDir, paths, transactionStore, options.externalHolds ?? [], options.residentHolds), discovered.orphans);
|
|
34
35
|
for (const diagnostic of discovered.diagnostics.slice(0, 16)) {
|
|
35
36
|
await store.recordCleanupFailure(diagnostic.releaseId, diagnostic.stage, diagnostic.error);
|
|
36
37
|
}
|
|
@@ -77,7 +78,7 @@ export async function runBoundedReleaseCleanup(dataDir, paths, options = {}) {
|
|
|
77
78
|
maximum -= 1;
|
|
78
79
|
const certificationId = certificationReleaseId(artifact);
|
|
79
80
|
if (certificationId !== null) {
|
|
80
|
-
const inputs = await protectionInputs(paths, transactionStore, options.externalHolds ?? []);
|
|
81
|
+
const inputs = await protectionInputs(dataDir, paths, transactionStore, options.externalHolds ?? [], options.residentHolds);
|
|
81
82
|
if (isProtectedDetachedRelease(await store.read(), inputs, certificationId))
|
|
82
83
|
continue;
|
|
83
84
|
}
|
|
@@ -232,7 +233,7 @@ export async function collectRelease(store, dataDir, releaseId, externalReferenc
|
|
|
232
233
|
const state = await store.read();
|
|
233
234
|
if (!state.releases[releaseId])
|
|
234
235
|
throw new Error(`unknown release ${releaseId}`);
|
|
235
|
-
await store.reconcileRetention(async () => ({ ...(await protectionInputs(paths, new UpdateTransactionStore(dataDir), holds)) }));
|
|
236
|
+
await store.reconcileRetention(async () => ({ ...(await protectionInputs(dataDir, paths, new UpdateTransactionStore(dataDir), holds)) }));
|
|
236
237
|
const disposition = (await store.read()).cleanup.pending[releaseId];
|
|
237
238
|
if (!disposition)
|
|
238
239
|
throw new Error(`release ${releaseId} is still referenced and cannot be collected`);
|
|
@@ -243,7 +244,7 @@ export async function collectRelease(store, dataDir, releaseId, externalReferenc
|
|
|
243
244
|
async function collectDisposition(store, dataDir, paths, disposition, options) {
|
|
244
245
|
const releaseId = disposition.release.releaseId;
|
|
245
246
|
try {
|
|
246
|
-
const inputs = await protectionInputs(paths, options.transactionStore ?? new UpdateTransactionStore(dataDir), options.externalHolds ?? []);
|
|
247
|
+
const inputs = await protectionInputs(dataDir, paths, options.transactionStore ?? new UpdateTransactionStore(dataDir), options.externalHolds ?? [], options.residentHolds);
|
|
247
248
|
const current = await store.read();
|
|
248
249
|
if (isProtectedDetachedRelease(current, inputs, releaseId))
|
|
249
250
|
return false;
|
|
@@ -289,12 +290,47 @@ function isProtectedDetachedRelease(state, inputs, releaseId) {
|
|
|
289
290
|
|| (inputs.externalHolds ?? []).some(hold => hold.releaseId === releaseId)
|
|
290
291
|
|| (inputs.transaction?.status === "active" && inputs.transaction.priorActiveReleaseId === releaseId);
|
|
291
292
|
}
|
|
292
|
-
async function protectionInputs(paths, transactionStore, externalHolds) {
|
|
293
|
-
const [liveIds, transaction] = await Promise.all([
|
|
293
|
+
async function protectionInputs(dataDir, paths, transactionStore, externalHolds, residentHolds) {
|
|
294
|
+
const [liveIds, transaction, resident] = await Promise.all([
|
|
294
295
|
paths ? liveReleaseIds(paths) : Promise.resolve([]),
|
|
295
296
|
transactionStore.read(),
|
|
297
|
+
residentReleaseHolds(dataDir, residentHolds),
|
|
296
298
|
]);
|
|
297
|
-
return { liveReleaseIds: liveIds, externalHolds, transaction: transactionReference(transaction) };
|
|
299
|
+
return { liveReleaseIds: liveIds, externalHolds: [...externalHolds, ...resident], transaction: transactionReference(transaction) };
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Holds every release a live resident server, holder, or tab child runs from. Retention fails
|
|
303
|
+
* closed: an incomplete report, or a recorded root inside the release store that names no known
|
|
304
|
+
* release, holds every known release.
|
|
305
|
+
*/
|
|
306
|
+
async function residentReleaseHolds(dataDir, reader) {
|
|
307
|
+
const state = await new CohortStateStore(dataDir).read();
|
|
308
|
+
const records = [
|
|
309
|
+
...Object.values(state.releases),
|
|
310
|
+
...Object.values(state.cleanup.pending).map(disposition => disposition.release),
|
|
311
|
+
];
|
|
312
|
+
const preferred = [state.references.active, state.references.rollback]
|
|
313
|
+
.flatMap(id => id && state.releases[id] ? [state.releases[id].releaseRoot] : []);
|
|
314
|
+
const roots = [...new Set([...preferred, ...Object.values(state.releases).map(record => record.releaseRoot)])];
|
|
315
|
+
const report = await (reader ?? ((directory, releaseRoots) => readResidentReleaseHolds({ dataDir: directory, releaseRoots })))(dataDir, roots);
|
|
316
|
+
const everything = () => [...new Set(records.map(record => record.releaseId))].map(releaseId => ({ authority: "resident", releaseId }));
|
|
317
|
+
if (!report.complete)
|
|
318
|
+
return everything();
|
|
319
|
+
const byRoot = new Map(records.map(record => [comparablePath(record.releaseRoot), record.releaseId]));
|
|
320
|
+
const store = comparablePath(resolve(dataDir, "releases"));
|
|
321
|
+
const holds = new Set();
|
|
322
|
+
for (const root of report.releaseRoots) {
|
|
323
|
+
const releaseId = byRoot.get(comparablePath(root));
|
|
324
|
+
if (releaseId)
|
|
325
|
+
holds.add(releaseId);
|
|
326
|
+
else if (comparablePath(root).startsWith(`${store}${sep}`))
|
|
327
|
+
return everything();
|
|
328
|
+
}
|
|
329
|
+
return [...holds].sort().map(releaseId => ({ authority: "resident", releaseId }));
|
|
330
|
+
}
|
|
331
|
+
function comparablePath(path) {
|
|
332
|
+
const absolute = resolve(path);
|
|
333
|
+
return process.platform === "win32" ? absolute.toLowerCase() : absolute;
|
|
298
334
|
}
|
|
299
335
|
function transactionReference(transaction) {
|
|
300
336
|
return transaction === null ? null : { status: transaction.status, priorActiveReleaseId: transaction.priorActiveReleaseId };
|
|
@@ -745,7 +781,7 @@ function parseWorkerHolds(value) {
|
|
|
745
781
|
if (!Array.isArray(parsed))
|
|
746
782
|
return [];
|
|
747
783
|
return parsed.filter((hold) => Boolean(hold) && typeof hold === "object"
|
|
748
|
-
&&
|
|
784
|
+
&& ["agent", "migration", "resident"].includes(hold.authority)
|
|
749
785
|
&& typeof hold.releaseId === "string");
|
|
750
786
|
}
|
|
751
787
|
catch {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface BoundedHostCommandResult {
|
|
2
|
+
readonly code: number;
|
|
3
|
+
readonly stdout: string;
|
|
4
|
+
}
|
|
5
|
+
export interface BoundedHostCommandLimits {
|
|
6
|
+
/** Output beyond this many bytes fails the exchange instead of being parsed. */
|
|
7
|
+
readonly maxStdoutBytes: number;
|
|
8
|
+
readonly timeoutMs: number;
|
|
9
|
+
/** Names the exchange in the timeout error. */
|
|
10
|
+
readonly label: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Runs one terminal-host command whose report is small. An oversized report kills the command and
|
|
14
|
+
* reports a failed exchange; a command that outlives its deadline is killed and rejected.
|
|
15
|
+
*/
|
|
16
|
+
export declare function runBoundedHostCommand(executable: string, arguments_: readonly string[], limits: BoundedHostCommandLimits): Promise<BoundedHostCommandResult>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Runs one terminal-host command whose report is small. An oversized report kills the command and
|
|
4
|
+
* reports a failed exchange; a command that outlives its deadline is killed and rejected.
|
|
5
|
+
*/
|
|
6
|
+
export async function runBoundedHostCommand(executable, arguments_, limits) {
|
|
7
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
8
|
+
const child = spawn(executable, [...arguments_], { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
9
|
+
const stdout = [];
|
|
10
|
+
let size = 0;
|
|
11
|
+
let overflowed = false;
|
|
12
|
+
child.stdout.on("data", chunk => {
|
|
13
|
+
size += chunk.length;
|
|
14
|
+
// Security: an oversized answer is a failed exchange, never a truncated report to parse.
|
|
15
|
+
if (size > limits.maxStdoutBytes) {
|
|
16
|
+
overflowed = true;
|
|
17
|
+
child.kill("SIGKILL");
|
|
18
|
+
}
|
|
19
|
+
else
|
|
20
|
+
stdout.push(Buffer.from(chunk));
|
|
21
|
+
});
|
|
22
|
+
child.once("error", rejectPromise);
|
|
23
|
+
child.once("close", code => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
resolvePromise(overflowed
|
|
26
|
+
? { code: code === 0 || code === null ? 1 : code, stdout: "" }
|
|
27
|
+
: { code: code ?? 1, stdout: Buffer.concat(stdout).toString("utf8") });
|
|
28
|
+
});
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
child.kill("SIGKILL");
|
|
31
|
+
rejectPromise(new Error(`${limits.label} timed out`));
|
|
32
|
+
}, limits.timeoutMs);
|
|
33
|
+
timer.unref();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ResidentTabStatus = "starting" | "working" | "idle" | "unresponsive" | "stalled" | "crashed" | "restarting" | "failed";
|
|
1
|
+
export type ResidentTabStatus = "starting" | "working" | "needs-input" | "done-unseen" | "idle" | "unresponsive" | "stalled" | "crashed" | "restarting" | "failed";
|
|
2
2
|
export interface TabBridgeCredentials {
|
|
3
3
|
readonly tabId: string;
|
|
4
4
|
readonly token: string;
|
|
@@ -8,6 +8,17 @@ export interface TabAttachment {
|
|
|
8
8
|
readonly clientId: string;
|
|
9
9
|
readonly controllerRevision: number;
|
|
10
10
|
}
|
|
11
|
+
export type TabBridgeCommand = {
|
|
12
|
+
readonly type: "visibility";
|
|
13
|
+
readonly visible: boolean;
|
|
14
|
+
} | {
|
|
15
|
+
readonly type: "rename";
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly nameSource: "default" | "user" | "auto";
|
|
18
|
+
} | {
|
|
19
|
+
readonly type: "stop";
|
|
20
|
+
readonly deadlineMs: number;
|
|
21
|
+
};
|
|
11
22
|
interface BridgeTransport {
|
|
12
23
|
write(bytes: Uint8Array): boolean;
|
|
13
24
|
destroy(): void;
|
|
@@ -30,12 +41,16 @@ export declare class TabBridgeClient {
|
|
|
30
41
|
constructor(options: TabBridgeOptions);
|
|
31
42
|
get available(): boolean;
|
|
32
43
|
captureAttachment(): TabAttachment | null;
|
|
44
|
+
onCommand(listener: (command: TabBridgeCommand) => void): () => void;
|
|
33
45
|
start(timeoutMs?: number): Promise<boolean>;
|
|
34
|
-
status(state: ResidentTabStatus, degraded?: boolean): void;
|
|
46
|
+
status(state: ResidentTabStatus, degraded?: boolean, pendingRequests?: number, queuedInput?: number): void;
|
|
35
47
|
degraded(degraded: boolean): void;
|
|
36
|
-
|
|
48
|
+
accent(accent: string): void;
|
|
49
|
+
session(file: string | null, name: string | null, nameSource?: "default" | "user" | "auto"): void;
|
|
50
|
+
prompt(interrupted: boolean): void;
|
|
37
51
|
requestDetach(origin?: TabAttachment | null, timeoutMs?: number): Promise<boolean>;
|
|
38
52
|
requestRestart(origin?: TabAttachment | null, timeoutMs?: number): Promise<boolean>;
|
|
53
|
+
consumeControllerBoundaryInput(data: string): string;
|
|
39
54
|
close(): void;
|
|
40
55
|
}
|
|
41
56
|
export {};
|
|
@@ -13,13 +13,20 @@ export class TabBridgeClient {
|
|
|
13
13
|
#closed = false;
|
|
14
14
|
#retry = null;
|
|
15
15
|
#latestStatus = null;
|
|
16
|
+
#accent = null;
|
|
16
17
|
#latestSession = null;
|
|
17
18
|
#heartbeat = null;
|
|
18
19
|
#pending = new Map();
|
|
19
20
|
#startWaiter = null;
|
|
21
|
+
#commandListeners = new Set();
|
|
22
|
+
#boundaryInputRemainder = "";
|
|
20
23
|
constructor(options) { this.#options = options; }
|
|
21
24
|
get available() { return this.#available && this.#connected; }
|
|
22
25
|
captureAttachment() { return this.#attachment === null ? null : { ...this.#attachment }; }
|
|
26
|
+
onCommand(listener) {
|
|
27
|
+
this.#commandListeners.add(listener);
|
|
28
|
+
return () => this.#commandListeners.delete(listener);
|
|
29
|
+
}
|
|
23
30
|
async start(timeoutMs = 4_000) {
|
|
24
31
|
if (this.#closed || this.#transport !== null)
|
|
25
32
|
return this.available;
|
|
@@ -56,7 +63,7 @@ export class TabBridgeClient {
|
|
|
56
63
|
transport.once("connect", () => {
|
|
57
64
|
this.#send("hello", {
|
|
58
65
|
role: "bridge", generation: 1, build: this.#options.build ?? "a1",
|
|
59
|
-
features: ["bridge-status", "bridge-detach", "bridge-heartbeat", "bridge-restart", "recovery-journal"],
|
|
66
|
+
features: ["bridge-status", "bridge-detach", "bridge-heartbeat", "bridge-restart", "bridge-visibility", "bridge-rename", "bridge-stop", "controller-boundary", "recovery-journal"],
|
|
60
67
|
token: [...Buffer.from(this.#options.credentials.token, "base64")], tabId: this.#options.credentials.tabId,
|
|
61
68
|
pid: this.#options.pid ?? process.pid, startIdentity: this.#options.startIdentity ?? `${process.pid}:node`,
|
|
62
69
|
}, ++this.#request);
|
|
@@ -66,23 +73,34 @@ export class TabBridgeClient {
|
|
|
66
73
|
timer.unref();
|
|
67
74
|
});
|
|
68
75
|
}
|
|
69
|
-
status(state, degraded = false) {
|
|
70
|
-
this.#latestStatus = { state, degraded };
|
|
76
|
+
status(state, degraded = false, pendingRequests = 0, queuedInput = 0) {
|
|
77
|
+
this.#latestStatus = { state, degraded, accent: this.#accent, pendingRequests, queuedInput };
|
|
71
78
|
const recoveryState = state === "unresponsive" || state === "stalled" || state === "crashed" || state === "restarting" || state === "failed";
|
|
72
79
|
if (recoveryState && !this.#supports("recovery-status"))
|
|
73
80
|
return;
|
|
74
81
|
this.#send("bridge.status", {
|
|
75
82
|
seq: ++this.#sequence,
|
|
76
83
|
state,
|
|
84
|
+
pendingRequests: Math.max(0, Math.trunc(pendingRequests)),
|
|
85
|
+
queuedInput: Math.max(0, Math.trunc(queuedInput)),
|
|
86
|
+
...(this.#accent === null ? {} : { accent: this.#accent }),
|
|
77
87
|
...(degraded && this.#supports("recovery-status") ? { degraded: true } : {}),
|
|
78
88
|
});
|
|
79
89
|
}
|
|
80
90
|
degraded(degraded) {
|
|
81
91
|
this.status(this.#latestStatus?.state ?? "idle", degraded);
|
|
82
92
|
}
|
|
83
|
-
|
|
84
|
-
this.#
|
|
85
|
-
this.#
|
|
93
|
+
accent(accent) {
|
|
94
|
+
this.#accent = accent;
|
|
95
|
+
if (this.#latestStatus !== null)
|
|
96
|
+
this.status(this.#latestStatus.state, this.#latestStatus.degraded, this.#latestStatus.pendingRequests, this.#latestStatus.queuedInput);
|
|
97
|
+
}
|
|
98
|
+
session(file, name, nameSource = "default") {
|
|
99
|
+
this.#latestSession = { file, name, nameSource };
|
|
100
|
+
this.#send("bridge.session", { file, name, nameSource });
|
|
101
|
+
}
|
|
102
|
+
prompt(interrupted) {
|
|
103
|
+
this.#send("bridge.prompt", { interrupted });
|
|
86
104
|
}
|
|
87
105
|
requestDetach(origin = this.captureAttachment(), timeoutMs = 1_000) {
|
|
88
106
|
return this.#requestAction("detach", origin, timeoutMs);
|
|
@@ -90,6 +108,51 @@ export class TabBridgeClient {
|
|
|
90
108
|
requestRestart(origin = this.captureAttachment(), timeoutMs = 1_000) {
|
|
91
109
|
return this.#requestAction("restart", origin, timeoutMs);
|
|
92
110
|
}
|
|
111
|
+
consumeControllerBoundaryInput(data) {
|
|
112
|
+
const prefix = "\x1bP+a1;controller=";
|
|
113
|
+
const suffix = "\x1b\\";
|
|
114
|
+
let pending = this.#boundaryInputRemainder + data;
|
|
115
|
+
this.#boundaryInputRemainder = "";
|
|
116
|
+
let output = "";
|
|
117
|
+
while (pending.length > 0) {
|
|
118
|
+
const start = pending.indexOf(prefix);
|
|
119
|
+
if (start === -1) {
|
|
120
|
+
let retained = 0;
|
|
121
|
+
const maximum = Math.min(prefix.length - 1, pending.length);
|
|
122
|
+
for (let length = maximum; length > 0; length--) {
|
|
123
|
+
if (pending.endsWith(prefix.slice(0, length))) {
|
|
124
|
+
retained = length;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const split = pending.length - retained;
|
|
129
|
+
output += pending.slice(0, split);
|
|
130
|
+
this.#boundaryInputRemainder = pending.slice(split);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
output += pending.slice(0, start);
|
|
134
|
+
const end = pending.indexOf(suffix, start + prefix.length);
|
|
135
|
+
if (end === -1) {
|
|
136
|
+
this.#boundaryInputRemainder = pending.slice(start);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
const body = pending.slice(start + prefix.length, end);
|
|
140
|
+
const separator = body.indexOf(";");
|
|
141
|
+
const generation = Number(body.slice(0, separator));
|
|
142
|
+
const nonceHex = body.slice(separator + 1);
|
|
143
|
+
if (separator > 0 && Number.isSafeInteger(generation) && generation > 0 && /^[0-9a-f]{32}$/u.test(nonceHex)) {
|
|
144
|
+
this.#send("bridge.boundary", {
|
|
145
|
+
generation,
|
|
146
|
+
nonce: [...Buffer.from(nonceHex, "hex")],
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
output += pending.slice(start, end + suffix.length);
|
|
151
|
+
}
|
|
152
|
+
pending = pending.slice(end + suffix.length);
|
|
153
|
+
}
|
|
154
|
+
return output;
|
|
155
|
+
}
|
|
93
156
|
close() {
|
|
94
157
|
this.#closed = true;
|
|
95
158
|
if (this.#retry !== null)
|
|
@@ -101,6 +164,10 @@ export class TabBridgeClient {
|
|
|
101
164
|
transport?.destroy();
|
|
102
165
|
this.#fail(false);
|
|
103
166
|
}
|
|
167
|
+
#emitCommand(command) {
|
|
168
|
+
for (const listener of this.#commandListeners)
|
|
169
|
+
listener(command);
|
|
170
|
+
}
|
|
104
171
|
#send(type, body, requestId) {
|
|
105
172
|
if (this.#transport === null || (!this.#connected && type !== "hello"))
|
|
106
173
|
return;
|
|
@@ -121,7 +188,7 @@ export class TabBridgeClient {
|
|
|
121
188
|
if (this.#supports("bridge-heartbeat"))
|
|
122
189
|
this.#startHeartbeat();
|
|
123
190
|
if (this.#latestStatus !== null)
|
|
124
|
-
this.status(this.#latestStatus.state, this.#latestStatus.degraded);
|
|
191
|
+
this.status(this.#latestStatus.state, this.#latestStatus.degraded, this.#latestStatus.pendingRequests, this.#latestStatus.queuedInput);
|
|
125
192
|
if (this.#latestSession !== null)
|
|
126
193
|
this.#send("bridge.session", this.#latestSession);
|
|
127
194
|
return;
|
|
@@ -130,6 +197,19 @@ export class TabBridgeClient {
|
|
|
130
197
|
this.#fail();
|
|
131
198
|
return;
|
|
132
199
|
}
|
|
200
|
+
if (message.type === "bridge.visibility" && typeof message.body["visible"] === "boolean") {
|
|
201
|
+
this.#emitCommand({ type: "visibility", visible: message.body["visible"] });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (message.type === "bridge.rename" && typeof message.body["name"] === "string") {
|
|
205
|
+
const source = message.body["nameSource"];
|
|
206
|
+
this.#emitCommand({ type: "rename", name: message.body["name"], nameSource: source === "auto" || source === "default" ? source : "user" });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (message.type === "bridge.stop" && typeof message.body["deadlineMs"] === "number") {
|
|
210
|
+
this.#emitCommand({ type: "stop", deadlineMs: message.body["deadlineMs"] });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
133
213
|
if (message.type === "attachment") {
|
|
134
214
|
const clientId = message.body["clientId"];
|
|
135
215
|
const revision = message.body["controllerRevision"];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { MaintenanceReport } from "./maintenance.js";
|
|
2
|
+
export declare const DOCTOR_MAX_BYTES: number;
|
|
3
|
+
export interface DoctorOptions {
|
|
4
|
+
/** The profile's resident state directory, as reported by the terminal host. */
|
|
5
|
+
readonly stateDir: string;
|
|
6
|
+
readonly hostStatus: MaintenanceReport;
|
|
7
|
+
readonly releaseRoots: readonly string[];
|
|
8
|
+
readonly settings: {
|
|
9
|
+
readonly tabsMax: number;
|
|
10
|
+
readonly tabsAutoName: boolean;
|
|
11
|
+
};
|
|
12
|
+
readonly home?: string;
|
|
13
|
+
readonly platform?: NodeJS.Platform;
|
|
14
|
+
readonly architecture?: string;
|
|
15
|
+
readonly now?: Date;
|
|
16
|
+
readonly maxBytes?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface DoctorBundle {
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly bytes: number;
|
|
21
|
+
readonly dropped: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Writes an owner-only diagnostic bundle by allowlist: structured logs, crash records, host
|
|
25
|
+
* status, a redacted registry, the owner marker without secrets, artifact verification, platform
|
|
26
|
+
* facts, and the Tabs settings. Secrets, tokens, environment values, prompt journals, drafts,
|
|
27
|
+
* last screens, the private child-stderr sink, and terminal content are never read.
|
|
28
|
+
*/
|
|
29
|
+
export declare function writeDoctorBundle(options: DoctorOptions): Promise<DoctorBundle>;
|