@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
package/bin/guardian.js
CHANGED
|
@@ -16,8 +16,13 @@ const profileId = readLaunchContext(process.env, "profile").launchProfile;
|
|
|
16
16
|
if (releaseRoot === undefined || profileId === undefined) throw new Error("A1 launch context is incomplete");
|
|
17
17
|
|
|
18
18
|
const sessionSelection = parseSessionSelection(process.argv.slice(2));
|
|
19
|
+
const rootCommand = launchContext.residentRootExecutable === undefined ? undefined : {
|
|
20
|
+
executable: launchContext.residentRootExecutable,
|
|
21
|
+
arguments: JSON.parse(launchContext.residentRootArguments ?? "[]"),
|
|
22
|
+
};
|
|
19
23
|
Promise.resolve().then(() => runLaunchGuardian({
|
|
20
24
|
...(sessionSelection === undefined ? {} : { sessionSelection }),
|
|
25
|
+
...(rootCommand === undefined ? {} : { rootCommand }),
|
|
21
26
|
profileId,
|
|
22
27
|
releaseRoot,
|
|
23
28
|
uiEntry: resolve(releaseRoot, "bin", "ui.js"),
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface TabLaunchCredentials {
|
|
2
|
+
readonly tabId: string;
|
|
3
|
+
readonly token: string;
|
|
4
|
+
readonly endpoint: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function formatResidentFallbackNotice(reason: string): string;
|
|
8
|
+
|
|
9
|
+
export function assignFreshSessionRuntimeId(
|
|
10
|
+
environment: Record<string, string | undefined>,
|
|
11
|
+
name: string,
|
|
12
|
+
createId: () => string,
|
|
13
|
+
): string;
|
|
14
|
+
|
|
15
|
+
export function consumeTabLaunchContext(
|
|
16
|
+
arguments_: readonly string[],
|
|
17
|
+
environment: Record<string, string | undefined>,
|
|
18
|
+
): { readonly uiArguments: string[]; readonly tabCredentials: TabLaunchCredentials | undefined };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const TAB_ENVIRONMENT_NAMES = ["A1_TAB_ID", "A1_TAB_TOKEN", "A1_TAB_ENDPOINT"];
|
|
2
|
+
|
|
3
|
+
/** @param {string} reason */
|
|
4
|
+
export function formatResidentFallbackNotice(reason) {
|
|
5
|
+
return `Resident tabs unavailable (${reason}); running a single agent.`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {Record<string, string | undefined>} environment
|
|
10
|
+
* @param {string} name
|
|
11
|
+
* @param {() => string} createId
|
|
12
|
+
*/
|
|
13
|
+
export function assignFreshSessionRuntimeId(environment, name, createId) {
|
|
14
|
+
const id = createId();
|
|
15
|
+
if (typeof id !== "string" || id.length === 0 || id.includes("\0")) throw new Error("Session runtime identity generation failed");
|
|
16
|
+
environment[name] = id;
|
|
17
|
+
return id;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Security: consume bridge credentials into caller-private memory and erase their inheritable
|
|
22
|
+
* environment entries before any extension-capable module loads.
|
|
23
|
+
* @param {readonly string[]} arguments_
|
|
24
|
+
* @param {Record<string, string | undefined>} environment
|
|
25
|
+
*/
|
|
26
|
+
export function consumeTabLaunchContext(arguments_, environment) {
|
|
27
|
+
const uiArguments = [...arguments_];
|
|
28
|
+
const tabArgument = uiArguments.indexOf("--tab");
|
|
29
|
+
const tabMode = tabArgument !== -1;
|
|
30
|
+
if (tabMode) uiArguments.splice(tabArgument, 1);
|
|
31
|
+
const [tabId, token, endpoint] = TAB_ENVIRONMENT_NAMES.map(name => environment[name]);
|
|
32
|
+
for (const name of TAB_ENVIRONMENT_NAMES) delete environment[name];
|
|
33
|
+
if (!tabMode) return { uiArguments, tabCredentials: undefined };
|
|
34
|
+
if (!tabId || !token || !endpoint) throw new Error("A1 tab mode requires bridge credentials");
|
|
35
|
+
return { uiArguments, tabCredentials: { tabId, token, endpoint } };
|
|
36
|
+
}
|
package/bin/ui.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// Security: this helper has no A1, Pi, tool, or extension dependency and erases credentials before those modules load.
|
|
4
|
+
const { assignFreshSessionRuntimeId, consumeTabLaunchContext, formatResidentFallbackNotice } = await import("./tab-launch-context.js");
|
|
5
|
+
const { uiArguments, tabCredentials } = consumeTabLaunchContext(process.argv.slice(2), process.env);
|
|
6
|
+
|
|
3
7
|
// Invariant: one terminal module identity per process, decided before any A1 or Pi module loads.
|
|
4
8
|
const { installPinnedPiTuiResolver } = await import("./module-resolver.js");
|
|
5
9
|
installPinnedPiTuiResolver(new URL("..", import.meta.url).pathname.replace(/^\/(.:)/, "$1").replace(/\/$/, ""));
|
|
@@ -14,7 +18,7 @@ if (profile === "a1") {
|
|
|
14
18
|
import("node:crypto"),
|
|
15
19
|
import("../dist/product-identity.js"),
|
|
16
20
|
]);
|
|
17
|
-
process.env
|
|
21
|
+
assignFreshSessionRuntimeId(process.env, PRODUCT_IDENTITY.environment.sessionRuntimeId, randomUUID);
|
|
18
22
|
}
|
|
19
23
|
const { installFatalExit } = await import("../dist/foundation/terminal-cleanup/fatal-exit.js");
|
|
20
24
|
const { resolveProductPaths } = await import("../dist/foundation/lifecycle/paths.js");
|
|
@@ -51,8 +55,11 @@ const [
|
|
|
51
55
|
identity.assertSinglePiTuiModuleAtLaunch(packageRootPath, (/** @type {string} */ message) => process.stderr.write(message));
|
|
52
56
|
await startup.markStartupPhase(process.env, "ui-modules-loaded");
|
|
53
57
|
|
|
54
|
-
const sessionSelection = parseSessionSelection(
|
|
55
|
-
Promise.resolve().then(() => {
|
|
58
|
+
const sessionSelection = parseSessionSelection(uiArguments);
|
|
59
|
+
Promise.resolve().then(async () => {
|
|
60
|
+
const tab = tabCredentials === undefined ? undefined : {
|
|
61
|
+
bridge: new (await import("../dist/foundation/resident-tabs/bridge-client.js")).TabBridgeClient({ credentials: tabCredentials }),
|
|
62
|
+
};
|
|
56
63
|
if (sessionSelection && profile !== "a1") throw new Error("session selection requires the normal A1 profile");
|
|
57
64
|
return runSelectedInteractiveRuntime(profile, {
|
|
58
65
|
ownedUi: async (profileId, ownedSurfaces) => {
|
|
@@ -69,6 +76,10 @@ Promise.resolve().then(() => {
|
|
|
69
76
|
projectTrustPrompt: createConsoleProjectTrustPrompt({ presentation: profile === "a1" ? "bare" : "comparison" }),
|
|
70
77
|
sessionForkPrompt: createConsoleSessionForkPrompt(),
|
|
71
78
|
...(sessionSelection === undefined ? {} : { sessionSelection }),
|
|
79
|
+
...(tab === undefined ? {} : { tab }),
|
|
80
|
+
...(launchContext.residentFallback === undefined ? {} : {
|
|
81
|
+
residentFallbackNotice: formatResidentFallbackNotice(launchContext.residentFallback),
|
|
82
|
+
}),
|
|
72
83
|
});
|
|
73
84
|
runningApplication = application;
|
|
74
85
|
return await runOwnedUi({ application, ...(settings === null ? {} : { settings }) });
|
|
@@ -12,16 +12,14 @@ export interface HelperPoolOptions {
|
|
|
12
12
|
readonly stop: (child: ChildProcess) => void;
|
|
13
13
|
readonly idleMs?: number;
|
|
14
14
|
}
|
|
15
|
-
/**
|
|
16
|
-
* Keeps one forked IPC helper ready so a request takes a live child instead of paying fork and module
|
|
17
|
-
* load on its own critical path. The spare stays idle on its channel until it is taken; a spare that
|
|
18
|
-
* exits or idles too long is dropped, and the next request forks cold and warms a replacement afterwards.
|
|
19
|
-
*/
|
|
15
|
+
/** Holds one idle IPC helper; requests take it, and exits, expiry, or use trigger replacement. */
|
|
20
16
|
export declare class HelperPool {
|
|
21
17
|
#private;
|
|
22
18
|
constructor(options: HelperPoolOptions);
|
|
23
19
|
/** Whether a spare is currently held (ready or still starting). */
|
|
24
20
|
get warmed(): boolean;
|
|
21
|
+
/** Whether the held spare has announced that module loading is complete. */
|
|
22
|
+
get ready(): boolean;
|
|
25
23
|
/** Opts the pool in and forks the first spare; from here on `replenish` keeps one ready after each request. */
|
|
26
24
|
warm(): void;
|
|
27
25
|
/** Forks the next spare after a request released its child; a no-op until the owner warmed the pool. A fork failure leaves the pool empty. */
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
/** How long an unused spare helper may idle before it is stopped so a background session holds no extra process. */
|
|
2
2
|
export const HELPER_SPARE_IDLE_MS = 5 * 60_000;
|
|
3
|
-
/**
|
|
4
|
-
* Keeps one forked IPC helper ready so a request takes a live child instead of paying fork and module
|
|
5
|
-
* load on its own critical path. The spare stays idle on its channel until it is taken; a spare that
|
|
6
|
-
* exits or idles too long is dropped, and the next request forks cold and warms a replacement afterwards.
|
|
7
|
-
*/
|
|
3
|
+
/** Holds one idle IPC helper; requests take it, and exits, expiry, or use trigger replacement. */
|
|
8
4
|
export class HelperPool {
|
|
9
5
|
#fork;
|
|
10
6
|
#stop;
|
|
@@ -19,6 +15,8 @@ export class HelperPool {
|
|
|
19
15
|
}
|
|
20
16
|
/** Whether a spare is currently held (ready or still starting). */
|
|
21
17
|
get warmed() { return this.#spare !== undefined; }
|
|
18
|
+
/** Whether the held spare has announced that module loading is complete. */
|
|
19
|
+
get ready() { return this.#spare?.ready === true; }
|
|
22
20
|
/** Opts the pool in and forks the first spare; from here on `replenish` keeps one ready after each request. */
|
|
23
21
|
warm() {
|
|
24
22
|
if (this.#disposed)
|
|
@@ -43,6 +43,11 @@ export interface OwnedUiShellEngineOptions {
|
|
|
43
43
|
};
|
|
44
44
|
/** Bare A1 selects the owned bounded viewport; comparison profiles stay pinned. */
|
|
45
45
|
readonly sessionLayout?: "pinned" | "custom-viewport";
|
|
46
|
+
/** Resident tab quit routes detach the outer client without stopping this engine session. */
|
|
47
|
+
readonly residentTab?: {
|
|
48
|
+
requestDetach(): Promise<boolean>;
|
|
49
|
+
readonly unavailableNotice?: string;
|
|
50
|
+
};
|
|
46
51
|
}
|
|
47
52
|
/** Terminal, startup, viewport, and presentation-cadence seams; production leaves most at their defaults. */
|
|
48
53
|
export interface OwnedUiShellPresentationOptions {
|
|
@@ -182,7 +187,7 @@ export declare class OwnedUiSessionShellRoot implements PiTuiComponentPort {
|
|
|
182
187
|
readonly onPromptSuggestionAccepted?: (text: string) => void;
|
|
183
188
|
readonly onInputSurfaceChanged?: () => void;
|
|
184
189
|
readonly onPromptSuggestionEligibilityChanged?: () => void;
|
|
185
|
-
readonly onCopyText?: (text: string) => void;
|
|
190
|
+
readonly onCopyText?: (text: string, cut: boolean) => void;
|
|
186
191
|
readonly readClipboardContent?: (signal?: AbortSignal) => Promise<PiShellClipboardContent | null>;
|
|
187
192
|
readonly captureClipboardPaste?: () => PasteSource;
|
|
188
193
|
readonly pasteDiagnostics?: (event: PasteEvent) => void;
|
|
@@ -458,7 +458,7 @@ export class OwnedUiSessionShellRoot {
|
|
|
458
458
|
&& snapshot.inputSurface === inputSurface
|
|
459
459
|
&& snapshot.viewportRevision === viewportRevision
|
|
460
460
|
&& snapshot.selectionRevision === selectionRevision
|
|
461
|
-
? this.#viewportController.composeDockOnly(dockRows, width, height)
|
|
461
|
+
? this.#viewportController.composeDockOnly(dockRows, width, height, dock.footerRows)
|
|
462
462
|
: null;
|
|
463
463
|
if (frame === null) {
|
|
464
464
|
frame = this.#viewportController.compose({
|
|
@@ -474,6 +474,7 @@ export class OwnedUiSessionShellRoot {
|
|
|
474
474
|
...(document.liveTailStartRow === undefined ? {} : { liveTailStartRow: document.liveTailStartRow }),
|
|
475
475
|
bottomAlignedTailRowCount: steeringRows.length + statusRows.length,
|
|
476
476
|
dockRows,
|
|
477
|
+
explicitCopyDockRows: dock.footerRows,
|
|
477
478
|
promptAnchors: document.promptAnchors,
|
|
478
479
|
width,
|
|
479
480
|
height,
|
|
@@ -603,7 +604,6 @@ export class OwnedUiSessionShellRoot {
|
|
|
603
604
|
const transientRows = [...queued, ...statusRows, ...this.#renderDockNotice(width)];
|
|
604
605
|
const aboveWidgets = this.#renderWidgets("aboveEditor", width);
|
|
605
606
|
const input = this.#inputSurface.render(width);
|
|
606
|
-
// Invariant: pointer rows describe the body, not the autocomplete block now preceding it.
|
|
607
607
|
const body = this.usesDefaultInputSurface() ? this.editor.bodyGeometry?.() : undefined;
|
|
608
608
|
const belowWidgets = this.#renderWidgets("belowEditor", width);
|
|
609
609
|
const footer = this.#renderFooter(width);
|
|
@@ -614,6 +614,7 @@ export class OwnedUiSessionShellRoot {
|
|
|
614
614
|
inputOffset,
|
|
615
615
|
editorOffset: inputOffset + (body?.rowOffset ?? 0),
|
|
616
616
|
inputRows: body?.rowCount ?? input.length,
|
|
617
|
+
footerRows: footer.length,
|
|
617
618
|
};
|
|
618
619
|
}
|
|
619
620
|
#renderCopyAcknowledgement(width) {
|
|
@@ -3,7 +3,7 @@ import { createResponseCopyExecutor, hasAsyncClipboardOutput, responseCopyDestin
|
|
|
3
3
|
import { MAX_COPY_CONTROL_BYTES } from "./response-copy-protocol.js";
|
|
4
4
|
import { PromptHistoryController } from "./prompt-history-controller.js";
|
|
5
5
|
import { PRODUCT_TEXT } from "../../product-identity.js";
|
|
6
|
-
import { boundedCleanup } from "../../foundation/terminal-cleanup/terminal-reset.js";
|
|
6
|
+
import { CLEAR_EXIT_INTERVAL_MS, boundedCleanup } from "../../foundation/terminal-cleanup/terminal-reset.js";
|
|
7
7
|
import { assertOwnedUiCommand, OWNED_UI_HIDDEN_COMMAND_NAMES } from "../../contracts/owned-ui/index.js";
|
|
8
8
|
import { assertPromptImages, ImageAttachmentError } from "../../contracts/owned-ui/index.js";
|
|
9
9
|
import { ContextualPromptSuggestionController } from "./prompt-suggestion-controller.js";
|
|
@@ -34,6 +34,7 @@ export class OwnedUiSessionPresenter {
|
|
|
34
34
|
#host;
|
|
35
35
|
#terminal;
|
|
36
36
|
#cwd;
|
|
37
|
+
#residentTabOptions;
|
|
37
38
|
#listeners = new Set();
|
|
38
39
|
#unsubscribe;
|
|
39
40
|
#unsubscribePromptSuggestions;
|
|
@@ -104,6 +105,7 @@ export class OwnedUiSessionPresenter {
|
|
|
104
105
|
this.#sessionBindingGeneration = this.backend.identity.sessionBindingGeneration;
|
|
105
106
|
this.#suggestionModelKey = modelKey(this.backend.session.view());
|
|
106
107
|
this.#cwd = cwd;
|
|
108
|
+
this.#residentTabOptions = options.engine.residentTab;
|
|
107
109
|
this.#routeHost = routeHost ?? null;
|
|
108
110
|
this.#startupRoute = options.engine.startupRoute;
|
|
109
111
|
this.#customViewport = sessionLayout === "custom-viewport";
|
|
@@ -218,9 +220,9 @@ export class OwnedUiSessionPresenter {
|
|
|
218
220
|
promptSuggestionController?.invalidate();
|
|
219
221
|
this.#promptHistory?.synchronize();
|
|
220
222
|
},
|
|
221
|
-
onCopyText: text => {
|
|
223
|
+
onCopyText: (text, cut) => {
|
|
222
224
|
if (this.#responseCopy !== null) {
|
|
223
|
-
|
|
225
|
+
this.#trackCopy(this.#responseCopy.submitText(text), cut ? undefined : text.length);
|
|
224
226
|
return;
|
|
225
227
|
}
|
|
226
228
|
const write = () => {
|
|
@@ -626,21 +628,21 @@ export class OwnedUiSessionPresenter {
|
|
|
626
628
|
const routed = this.root.handleViewportPreInput(data, true, Date.now(), this.root.usesDefaultInputSurface() && !this.#terminal.hasFocusedOverlay());
|
|
627
629
|
if (routed.copySelection !== undefined) {
|
|
628
630
|
const snapshot = routed.copySelection;
|
|
629
|
-
|
|
630
|
-
const intent = ++this.#copyIntentSequence;
|
|
631
|
-
void this.#responseCopy?.submit(snapshot, this.#pendingClipboardWrite).then(result => {
|
|
632
|
-
if (this.#disposed || !this.#terminal.running || intent !== this.#copyIntentSequence
|
|
633
|
-
|| result.outcome !== "delivered" && result.outcome !== "submitted-unverified")
|
|
634
|
-
return;
|
|
635
|
-
if (acknowledgement.hasNonWhitespace) {
|
|
636
|
-
this.root.showCopyAcknowledgement(`copied ${acknowledgement.characters} chars to clipboard`);
|
|
637
|
-
}
|
|
638
|
-
});
|
|
631
|
+
this.#trackCopy(this.#responseCopy?.submit(snapshot, this.#pendingClipboardWrite), copyCharacters(snapshot));
|
|
639
632
|
}
|
|
640
633
|
if (!routed.consumed)
|
|
641
634
|
return routed.data === data ? undefined : { data: routed.data };
|
|
642
635
|
return routed.data.length === 0 ? { consume: true } : { data: routed.data };
|
|
643
636
|
}
|
|
637
|
+
#trackCopy(delivery, count) {
|
|
638
|
+
const intent = ++this.#copyIntentSequence;
|
|
639
|
+
void delivery?.then(({ outcome }) => {
|
|
640
|
+
if (count === undefined || this.#disposed || !this.#terminal.running || intent !== this.#copyIntentSequence
|
|
641
|
+
|| outcome !== "delivered" && outcome !== "submitted-unverified")
|
|
642
|
+
return;
|
|
643
|
+
this.root.showCopyAcknowledgement(`copied ${count} chars to clipboard`);
|
|
644
|
+
});
|
|
645
|
+
}
|
|
644
646
|
#promptSuggestionPresentationBlockReason(identity) {
|
|
645
647
|
const view = this.view();
|
|
646
648
|
if (this.#disposed)
|
|
@@ -795,7 +797,7 @@ export class OwnedUiSessionPresenter {
|
|
|
795
797
|
}
|
|
796
798
|
async clearOrExit(now = Date.now()) {
|
|
797
799
|
this.#promptSuggestions?.abortPending();
|
|
798
|
-
if (now - this.#lastClearTime <
|
|
800
|
+
if (now - this.#lastClearTime < CLEAR_EXIT_INTERVAL_MS)
|
|
799
801
|
return this.shutdown();
|
|
800
802
|
this.root.editor.setText("");
|
|
801
803
|
this.#lastClearTime = now;
|
|
@@ -1149,10 +1151,23 @@ export class OwnedUiSessionPresenter {
|
|
|
1149
1151
|
this.#presenters.openSettingsSelector(this.#dialogHost);
|
|
1150
1152
|
}
|
|
1151
1153
|
shutdown() {
|
|
1152
|
-
this.#shutdownPromise ??= this.#shutdown()
|
|
1154
|
+
this.#shutdownPromise ??= this.#shutdown().finally(() => {
|
|
1155
|
+
// Invariant: a rejected resident detach is retryable; ordinary shutdown has disposed the shell already.
|
|
1156
|
+
if (!this.#disposed)
|
|
1157
|
+
this.#shutdownPromise = undefined;
|
|
1158
|
+
});
|
|
1153
1159
|
return this.#shutdownPromise;
|
|
1154
1160
|
}
|
|
1155
1161
|
async #shutdown() {
|
|
1162
|
+
const residentTab = this.#residentTab();
|
|
1163
|
+
if (residentTab !== undefined) {
|
|
1164
|
+
const detached = await residentTab.requestDetach().catch(() => false);
|
|
1165
|
+
if (detached)
|
|
1166
|
+
return { outcome: "completed", diagnostic: null };
|
|
1167
|
+
this.root.appendWorkflowStatus(residentTab.unavailableNotice ?? "Press Ctrl+C twice to leave; this agent keeps running.");
|
|
1168
|
+
this.#terminal.requestRender();
|
|
1169
|
+
return rejected("resident tab detach is unavailable");
|
|
1170
|
+
}
|
|
1156
1171
|
try {
|
|
1157
1172
|
const quit = this.backend.workflows.executeWorkflow({ command: "quit", argument: "" });
|
|
1158
1173
|
// Invariant: a slow or hung engine quit never holds the terminal past the cleanup deadline. The
|
|
@@ -1168,6 +1183,9 @@ export class OwnedUiSessionPresenter {
|
|
|
1168
1183
|
this.#resolveStoppedLifecycle();
|
|
1169
1184
|
}
|
|
1170
1185
|
}
|
|
1186
|
+
#residentTab() {
|
|
1187
|
+
return this.#residentTabOptions;
|
|
1188
|
+
}
|
|
1171
1189
|
async runWorkflow(request) {
|
|
1172
1190
|
if (request.command === "quit")
|
|
1173
1191
|
return this.shutdown();
|
|
@@ -1920,16 +1938,15 @@ export class OwnedUiSessionShell extends OwnedUiSessionPresenter {
|
|
|
1920
1938
|
function isCoalescedStreamBlock(kind) {
|
|
1921
1939
|
return kind === "assistant" || kind === "thinking" || kind === "tool-call" || kind === "tool-result";
|
|
1922
1940
|
}
|
|
1923
|
-
function
|
|
1941
|
+
function copyCharacters(snapshot) {
|
|
1924
1942
|
if (snapshot.literal !== true)
|
|
1925
|
-
return
|
|
1926
|
-
let
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
return { characters, hasNonWhitespace };
|
|
1943
|
+
return snapshot.sourceUnits;
|
|
1944
|
+
let count = Math.max(0, snapshot.rows.length - 1), hasContent = false;
|
|
1945
|
+
for (const { text } of snapshot.rows) {
|
|
1946
|
+
count += text.length;
|
|
1947
|
+
hasContent ||= /\S/u.test(text);
|
|
1948
|
+
}
|
|
1949
|
+
return hasContent ? count : undefined;
|
|
1933
1950
|
}
|
|
1934
1951
|
export function formatSessionResumeCommand(metadata) {
|
|
1935
1952
|
const args = [PRODUCT_TEXT.commandName];
|
|
@@ -64,7 +64,7 @@ export declare class SessionViewportController {
|
|
|
64
64
|
setOverlaySurfaces(surfaces: readonly PiTuiPointerSurface[] | null): void;
|
|
65
65
|
get inputGeometryReady(): boolean;
|
|
66
66
|
compose(input: Omit<TranscriptViewportFrameInput, "pointerPosition">): TranscriptViewportFrame;
|
|
67
|
-
composeDockOnly(dockRows: readonly string[], width: number, height: number): TranscriptViewportFrame | null;
|
|
67
|
+
composeDockOnly(dockRows: readonly string[], width: number, height: number, explicitCopyDockRows?: number): TranscriptViewportFrame | null;
|
|
68
68
|
/** Returns true when document wrapping may have changed. */
|
|
69
69
|
setConfig(config: OwnedUiViewportSettings): boolean;
|
|
70
70
|
resumeFollowing(): void;
|
|
@@ -165,8 +165,8 @@ export class SessionViewportController {
|
|
|
165
165
|
}
|
|
166
166
|
return frame;
|
|
167
167
|
}
|
|
168
|
-
composeDockOnly(dockRows, width, height) {
|
|
169
|
-
return this.#viewport.composeDockOnly(dockRows, width, height);
|
|
168
|
+
composeDockOnly(dockRows, width, height, explicitCopyDockRows = 0) {
|
|
169
|
+
return this.#viewport.composeDockOnly(dockRows, width, height, explicitCopyDockRows);
|
|
170
170
|
}
|
|
171
171
|
/** Returns true when document wrapping may have changed. */
|
|
172
172
|
setConfig(config) {
|
|
@@ -307,7 +307,7 @@ export class SessionViewportController {
|
|
|
307
307
|
this.#requestRender();
|
|
308
308
|
}
|
|
309
309
|
else if (this.#viewport.hasSelection) {
|
|
310
|
-
const copySelection = this.#viewport.captureSelectedText();
|
|
310
|
+
const copySelection = this.#viewport.captureSelectedText("explicit");
|
|
311
311
|
this.#viewport.clearSelection();
|
|
312
312
|
this.#stopSelectionAutoScroll();
|
|
313
313
|
this.#requestRender();
|
|
@@ -10,6 +10,7 @@ import type { OwnedUiApplicationPort, PresentationTerminalPort } from "../contra
|
|
|
10
10
|
import type { AgentSettingsPort } from "../contracts/agent-engine/index.js";
|
|
11
11
|
import type { OwnedUiPromptSuggestionGeneratorPort, UiAccentColor } from "../contracts/owned-ui/index.js";
|
|
12
12
|
import type { ReleaseNoteCatalog } from "../features/owned-ui/release-notes.js";
|
|
13
|
+
import type { TabBridgeClient } from "../foundation/resident-tabs/bridge-client.js";
|
|
13
14
|
/**
|
|
14
15
|
* The session backend composition wires: the owned-UI port, the prompt-suggestion generator, and
|
|
15
16
|
* the agent settings port. `settingsPort` stays outside the owned-UI contract because its type
|
|
@@ -55,11 +56,27 @@ export interface OwnedUiCompositionOptions {
|
|
|
55
56
|
readonly checkForNewerRelease?: (options: StartupReleaseCheckOptions) => Promise<AvailableRelease | null>;
|
|
56
57
|
/** Exact native process inspection injected by the shipped entry; absent SDK seams do not publish claims. */
|
|
57
58
|
readonly inspectProcess?: (pid: number) => Promise<NativeProcessIdentity | null>;
|
|
59
|
+
/** Private credentials were consumed by the entry before composition or extensions loaded. */
|
|
60
|
+
readonly tab?: {
|
|
61
|
+
readonly bridge: TabBridgeClient;
|
|
62
|
+
};
|
|
63
|
+
/** One prompt-adjacent notice explaining why an opted-in resident launch fell back. */
|
|
64
|
+
readonly residentFallbackNotice?: string;
|
|
58
65
|
}
|
|
59
66
|
export interface OwnedUiComposition {
|
|
60
67
|
readonly application: OwnedUiApplicationPort;
|
|
61
68
|
/** Present when a profile was supplied, so the caller can resolve settings before start. */
|
|
62
69
|
readonly settings: OwnedSettingsManager | null;
|
|
63
70
|
}
|
|
71
|
+
export declare function residentStartupPresentation(resident: boolean, fallbackNotice?: string): {
|
|
72
|
+
readonly startup?: {
|
|
73
|
+
readonly quiet?: boolean;
|
|
74
|
+
readonly notices?: readonly [{
|
|
75
|
+
readonly kind: "warning";
|
|
76
|
+
readonly message: string;
|
|
77
|
+
}];
|
|
78
|
+
};
|
|
79
|
+
};
|
|
64
80
|
export declare function composeOwnedUiApplication(options?: OwnedUiCompositionOptions): Promise<OwnedUiApplicationPort>;
|
|
65
81
|
export declare function composeOwnedUi(options?: OwnedUiCompositionOptions): Promise<OwnedUiComposition>;
|
|
82
|
+
export declare function bindResidentTabBridge(tabBridge: TabBridgeClient, backend: Pick<ComposedSessionBackend, "identity" | "session">): Promise<() => void>;
|
|
@@ -19,6 +19,11 @@ import { createOwnedRouteHost } from "./settings-route-host.js";
|
|
|
19
19
|
import { renderPiShellChangelogLines } from "../integrations/pi/components/shell-presenters-info.js";
|
|
20
20
|
import { nativeHyperlinkStyle } from "../ui/components/spans.js";
|
|
21
21
|
const SESSION_RUNTIME_REFRESH_MS = 30_000;
|
|
22
|
+
export function residentStartupPresentation(resident, fallbackNotice) {
|
|
23
|
+
if (resident)
|
|
24
|
+
return { startup: { quiet: true } };
|
|
25
|
+
return fallbackNotice === undefined ? {} : { startup: { notices: [{ kind: "warning", message: fallbackNotice }] } };
|
|
26
|
+
}
|
|
22
27
|
export async function composeOwnedUiApplication(options = {}) {
|
|
23
28
|
return (await composeOwnedUi(options)).application;
|
|
24
29
|
}
|
|
@@ -120,6 +125,8 @@ export async function composeOwnedUi(options = {}) {
|
|
|
120
125
|
await host.dispose();
|
|
121
126
|
throw error;
|
|
122
127
|
}
|
|
128
|
+
const tabBridge = options.tab?.bridge ?? null;
|
|
129
|
+
const unsubscribeTabBridge = tabBridge === null ? () => { } : await bindResidentTabBridge(tabBridge, backend);
|
|
123
130
|
let releaseNotes = null;
|
|
124
131
|
let releaseNotesFailure;
|
|
125
132
|
if (ownedSurfaces) {
|
|
@@ -228,13 +235,15 @@ export async function composeOwnedUi(options = {}) {
|
|
|
228
235
|
},
|
|
229
236
|
}),
|
|
230
237
|
...(ownedSurfaces ? { sessionLayout: "custom-viewport" } : {}),
|
|
238
|
+
...(tabBridge === null ? {} : { residentTab: { requestDetach: () => tabBridge.requestDetach() } }),
|
|
231
239
|
},
|
|
232
240
|
presentation: {
|
|
233
241
|
...(options.terminal === undefined ? {} : { terminal: createPiTerminalBridge(options.terminal) }),
|
|
242
|
+
...residentStartupPresentation(tabBridge !== null, options.residentFallbackNotice),
|
|
234
243
|
...(clipboardDiagnostics === null ? {} : { input: { onEvent: event => clipboardDiagnostics.runtime(event) } }),
|
|
235
244
|
...(viewportSettings === null ? {} : { viewportSettings }),
|
|
236
245
|
...(backgroundSettings === null ? {} : { backgroundSettings }),
|
|
237
|
-
...(settings === null || !ownedSurfaces ? {} : {
|
|
246
|
+
...(settings === null || !ownedSurfaces || tabBridge !== null ? {} : {
|
|
238
247
|
quitOutro: { snapshot: () => quitOutroSettingsSnapshot(settings), interactive: process.stdout.isTTY === true },
|
|
239
248
|
}),
|
|
240
249
|
},
|
|
@@ -323,6 +332,8 @@ export async function composeOwnedUi(options = {}) {
|
|
|
323
332
|
await releaseNoteClaim?.release();
|
|
324
333
|
suggestionDiagnostics?.dispose();
|
|
325
334
|
clipboardDiagnostics?.dispose();
|
|
335
|
+
unsubscribeTabBridge();
|
|
336
|
+
tabBridge?.close();
|
|
326
337
|
// Invariant: the host outlives the application; disposing it cancels host-level probes last.
|
|
327
338
|
await host.dispose();
|
|
328
339
|
}
|
|
@@ -330,6 +341,22 @@ export async function composeOwnedUi(options = {}) {
|
|
|
330
341
|
};
|
|
331
342
|
return { application, settings };
|
|
332
343
|
}
|
|
344
|
+
export async function bindResidentTabBridge(tabBridge, backend) {
|
|
345
|
+
await tabBridge.start().catch(() => false);
|
|
346
|
+
tabBridge.status("starting");
|
|
347
|
+
const announceSession = () => {
|
|
348
|
+
const resume = backend.identity.currentSessionResumeMetadata();
|
|
349
|
+
tabBridge.session(resume === null ? null : `${resume.sessionDir}/${resume.sessionId}`, backend.session.view().status.footer?.sessionName ?? null);
|
|
350
|
+
};
|
|
351
|
+
announceSession();
|
|
352
|
+
return backend.session.onEvent(event => {
|
|
353
|
+
if (event.type === "session-lifecycle") {
|
|
354
|
+
tabBridge.status(event.lifecycle === "busy" ? "working" : event.lifecycle === "starting" ? "starting" : "idle");
|
|
355
|
+
}
|
|
356
|
+
if (event.type === "session-view")
|
|
357
|
+
announceSession();
|
|
358
|
+
});
|
|
359
|
+
}
|
|
333
360
|
async function readPackageVersion() {
|
|
334
361
|
const manifest = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8"));
|
|
335
362
|
const version = manifest?.version;
|
|
@@ -7,6 +7,9 @@ export declare const PRIVATE_ENVIRONMENT: Readonly<{
|
|
|
7
7
|
releaseLayers: "LAUNCH_CONTEXT_RELEASE_LAYERS";
|
|
8
8
|
launchProfile: "LAUNCH_CONTEXT_PROFILE";
|
|
9
9
|
immutableWarmup: "LAUNCH_CONTEXT_WARMUP";
|
|
10
|
+
residentRootExecutable: "LAUNCH_CONTEXT_RESIDENT_ROOT_EXECUTABLE";
|
|
11
|
+
residentRootArguments: "LAUNCH_CONTEXT_RESIDENT_ROOT_ARGUMENTS";
|
|
12
|
+
residentFallback: "LAUNCH_CONTEXT_RESIDENT_FALLBACK";
|
|
10
13
|
}>;
|
|
11
14
|
/**
|
|
12
15
|
* Keys published by releases that predate the neutral cutover. They are read when
|
|
@@ -31,6 +34,9 @@ export interface LaunchContext {
|
|
|
31
34
|
readonly releaseLayers?: string;
|
|
32
35
|
readonly launchProfile?: "a1" | "pi";
|
|
33
36
|
readonly immutableWarmup?: "1";
|
|
37
|
+
readonly residentRootExecutable?: string;
|
|
38
|
+
readonly residentRootArguments?: string;
|
|
39
|
+
readonly residentFallback?: string;
|
|
34
40
|
}
|
|
35
41
|
export type LaunchContextRequirement = "optional" | "profile" | "release" | "warmup";
|
|
36
42
|
/** Read current keys, fall back to superseded keys per field, with bounded diagnostics. */
|
|
@@ -7,6 +7,9 @@ export const PRIVATE_ENVIRONMENT = Object.freeze({
|
|
|
7
7
|
releaseLayers: "LAUNCH_CONTEXT_RELEASE_LAYERS",
|
|
8
8
|
launchProfile: "LAUNCH_CONTEXT_PROFILE",
|
|
9
9
|
immutableWarmup: "LAUNCH_CONTEXT_WARMUP",
|
|
10
|
+
residentRootExecutable: "LAUNCH_CONTEXT_RESIDENT_ROOT_EXECUTABLE",
|
|
11
|
+
residentRootArguments: "LAUNCH_CONTEXT_RESIDENT_ROOT_ARGUMENTS",
|
|
12
|
+
residentFallback: "LAUNCH_CONTEXT_RESIDENT_FALLBACK",
|
|
10
13
|
});
|
|
11
14
|
/**
|
|
12
15
|
* Keys published by releases that predate the neutral cutover. They are read when
|
|
@@ -63,6 +66,18 @@ export function readLaunchContext(environment, requirement = "optional", platfor
|
|
|
63
66
|
}
|
|
64
67
|
if (values.immutableWarmup !== undefined && values.immutableWarmup !== "1")
|
|
65
68
|
throw invalidContext("immutableWarmup", "invalid permission flag");
|
|
69
|
+
if ((values.residentRootExecutable === undefined) !== (values.residentRootArguments === undefined))
|
|
70
|
+
throw invalidContext("residentRootExecutable", "resident root command is incomplete");
|
|
71
|
+
if (values.residentRootArguments !== undefined) {
|
|
72
|
+
try {
|
|
73
|
+
const argumentsValue = JSON.parse(values.residentRootArguments);
|
|
74
|
+
if (!Array.isArray(argumentsValue) || argumentsValue.some(value => typeof value !== "string"))
|
|
75
|
+
throw new Error("not string arguments");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw invalidContext("residentRootArguments", "invalid JSON argument list");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
66
81
|
if (values.releaseDigest !== undefined && !/^[a-f0-9]{64}$/.test(values.releaseDigest))
|
|
67
82
|
throw invalidContext("releaseDigest", "invalid digest");
|
|
68
83
|
if (requirement === "profile")
|
|
@@ -16,11 +16,16 @@ interface GuardianControl {
|
|
|
16
16
|
off(event: "disconnect", listener: () => void): unknown;
|
|
17
17
|
close(): void;
|
|
18
18
|
}
|
|
19
|
+
export interface LaunchGuardianRootCommand {
|
|
20
|
+
readonly executable: string;
|
|
21
|
+
readonly arguments: readonly string[];
|
|
22
|
+
}
|
|
19
23
|
export interface LaunchGuardianOptions {
|
|
20
24
|
readonly profileId: LaunchProfileId;
|
|
21
25
|
readonly releaseRoot: string;
|
|
22
26
|
readonly uiEntry: string;
|
|
23
27
|
readonly sessionSelection?: SessionSelection;
|
|
28
|
+
readonly rootCommand?: LaunchGuardianRootCommand;
|
|
24
29
|
readonly environment?: NodeJS.ProcessEnv;
|
|
25
30
|
readonly cwd?: string;
|
|
26
31
|
readonly helperPath?: string;
|
|
@@ -44,7 +44,8 @@ export async function runLaunchGuardian(options) {
|
|
|
44
44
|
});
|
|
45
45
|
let handle;
|
|
46
46
|
try {
|
|
47
|
-
|
|
47
|
+
const rootCommand = options.rootCommand ?? { executable: process.execPath, arguments: [options.uiEntry, ...sessionArgs] };
|
|
48
|
+
handle = await containment.spawn(rootCommand.executable, [...rootCommand.arguments], {
|
|
48
49
|
cwd: options.cwd ?? process.cwd(),
|
|
49
50
|
environment: environmentEntries(environment),
|
|
50
51
|
...(environment.TERM ? { terminalType: environment.TERM } : {}),
|
|
@@ -2,6 +2,7 @@ import { type SupervisorEndpointMetadata } from "./cohort-state.js";
|
|
|
2
2
|
import { resolveProductPaths, type SessionSelection, type LaunchProfileId, type SupervisorStartupAttemptIdentity } from "../lifecycle/index.js";
|
|
3
3
|
import { cleanupProvenIdleOwner } from "./process-cleanup.js";
|
|
4
4
|
import { type MaterializedRelease, type VerifyMaterializedReleaseOptions } from "./release-store.js";
|
|
5
|
+
import { type EnsureTerminalHostOptions, type EnsureTerminalHostResult } from "../resident-tabs/index.js";
|
|
5
6
|
export interface BootstrapOptions {
|
|
6
7
|
readonly packageRoot: string;
|
|
7
8
|
readonly launchIntent?: {
|
|
@@ -23,6 +24,9 @@ export interface BootstrapOptions {
|
|
|
23
24
|
readonly releaseIsLaunchable?: (releaseRoot: string) => boolean;
|
|
24
25
|
/** Test seam for bounded maintenance scheduled after stale endpoint sweeping. */
|
|
25
26
|
readonly scheduleMaintenance?: (dataDir: string, paths: ReturnType<typeof resolveProductPaths>) => Promise<void>;
|
|
27
|
+
/** Test seams for resident launch routing before the guardian starts. */
|
|
28
|
+
readonly readResidentTabs?: (configDir: string, profileId: string) => Promise<boolean>;
|
|
29
|
+
readonly ensureTerminalHost?: (options: EnsureTerminalHostOptions) => Promise<EnsureTerminalHostResult>;
|
|
26
30
|
}
|
|
27
31
|
export declare function runBootstrap(options: BootstrapOptions): Promise<number>;
|
|
28
32
|
export declare function certifyMaterializedRelease(release: MaterializedRelease, dataDir: string, verification?: VerifyMaterializedReleaseOptions): Promise<string>;
|