@senpiper/sp-agent 0.1.7 → 0.1.9
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/INSTALL.md +2 -2
- package/README.md +10 -10
- package/dist/distribution.d.ts +2 -2
- package/dist/distribution.d.ts.map +1 -1
- package/dist/distribution.js +2 -1
- package/dist/distribution.js.map +1 -1
- package/dist/extension-identity.generated.d.ts +3 -3
- package/dist/extension-identity.generated.d.ts.map +1 -1
- package/dist/extension-identity.generated.js +3 -3
- package/dist/extension-identity.generated.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/node_modules/@senpiper/pi-coding-agent/dist/bundle/chunks/{chunk-BFHDVWYY.js → chunk-IK73Y232.js} +1 -1
- package/node_modules/@senpiper/pi-coding-agent/dist/bundle/chunks/github-copilot.js +1 -1
- package/node_modules/@senpiper/pi-coding-agent/dist/bundle/cli.js +1 -1
- package/node_modules/@senpiper/pi-coding-agent/dist/bundle/index.js +1 -1
- package/node_modules/@senpiper/pi-coding-agent/dist/bundle/rpc-entry.js +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/.manifest.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/amazon-bedrock.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/azure-openai-responses.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/baseten.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/cerebras.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/cloudflare-ai-gateway.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/fireworks.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/github-copilot.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/huggingface.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/moonshotai-cn.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/moonshotai.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/openai.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/opencode-go.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/opencode.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/openrouter.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/vercel-ai-gateway.json +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/TERMINAL_WEB_BRIDGE.md +3 -3
- package/node_modules/@senpiper/sp-agent-extensions/dist/extension.d.ts.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/extension.js +2 -0
- package/node_modules/@senpiper/sp-agent-extensions/dist/extension.js.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/startup-header.d.ts +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/startup-header.d.ts.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/startup-header.js +13 -2
- package/node_modules/@senpiper/sp-agent-extensions/dist/startup-header.js.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/terminal-web-bridge.d.ts.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/terminal-web-bridge.js +7 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/terminal-web-bridge.js.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/web-command.d.ts +5 -0
- package/node_modules/@senpiper/sp-agent-extensions/dist/web-command.d.ts.map +1 -0
- package/node_modules/@senpiper/sp-agent-extensions/dist/web-command.js +95 -0
- package/node_modules/@senpiper/sp-agent-extensions/dist/web-command.js.map +1 -0
- package/node_modules/@senpiper/sp-agent-extensions/package.json +1 -1
- package/package.json +3 -3
|
@@ -170,13 +170,13 @@ The extension starts one detached sidecar per configured SP agent directory and
|
|
|
170
170
|
|
|
171
171
|
| Variable | Meaning |
|
|
172
172
|
|---|---|
|
|
173
|
-
| `SP_WEB_EXECUTABLE` | Explicit executable path or name, overriding
|
|
173
|
+
| `SP_WEB_EXECUTABLE` | Explicit executable path or name, overriding installed package resolution. |
|
|
174
174
|
| `SP_WEB_ARGS_JSON` | JSON string array of arguments. The launcher does not use a shell. |
|
|
175
175
|
| `SP_WEB_DISABLE` | `1`, `true`, or `yes` disables launch. |
|
|
176
176
|
|
|
177
|
-
Each `SP_WEB_*` setting takes precedence over its old `SP_PI_WEB_*` alias, including an explicitly empty value. Without an executable override, the launcher resolves `@senpiper/sp-web/bin/sp-web.js` from the extension module, then from the SP executable location. The
|
|
177
|
+
Each `SP_WEB_*` setting takes precedence over its old `SP_PI_WEB_*` alias, including an explicitly empty value. Without an executable override, the launcher resolves `@senpiper/sp-web/bin/sp-web.js` from the extension module, then from the SP executable location. The current source pins `@senpiper/sp-web@0.1.1` as a normal registry dependency, installed automatically by the same global SP install command, not bundled in the SP tarball. The resolved absolute entry runs with the current Node executable, or external `node` from PATH when SP runs under Bun. Compiled Bun's executable anchor locates the npm dependency tree even when its module URL is virtual. Resolution does not use the project working directory or a global `pi-web` command.
|
|
178
178
|
|
|
179
|
-
Standalone archives without the npm package fall back to `sp-web` on PATH. Missing executables produce a warning with `SP_WEB_EXECUTABLE` setup guidance. Bun launches of the
|
|
179
|
+
Standalone archives without the npm package fall back to `sp-web` on PATH. Missing executables produce a warning with `SP_WEB_EXECUTABLE` setup guidance. Bun launches of the installed entry require external Node on PATH. A successful launch result records a spawned PID, not HTTP readiness.
|
|
180
180
|
|
|
181
181
|
The launcher always adds `--no-open`, sets `SP_WEB_NO_OPEN=1` and `SP_TERMINAL_BRIDGE_ONLY=1`, and passes `SP_TERMINAL_BRIDGE_DISCOVERY_DIR` and `SP_TERMINAL_BRIDGE_PROTOCOL` to the child. SP Web must use the authenticated descriptors for live commands, never construct another writer for the same session.
|
|
182
182
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"extension.d.ts","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAKhF,OAAO,EAA6B,KAAK,wBAAwB,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"extension.d.ts","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAKhF,OAAO,EAA6B,KAAK,wBAAwB,EAAE,MAAM,0BAA0B,CAAC;AAGpG,wBAAgB,sBAAsB,CAAC,aAAa,GAAE,wBAA6B,GAAG,gBAAgB,CAUrG;;AAED,wBAAwC","sourcesContent":["import type { ExtensionAPI, ExtensionFactory } from \"@senpiper/pi-coding-agent\";\nimport { registerSpCommands } from \"./commands.ts\";\nimport { createSpGatewayExtension } from \"./gateway.ts\";\nimport { registerInteractionExtensions } from \"./interactions.ts\";\nimport { registerSpStartupHeader } from \"./startup-header.ts\";\nimport { registerTerminalWebBridge, type TerminalWebBridgeOptions } from \"./terminal-web-bridge.ts\";\nimport { registerWebCommand } from \"./web-command.ts\";\n\nexport function createSpAgentExtension(bridgeOptions: TerminalWebBridgeOptions = {}): ExtensionFactory {\n\tconst registerGateway = createSpGatewayExtension();\n\treturn (pi: ExtensionAPI) => {\n\t\tregisterGateway(pi);\n\t\tregisterInteractionExtensions(pi);\n\t\tregisterSpCommands(pi);\n\t\tregisterWebCommand(pi, bridgeOptions);\n\t\tregisterSpStartupHeader(pi);\n\t\tregisterTerminalWebBridge(pi, bridgeOptions);\n\t};\n}\n\nexport default createSpAgentExtension();\n"]}
|
|
@@ -3,12 +3,14 @@ import { createSpGatewayExtension } from "./gateway.js";
|
|
|
3
3
|
import { registerInteractionExtensions } from "./interactions.js";
|
|
4
4
|
import { registerSpStartupHeader } from "./startup-header.js";
|
|
5
5
|
import { registerTerminalWebBridge } from "./terminal-web-bridge.js";
|
|
6
|
+
import { registerWebCommand } from "./web-command.js";
|
|
6
7
|
export function createSpAgentExtension(bridgeOptions = {}) {
|
|
7
8
|
const registerGateway = createSpGatewayExtension();
|
|
8
9
|
return (pi) => {
|
|
9
10
|
registerGateway(pi);
|
|
10
11
|
registerInteractionExtensions(pi);
|
|
11
12
|
registerSpCommands(pi);
|
|
13
|
+
registerWebCommand(pi, bridgeOptions);
|
|
12
14
|
registerSpStartupHeader(pi);
|
|
13
15
|
registerTerminalWebBridge(pi, bridgeOptions);
|
|
14
16
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"extension.js","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AACxD,OAAO,EAAE,6BAA6B,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,EAAE,yBAAyB,EAAiC,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"extension.js","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AACxD,OAAO,EAAE,6BAA6B,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,EAAE,yBAAyB,EAAiC,MAAM,0BAA0B,CAAC;AACpG,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAEtD,MAAM,UAAU,sBAAsB,CAAC,aAAa,GAA6B,EAAE,EAAoB;IACtG,MAAM,eAAe,GAAG,wBAAwB,EAAE,CAAC;IACnD,OAAO,CAAC,EAAgB,EAAE,EAAE,CAAC;QAC5B,eAAe,CAAC,EAAE,CAAC,CAAC;QACpB,6BAA6B,CAAC,EAAE,CAAC,CAAC;QAClC,kBAAkB,CAAC,EAAE,CAAC,CAAC;QACvB,kBAAkB,CAAC,EAAE,EAAE,aAAa,CAAC,CAAC;QACtC,uBAAuB,CAAC,EAAE,CAAC,CAAC;QAC5B,yBAAyB,CAAC,EAAE,EAAE,aAAa,CAAC,CAAC;IAAA,CAC7C,CAAC;AAAA,CACF;AAED,eAAe,sBAAsB,EAAE,CAAC","sourcesContent":["import type { ExtensionAPI, ExtensionFactory } from \"@senpiper/pi-coding-agent\";\nimport { registerSpCommands } from \"./commands.ts\";\nimport { createSpGatewayExtension } from \"./gateway.ts\";\nimport { registerInteractionExtensions } from \"./interactions.ts\";\nimport { registerSpStartupHeader } from \"./startup-header.ts\";\nimport { registerTerminalWebBridge, type TerminalWebBridgeOptions } from \"./terminal-web-bridge.ts\";\nimport { registerWebCommand } from \"./web-command.ts\";\n\nexport function createSpAgentExtension(bridgeOptions: TerminalWebBridgeOptions = {}): ExtensionFactory {\n\tconst registerGateway = createSpGatewayExtension();\n\treturn (pi: ExtensionAPI) => {\n\t\tregisterGateway(pi);\n\t\tregisterInteractionExtensions(pi);\n\t\tregisterSpCommands(pi);\n\t\tregisterWebCommand(pi, bridgeOptions);\n\t\tregisterSpStartupHeader(pi);\n\t\tregisterTerminalWebBridge(pi, bridgeOptions);\n\t};\n}\n\nexport default createSpAgentExtension();\n"]}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, StartupMessage, Theme } from "@senpiper/pi-coding-agent";
|
|
2
|
-
export declare function renderSpStartupHeader(theme: Theme, width: number, modelId?: string, gatewayConnected?: boolean): string[];
|
|
2
|
+
export declare function renderSpStartupHeader(theme: Theme, width: number, modelId?: string, gatewayConnected?: boolean, webUrl?: string): string[];
|
|
3
3
|
export declare function filterSpStartupMessage(startupMessage: StartupMessage): string | undefined;
|
|
4
4
|
export declare function registerSpStartupHeader(pi: ExtensionAPI): void;
|
|
5
5
|
//# sourceMappingURL=startup-header.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"startup-header.d.ts","sourceRoot":"","sources":["../src/startup-header.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,KAAK,EAAE,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"startup-header.d.ts","sourceRoot":"","sources":["../src/startup-header.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,KAAK,EAAE,MAAM,2BAA2B,CAAC;AAarF,wBAAgB,qBAAqB,CACpC,KAAK,EAAE,KAAK,EACZ,KAAK,EAAE,MAAM,EACb,OAAO,CAAC,EAAE,MAAM,EAChB,gBAAgB,UAAQ,EACxB,MAAM,CAAC,EAAE,MAAM,YAmCf;AAED,wBAAgB,sBAAsB,CAAC,cAAc,EAAE,cAAc,sBAEpE;AAED,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,YAAY,QAWvD","sourcesContent":["import type { ExtensionAPI, StartupMessage, Theme } from \"@senpiper/pi-coding-agent\";\nimport { truncateToWidth } from \"@senpiper/pi-coding-agent/runtime-deps\";\nimport { readSpGatewayState } from \"./gateway.ts\";\nimport { getSpWebUrl } from \"./web-command.ts\";\n\nconst LLM_IN_A_BOX_BANNER = [\n\t\" _ _ __ __ ___ _ _ _ ____ ___ __ __\",\n\t\"| | | | | \\\\/ | |_ _| \\\\ | | / \\\\ | __ ) / _ \\\\ \\\\ \\\\/ /\",\n\t\"| | | | | |\\\\/| | | || \\\\| | / _ \\\\ | _ \\\\| | | | \\\\ /\",\n\t\"| |___| |___| | | | | || |\\\\ | / ___ \\\\ | |_) | |_| | / \\\\ \",\n\t\"|_____|_____|_| |_| |___|_| \\\\_| /_/ \\\\_\\\\ |____/ \\\\___/ /_/\\\\_\\\\\",\n];\n\nexport function renderSpStartupHeader(\n\ttheme: Theme,\n\twidth: number,\n\tmodelId?: string,\n\tgatewayConnected = false,\n\twebUrl?: string,\n) {\n\tif (webUrl === undefined) {\n\t\ttry {\n\t\t\twebUrl = getSpWebUrl();\n\t\t} catch {\n\t\t\twebUrl = \"Check SP_WEB configuration\";\n\t\t}\n\t}\n\tconst availableWidth = Math.max(1, width);\n\tconst truncateLine = (line: string) => truncateToWidth(line, availableWidth);\n\tconst status = `sp-gateway: ${gatewayConnected ? \"connected\" : \"not configured\"} · model: ${modelId ?? \"not selected\"}`;\n\tconst loginPrompt = gatewayConnected\n\t\t? []\n\t\t: [truncateLine(theme.bold(theme.fg(\"warning\", \"Not logged in. Run /login to continue.\")))];\n\tconst commands = \"/web · /login · /model · /onboarding\";\n\n\tif (availableWidth < 68) {\n\t\treturn [\n\t\t\ttruncateLine(theme.bold(theme.fg(\"accent\", \"LLM IN A BOX\")) + theme.fg(\"dim\", \" · powered by Senpiper\")),\n\t\t\ttruncateLine(theme.fg(\"dim\", `SP Web: ${webUrl}`)),\n\t\t\ttruncateLine(theme.fg(\"muted\", status)),\n\t\t\t...loginPrompt,\n\t\t\ttruncateLine(theme.fg(\"dim\", commands)),\n\t\t];\n\t}\n\n\treturn [\n\t\t...LLM_IN_A_BOX_BANNER.map((line) => truncateLine(theme.bold(theme.fg(\"accent\", line)))),\n\t\ttruncateLine(theme.fg(\"dim\", \"powered by Senpiper\")),\n\t\ttruncateLine(theme.fg(\"dim\", `SP Web: ${webUrl}`)),\n\t\ttruncateLine(theme.fg(\"muted\", status)),\n\t\t...loginPrompt,\n\t\ttruncateLine(theme.fg(\"dim\", commands)),\n\t];\n}\n\nexport function filterSpStartupMessage(startupMessage: StartupMessage) {\n\treturn startupMessage.type === \"no-model\" ? undefined : startupMessage.message;\n}\n\nexport function registerSpStartupHeader(pi: ExtensionAPI) {\n\tpi.registerStartupMessageFilter(filterSpStartupMessage);\n\tpi.on(\"session_start\", async (_event, context) => {\n\t\tif (context.mode !== \"tui\") return;\n\t\tcontext.ui.setHeader((_tui, theme) => ({\n\t\t\trender(width) {\n\t\t\t\treturn renderSpStartupHeader(theme, width, context.model?.id, readSpGatewayState() !== undefined);\n\t\t\t},\n\t\t\tinvalidate() {},\n\t\t}));\n\t});\n}\n"]}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { truncateToWidth } from "@senpiper/pi-coding-agent/runtime-deps";
|
|
2
2
|
import { readSpGatewayState } from "./gateway.js";
|
|
3
|
+
import { getSpWebUrl } from "./web-command.js";
|
|
3
4
|
const LLM_IN_A_BOX_BANNER = [
|
|
4
5
|
" _ _ __ __ ___ _ _ _ ____ ___ __ __",
|
|
5
6
|
"| | | | | \\/ | |_ _| \\ | | / \\ | __ ) / _ \\ \\ \\/ /",
|
|
@@ -7,17 +8,26 @@ const LLM_IN_A_BOX_BANNER = [
|
|
|
7
8
|
"| |___| |___| | | | | || |\\ | / ___ \\ | |_) | |_| | / \\ ",
|
|
8
9
|
"|_____|_____|_| |_| |___|_| \\_| /_/ \\_\\ |____/ \\___/ /_/\\_\\",
|
|
9
10
|
];
|
|
10
|
-
export function renderSpStartupHeader(theme, width, modelId, gatewayConnected = false) {
|
|
11
|
+
export function renderSpStartupHeader(theme, width, modelId, gatewayConnected = false, webUrl) {
|
|
12
|
+
if (webUrl === undefined) {
|
|
13
|
+
try {
|
|
14
|
+
webUrl = getSpWebUrl();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
webUrl = "Check SP_WEB configuration";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
11
20
|
const availableWidth = Math.max(1, width);
|
|
12
21
|
const truncateLine = (line) => truncateToWidth(line, availableWidth);
|
|
13
22
|
const status = `sp-gateway: ${gatewayConnected ? "connected" : "not configured"} · model: ${modelId ?? "not selected"}`;
|
|
14
23
|
const loginPrompt = gatewayConnected
|
|
15
24
|
? []
|
|
16
25
|
: [truncateLine(theme.bold(theme.fg("warning", "Not logged in. Run /login to continue.")))];
|
|
17
|
-
const commands = "/login · /model · /onboarding";
|
|
26
|
+
const commands = "/web · /login · /model · /onboarding";
|
|
18
27
|
if (availableWidth < 68) {
|
|
19
28
|
return [
|
|
20
29
|
truncateLine(theme.bold(theme.fg("accent", "LLM IN A BOX")) + theme.fg("dim", " · powered by Senpiper")),
|
|
30
|
+
truncateLine(theme.fg("dim", `SP Web: ${webUrl}`)),
|
|
21
31
|
truncateLine(theme.fg("muted", status)),
|
|
22
32
|
...loginPrompt,
|
|
23
33
|
truncateLine(theme.fg("dim", commands)),
|
|
@@ -26,6 +36,7 @@ export function renderSpStartupHeader(theme, width, modelId, gatewayConnected =
|
|
|
26
36
|
return [
|
|
27
37
|
...LLM_IN_A_BOX_BANNER.map((line) => truncateLine(theme.bold(theme.fg("accent", line)))),
|
|
28
38
|
truncateLine(theme.fg("dim", "powered by Senpiper")),
|
|
39
|
+
truncateLine(theme.fg("dim", `SP Web: ${webUrl}`)),
|
|
29
40
|
truncateLine(theme.fg("muted", status)),
|
|
30
41
|
...loginPrompt,
|
|
31
42
|
truncateLine(theme.fg("dim", commands)),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"startup-header.js","sourceRoot":"","sources":["../src/startup-header.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,MAAM,wCAAwC,CAAC;AACzE,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"startup-header.js","sourceRoot":"","sources":["../src/startup-header.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,MAAM,wCAAwC,CAAC;AACzE,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAE/C,MAAM,mBAAmB,GAAG;IAC3B,gEAAgE;IAChE,sEAAsE;IACtE,oEAAoE;IACpE,mEAAmE;IACnE,sEAAsE;CACtE,CAAC;AAEF,MAAM,UAAU,qBAAqB,CACpC,KAAY,EACZ,KAAa,EACb,OAAgB,EAChB,gBAAgB,GAAG,KAAK,EACxB,MAAe,EACd;IACD,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QAC1B,IAAI,CAAC;YACJ,MAAM,GAAG,WAAW,EAAE,CAAC;QACxB,CAAC;QAAC,MAAM,CAAC;YACR,MAAM,GAAG,4BAA4B,CAAC;QACvC,CAAC;IACF,CAAC;IACD,MAAM,cAAc,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;IAC1C,MAAM,YAAY,GAAG,CAAC,IAAY,EAAE,EAAE,CAAC,eAAe,CAAC,IAAI,EAAE,cAAc,CAAC,CAAC;IAC7E,MAAM,MAAM,GAAG,eAAe,gBAAgB,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,gBAAgB,cAAa,OAAO,IAAI,cAAc,EAAE,CAAC;IACxH,MAAM,WAAW,GAAG,gBAAgB;QACnC,CAAC,CAAC,EAAE;QACJ,CAAC,CAAC,CAAC,YAAY,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,SAAS,EAAE,wCAAwC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC7F,MAAM,QAAQ,GAAG,yCAAsC,CAAC;IAExD,IAAI,cAAc,GAAG,EAAE,EAAE,CAAC;QACzB,OAAO;YACN,YAAY,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,QAAQ,EAAE,cAAc,CAAC,CAAC,GAAG,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,yBAAwB,CAAC,CAAC;YACxG,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,MAAM,EAAE,CAAC,CAAC;YAClD,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YACvC,GAAG,WAAW;YACd,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;SACvC,CAAC;IACH,CAAC;IAED,OAAO;QACN,GAAG,mBAAmB,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC;QACxF,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,qBAAqB,CAAC,CAAC;QACpD,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,MAAM,EAAE,CAAC,CAAC;QAClD,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QACvC,GAAG,WAAW;QACd,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;KACvC,CAAC;AAAA,CACF;AAED,MAAM,UAAU,sBAAsB,CAAC,cAA8B,EAAE;IACtE,OAAO,cAAc,CAAC,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,cAAc,CAAC,OAAO,CAAC;AAAA,CAC/E;AAED,MAAM,UAAU,uBAAuB,CAAC,EAAgB,EAAE;IACzD,EAAE,CAAC,4BAA4B,CAAC,sBAAsB,CAAC,CAAC;IACxD,EAAE,CAAC,EAAE,CAAC,eAAe,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,CAAC;QACjD,IAAI,OAAO,CAAC,IAAI,KAAK,KAAK;YAAE,OAAO;QACnC,OAAO,CAAC,EAAE,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;YACtC,MAAM,CAAC,KAAK,EAAE;gBACb,OAAO,qBAAqB,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,EAAE,EAAE,kBAAkB,EAAE,KAAK,SAAS,CAAC,CAAC;YAAA,CAClG;YACD,UAAU,GAAG,EAAC,CAAC;SACf,CAAC,CAAC,CAAC;IAAA,CACJ,CAAC,CAAC;AAAA,CACH","sourcesContent":["import type { ExtensionAPI, StartupMessage, Theme } from \"@senpiper/pi-coding-agent\";\nimport { truncateToWidth } from \"@senpiper/pi-coding-agent/runtime-deps\";\nimport { readSpGatewayState } from \"./gateway.ts\";\nimport { getSpWebUrl } from \"./web-command.ts\";\n\nconst LLM_IN_A_BOX_BANNER = [\n\t\" _ _ __ __ ___ _ _ _ ____ ___ __ __\",\n\t\"| | | | | \\\\/ | |_ _| \\\\ | | / \\\\ | __ ) / _ \\\\ \\\\ \\\\/ /\",\n\t\"| | | | | |\\\\/| | | || \\\\| | / _ \\\\ | _ \\\\| | | | \\\\ /\",\n\t\"| |___| |___| | | | | || |\\\\ | / ___ \\\\ | |_) | |_| | / \\\\ \",\n\t\"|_____|_____|_| |_| |___|_| \\\\_| /_/ \\\\_\\\\ |____/ \\\\___/ /_/\\\\_\\\\\",\n];\n\nexport function renderSpStartupHeader(\n\ttheme: Theme,\n\twidth: number,\n\tmodelId?: string,\n\tgatewayConnected = false,\n\twebUrl?: string,\n) {\n\tif (webUrl === undefined) {\n\t\ttry {\n\t\t\twebUrl = getSpWebUrl();\n\t\t} catch {\n\t\t\twebUrl = \"Check SP_WEB configuration\";\n\t\t}\n\t}\n\tconst availableWidth = Math.max(1, width);\n\tconst truncateLine = (line: string) => truncateToWidth(line, availableWidth);\n\tconst status = `sp-gateway: ${gatewayConnected ? \"connected\" : \"not configured\"} · model: ${modelId ?? \"not selected\"}`;\n\tconst loginPrompt = gatewayConnected\n\t\t? []\n\t\t: [truncateLine(theme.bold(theme.fg(\"warning\", \"Not logged in. Run /login to continue.\")))];\n\tconst commands = \"/web · /login · /model · /onboarding\";\n\n\tif (availableWidth < 68) {\n\t\treturn [\n\t\t\ttruncateLine(theme.bold(theme.fg(\"accent\", \"LLM IN A BOX\")) + theme.fg(\"dim\", \" · powered by Senpiper\")),\n\t\t\ttruncateLine(theme.fg(\"dim\", `SP Web: ${webUrl}`)),\n\t\t\ttruncateLine(theme.fg(\"muted\", status)),\n\t\t\t...loginPrompt,\n\t\t\ttruncateLine(theme.fg(\"dim\", commands)),\n\t\t];\n\t}\n\n\treturn [\n\t\t...LLM_IN_A_BOX_BANNER.map((line) => truncateLine(theme.bold(theme.fg(\"accent\", line)))),\n\t\ttruncateLine(theme.fg(\"dim\", \"powered by Senpiper\")),\n\t\ttruncateLine(theme.fg(\"dim\", `SP Web: ${webUrl}`)),\n\t\ttruncateLine(theme.fg(\"muted\", status)),\n\t\t...loginPrompt,\n\t\ttruncateLine(theme.fg(\"dim\", commands)),\n\t];\n}\n\nexport function filterSpStartupMessage(startupMessage: StartupMessage) {\n\treturn startupMessage.type === \"no-model\" ? undefined : startupMessage.message;\n}\n\nexport function registerSpStartupHeader(pi: ExtensionAPI) {\n\tpi.registerStartupMessageFilter(filterSpStartupMessage);\n\tpi.on(\"session_start\", async (_event, context) => {\n\t\tif (context.mode !== \"tui\") return;\n\t\tcontext.ui.setHeader((_tui, theme) => ({\n\t\t\trender(width) {\n\t\t\t\treturn renderSpStartupHeader(theme, width, context.model?.id, readSpGatewayState() !== undefined);\n\t\t\t},\n\t\t\tinvalidate() {},\n\t\t}));\n\t});\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"terminal-web-bridge.d.ts","sourceRoot":"","sources":["../src/terminal-web-bridge.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAIhF,OAAO,EAAsB,KAAK,oBAAoB,EAAE,KAAK,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAE7G,eAAO,MAAM,wBAAwB,0BAA0B,CAAC;AAChE,eAAO,MAAM,uBAAuB,IAAI,CAAC;AACzC,eAAO,MAAM,uCAAuC,QAAmB,CAAC;AACxE,eAAO,MAAM,0CAA0C,QAAmB,CAAC;AAC3E,eAAO,MAAM,mCAAmC,IAAI,CAAC;AACrD,eAAO,MAAM,4BAA4B;;;;;CAK/B,CAAC;AAeX,MAAM,WAAW,wBAAwB;IACxC,OAAO,EAAE,CAAC,CAAC;IACX,QAAQ,EAAE,OAAO,wBAAwB,CAAC;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,kBAAkB,CAAC,EAAE,CAAC,CAAC;IACvB,KAAK,EAAE;QACN,QAAQ,EAAE,wBAAwB,CAAC;QACnC,QAAQ,EAAE,MAAM,CAAC;KACjB,CAAC;CACF;AAED,MAAM,WAAW,wBAAwB;IACxC,kGAAkG;IAClG,kBAAkB,CAAC,EAAE,CAAC,CAAC;IACvB,qFAAqF;IACrF,QAAQ,CAAC,EAAE;QACV,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;YAAE,SAAS,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QACpD,MAAM,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC;YAAE,SAAS,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QAC5D,aAAa,CAAC,CACb,OAAO,EAAE;YAAE,SAAS,EAAE,MAAM,CAAC;YAAC,WAAW,CAAC,EAAE,MAAM,CAAC;YAAC,SAAS,EAAE,IAAI,CAAA;SAAE,EACrE,SAAS,CAAC,EAAE,iBAAiB,GAC3B,OAAO,CAAC;YAAE,SAAS,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,IAAI,CAAA;SAAE,CAAC,CAAC;QACjD,eAAe,CAAC,OAAO,EAAE,gBAAgB,GAAG,IAAI,CAAC;QACjD,aAAa,CAAC,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAAC;QAChD,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;KACzB,CAAC;IACF,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,eAAe,CAAC,EAAE,oBAAoB,CAAC;IACvC,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,gBAAgB,EAAE,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACxG;AA4BD,UAAU,UAAU;IACnB,CAAC,EAAE,CAAC,CAAC;IACL,IAAI,EAAE,OAAO,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,SAAS,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,aAAa,CAAC;IAC1F,OAAO,EAAE,OAAO,CAAC;CACjB;AA+OD,qBAAa,iBAAiB;IAC7B,OAAO,CAAC,eAAe,CAAK;IAC5B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA2E;IAC1G,OAAO,CAAC,aAAa,CAA6D;IAElF,wFAAwF;IACxF,eAAe,CAAC,kBAAkB,EAAE,OAAO,GAAG,MAAM,IAAI,CAcvD;IAEK,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAGpC;IACD,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAe;IAClC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA2B;IACnD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAiD;IAC5E,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAyC;IACnE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA4B;IACtD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAClD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA+C;IAC5E,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,wBAAwB,CAAK;IACrC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,OAAO,CAA+B;IAC9C,OAAO,CAAC,KAAK,CAA0B;IACvC,OAAO,CAAC,UAAU,CAAuC;IACzD,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,YAAY,CAAoC;IACxD,OAAO,CAAC,cAAc,CAAoC;IAC1D,OAAO,CAAC,OAAO,CAAyB;IACxC,OAAO,CAAC,aAAa,CAAgC;IACrD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAsB;IAChD,OAAO,CAAC,oBAAoB,CAA2B;IAEvD,YAAY,EAAE,EAAE,YAAY,EAAE,OAAO,GAAE,wBAA6B,EAQnE;IAED,aAAa,yCAEZ;IAED,gBAAgB,kCAEf;IAED,KAAK,CAAC,OAAO,EAAE,gBAAgB,iBAI9B;YAEa,WAAW;IAqDzB,aAAa,CAAC,OAAO,EAAE,gBAAgB,QAEtC;IAED,iBAAiB,CAAC,OAAO,EAAE,gBAAgB,QAoC1C;IAED,OAAO,CAAC,QAAQ,EAAE,UAAU,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,OAAO,EAAE,gBAAgB,QA6C3F;IAED,IAAI,kBAWH;IAED,iFAAiF;IACjF,aAAa,SAaZ;YAEa,UAAU;IA8BxB,OAAO,CAAC,YAAY;IAqBpB,OAAO,CAAC,OAAO;IAqCf,OAAO,CAAC,YAAY;IAkBpB,OAAO,CAAC,aAAa;YAkEP,OAAO;IAwIrB,OAAO,CAAC,aAAa;IA+CrB,OAAO,CAAC,cAAc;IAkCtB,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,YAAY;IAWpB,OAAO,CAAC,WAAW;IAQnB,OAAO,CAAC,MAAM;IAMd,OAAO,CAAC,IAAI;IASZ,OAAO,CAAC,YAAY;IASpB,OAAO,CAAC,WAAW;CAuBnB;AAED,wBAAgB,yBAAyB,CAAC,EAAE,EAAE,YAAY,EAAE,OAAO,GAAE,wBAA6B,qBAyCjG;AAED,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,MAAM,GAAG,wBAAwB,CAGnF;AAED,wBAAgB,8BAA8B,CAAC,IAAI,EAAE,MAAM,WAE1D","sourcesContent":["import { AsyncLocalStorage } from \"node:async_hooks\";\nimport { randomBytes, randomUUID, timingSafeEqual } from \"node:crypto\";\nimport { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from \"node:fs\";\nimport { createServer, type Server, type Socket } from \"node:net\";\nimport { join } from \"node:path\";\nimport type { ExtensionAPI, ExtensionContext } from \"@senpiper/pi-coding-agent\";\nimport { completeSimple } from \"@senpiper/pi-coding-agent/runtime-deps\";\nimport { getSpAgentDir } from \"./gateway.ts\";\nimport { getQuestionInteraction, type QuestionInteraction, QuestionResponseError } from \"./question-interaction.ts\";\nimport { ensureSpWebSidecar, type SpWebLauncherOptions, type SpWebLaunchResult } from \"./sp-web-launcher.ts\";\n\nexport const TERMINAL_BRIDGE_PROTOCOL = \"sp-terminal-bridge-v1\";\nexport const TERMINAL_BRIDGE_VERSION = 1;\nexport const DEFAULT_TERMINAL_BRIDGE_MAX_FRAME_BYTES = 10 * 1024 * 1024;\nexport const DEFAULT_TERMINAL_BRIDGE_MAX_BUFFERED_BYTES = 10 * 1024 * 1024;\nexport const DEFAULT_TERMINAL_BRIDGE_MAX_CLIENTS = 8;\nexport const TERMINAL_BRIDGE_IMAGE_LIMITS = {\n\tmimeTypes: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\"],\n\tmaxImages: 8,\n\tmaxBase64BytesPerImage: 4 * 1024 * 1024,\n\tmaxBase64BytesTotal: 8 * 1024 * 1024,\n} as const;\n\nconst MAX_REQUEST_ID_LENGTH = 128;\nconst MAX_PENDING_REQUESTS = 32;\nconst MAX_SEEN_REQUEST_IDS = 1024;\nconst MAX_EVENT_HISTORY = 256;\nconst MAX_EVENT_HISTORY_BYTES = 32 * 1024 * 1024;\nconst MAX_TRANSCRIPT_PAGE_SIZE = 100;\nconst MAX_TITLE_CONTEXT_BYTES = 32 * 1024;\nconst MAX_TITLE_LENGTH = 80;\nconst THINKING_LEVELS = [\"off\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const;\n\ntype ThinkingLevel = (typeof THINKING_LEVELS)[number];\ntype JsonRecord = Record<string, unknown>;\n\nexport interface TerminalBridgeDescriptor {\n\tversion: 1;\n\tprotocol: typeof TERMINAL_BRIDGE_PROTOCOL;\n\tinstanceId: string;\n\tpid: number;\n\tsessionId: string;\n\tsessionFile?: string;\n\tcwd: string;\n\tsocketPath: string;\n\tauthToken: string;\n\tgeneration: number;\n\tstartedAt: string;\n\twriterLeaseVersion?: 1;\n\tframe: {\n\t\tencoding: \"newline-delimited-json\";\n\t\tmaxBytes: number;\n\t};\n}\n\nexport interface TerminalWebBridgeOptions {\n\t/** Set only by a distribution that installed the lifetime writer provider before file opening. */\n\twriterLeaseVersion?: 1;\n\t/** Supplied by the verified SP distribution, shared across terminal replacements. */\n\tsessions?: {\n\t\tcreate(cwd: string): Promise<{ sessionId: string }>;\n\t\tresume(sessionFile: string): Promise<{ sessionId: string }>;\n\t\tdeleteSession?(\n\t\t\trequest: { sessionId: string; sessionFile?: string; confirmed: true },\n\t\t\trequester?: TerminalWebBridge,\n\t\t): Promise<{ sessionId: string; deleted: true }>;\n\t\tterminalStarted(context: ExtensionContext): void;\n\t\tbridgeStopped?(bridge: TerminalWebBridge): void;\n\t\tdispose(): Promise<void>;\n\t};\n\twebOwned?: boolean;\n\tonReady?: (bridge: TerminalWebBridge) => void;\n\tagentDir?: string;\n\tmaxFrameBytes?: number;\n\tmaxBufferedBytes?: number;\n\tmaxClients?: number;\n\tlaunchSpWeb?: boolean;\n\tlauncherOptions?: SpWebLauncherOptions;\n\ttitleGenerator?: (context: ExtensionContext, pi: ExtensionAPI, signal: AbortSignal) => Promise<unknown>;\n}\n\ninterface BridgePaths {\n\trootDir: string;\n\tdiscoveryDir: string;\n\tsocketDir: string;\n\tdescriptorPath: string;\n\tsocketPath: string;\n}\n\ninterface ClientState {\n\tsocket: Socket;\n\tbuffer: Buffer;\n\tauthenticated: boolean;\n\tsubscribed: boolean;\n\tpendingRequests: number;\n\tseenRequestIds: Set<string>;\n}\n\ninterface RequestFrame {\n\tv: 1;\n\ttype: \"request\";\n\tid: string;\n\tgeneration: number;\n\tcommand: string;\n\tparams?: JsonRecord;\n}\n\ninterface EventFrame {\n\tv: 1;\n\ttype: \"event\";\n\tgeneration: number;\n\tsequence: number;\n\tevent: string;\n\tcategory: \"message\" | \"agent\" | \"tool\" | \"model\" | \"thinking\" | \"session\" | \"interaction\";\n\tpayload: unknown;\n}\n\nclass RequestError extends Error {\n\treadonly code: string;\n\n\tconstructor(code: string, message: string) {\n\t\tsuper(message);\n\t\tthis.code = code;\n\t}\n}\n\nfunction isRecord(value: unknown): value is JsonRecord {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction isMissingFileError(error: unknown) {\n\treturn isRecord(error) && error.code === \"ENOENT\";\n}\n\nfunction requireString(value: unknown, name: string, maximumLength = 100_000) {\n\tif (typeof value !== \"string\" || !value.trim()) throw new RequestError(\"invalid_params\", `${name} is required.`);\n\tif (Buffer.byteLength(value, \"utf8\") > maximumLength) {\n\t\tthrow new RequestError(\"invalid_params\", `${name} is too long.`);\n\t}\n\treturn value;\n}\n\nfunction userMessageContent(params: JsonRecord): Parameters<ExtensionAPI[\"sendUserMessage\"]>[0] {\n\tconst images = params.images;\n\tif (images === undefined || (Array.isArray(images) && images.length === 0)) {\n\t\treturn requireString(params.text, \"text\");\n\t}\n\tif (!Array.isArray(images) || images.length > TERMINAL_BRIDGE_IMAGE_LIMITS.maxImages) {\n\t\tthrow new RequestError(\"invalid_params\", \"images must be an array of at most 8 images.\");\n\t}\n\tconst content: Exclude<Parameters<ExtensionAPI[\"sendUserMessage\"]>[0], string> = [];\n\tif (params.text !== undefined) {\n\t\tif (typeof params.text !== \"string\" || Buffer.byteLength(params.text, \"utf8\") > 100_000) {\n\t\t\tthrow new RequestError(\"invalid_params\", \"text must be a string of at most 100000 UTF-8 bytes.\");\n\t\t}\n\t\tif (params.text.length > 0) content.push({ type: \"text\", text: params.text });\n\t}\n\tlet total = 0;\n\tfor (const image of images) {\n\t\tif (\n\t\t\t!isRecord(image) ||\n\t\t\tObject.keys(image).some((key) => key !== \"mimeType\" && key !== \"data\") ||\n\t\t\ttypeof image.mimeType !== \"string\" ||\n\t\t\t!TERMINAL_BRIDGE_IMAGE_LIMITS.mimeTypes.some((mimeType) => mimeType === image.mimeType) ||\n\t\t\ttypeof image.data !== \"string\" ||\n\t\t\timage.data.length === 0\n\t\t) {\n\t\t\tthrow new RequestError(\"invalid_params\", \"Each image requires a supported mimeType and base64 data only.\");\n\t\t}\n\t\ttotal += image.data.length;\n\t\tif (\n\t\t\timage.data.length > TERMINAL_BRIDGE_IMAGE_LIMITS.maxBase64BytesPerImage ||\n\t\t\ttotal > TERMINAL_BRIDGE_IMAGE_LIMITS.maxBase64BytesTotal\n\t\t) {\n\t\t\tthrow new RequestError(\"invalid_params\", \"Image base64 exceeds the per-image or aggregate byte limit.\");\n\t\t}\n\t\t// Buffer's decoder alone accepts paths, whitespace, URL-safe and malformed base64.\n\t\tif (/[^A-Za-z0-9+/=]/u.test(image.data) || Buffer.from(image.data, \"base64\").toString(\"base64\") !== image.data) {\n\t\t\tthrow new RequestError(\"invalid_params\", \"Image data must be canonical padded base64, not a path or URL.\");\n\t\t}\n\t\tcontent.push({ type: \"image\", mimeType: image.mimeType, data: image.data });\n\t}\n\treturn content;\n}\n\nfunction optionalInteger(value: unknown, name: string, fallback: number) {\n\tif (value === undefined) return fallback;\n\tif (!Number.isSafeInteger(value) || (value as number) < 0) {\n\t\tthrow new RequestError(\"invalid_params\", `${name} must be a non-negative integer.`);\n\t}\n\treturn value as number;\n}\n\nfunction normalizeForJson(value: unknown, omitImageData = false): unknown {\n\treturn JSON.parse(\n\t\tJSON.stringify(value, (_key, entry: unknown) => {\n\t\t\tif (omitImageData && isRecord(entry) && entry.type === \"image\") {\n\t\t\t\treturn { type: \"image\", mimeType: entry.mimeType };\n\t\t\t}\n\t\t\tif (typeof entry === \"bigint\") return entry.toString();\n\t\t\tif (entry instanceof Error) return { name: entry.name, message: entry.message };\n\t\t\treturn entry;\n\t\t}),\n\t) as unknown;\n}\n\nfunction availableModels(context: ExtensionContext) {\n\tconst scoped = context.scopedModels.map((entry) => entry.model);\n\tconst models = scoped.length > 0 ? scoped : context.modelRegistry.getAvailable();\n\treturn models\n\t\t.filter((model) => model.provider === \"sp-gateway\")\n\t\t.map((model) => ({\n\t\t\tprovider: model.provider,\n\t\t\tid: model.id,\n\t\t\tname: model.name,\n\t\t\tinput: model.input,\n\t\t\treasoning: model.reasoning,\n\t\t\tcontextWindow: model.contextWindow,\n\t\t\tmaxTokens: model.maxTokens,\n\t\t\t...(model.thinkingLevelMap ? { thinkingLevelMap: model.thinkingLevelMap } : {}),\n\t\t}));\n}\n\nfunction parseGeneratedTitle(raw: string) {\n\tlet title = raw.trim().split(/\\r?\\n/u, 1)[0] ?? \"\";\n\ttitle = title.replace(/^(?:session\\s+title|title)\\s*[:-]\\s*/iu, \"\");\n\ttitle = title\n\t\t.replace(/^[\"'`]|[\"'`]$/gu, \"\")\n\t\t.replace(/\\s+/gu, \" \")\n\t\t.replace(/[.!]+$/u, \"\")\n\t\t.trim();\n\tif (!/[\\p{L}\\p{N}]/u.test(title))\n\t\tthrow new RequestError(\"title_generation_failed\", \"The model returned no usable title.\");\n\treturn Array.from(title).slice(0, MAX_TITLE_LENGTH).join(\"\").trim();\n}\n\nasync function generateTitle(context: ExtensionContext, pi: ExtensionAPI, signal: AbortSignal) {\n\tif (!context.isIdle()) throw new RequestError(\"busy\", \"A title can only be generated while the agent is idle.\");\n\tconst activeModel = context.model;\n\tif (!activeModel || activeModel.provider !== \"sp-gateway\") {\n\t\tthrow new RequestError(\"model_unavailable\", \"Title generation requires an active sp-gateway model.\");\n\t}\n\tconst model = context.modelRegistry.find(activeModel.provider, activeModel.id);\n\tif (!model) throw new RequestError(\"model_unavailable\", \"The active sp-gateway model is unavailable.\");\n\tconst auth = await context.modelRegistry.getApiKeyAndHeaders(model);\n\tsignal.throwIfAborted();\n\tif (!auth.ok || !auth.apiKey) {\n\t\tthrow new RequestError(\"model_auth_unavailable\", auth.ok ? \"sp-gateway has no API key available.\" : auth.error);\n\t}\n\tconst serialized = Buffer.from(JSON.stringify(normalizeForJson(context.sessionManager.getEntries(), true)), \"utf8\");\n\tconst transcript =\n\t\tserialized.length <= MAX_TITLE_CONTEXT_BYTES\n\t\t\t? serialized.toString(\"utf8\")\n\t\t\t: serialized.subarray(-MAX_TITLE_CONTEXT_BYTES).toString(\"utf8\");\n\tconst response = await completeSimple(\n\t\tmodel,\n\t\t{\n\t\t\tsystemPrompt: [\n\t\t\t\t\"Create a concise English title for this coding session.\",\n\t\t\t\t\"Use 4-12 words. Describe the concrete task or result.\",\n\t\t\t\t\"Return only the title as plain text. Do not use tools, quotes, labels, or punctuation at the end.\",\n\t\t\t].join(\"\\n\"),\n\t\t\tmessages: [\n\t\t\t\t{\n\t\t\t\t\trole: \"user\",\n\t\t\t\t\tcontent: `Session transcript entries:\\n${transcript}`,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t},\n\t\t\t],\n\t\t\ttools: [],\n\t\t},\n\t\t{ apiKey: auth.apiKey, headers: auth.headers, env: auth.env, signal },\n\t);\n\tsignal.throwIfAborted();\n\tif (response.stopReason === \"error\") {\n\t\tthrow new RequestError(\"title_generation_failed\", response.errorMessage ?? \"The title model request failed.\");\n\t}\n\tconst rawTitle = response.content\n\t\t.filter((part): part is { type: \"text\"; text: string } => part.type === \"text\")\n\t\t.map((part) => part.text)\n\t\t.join(\"\\n\");\n\tconst title = parseGeneratedTitle(rawTitle);\n\tpi.setSessionName(title);\n\treturn {\n\t\ttitle,\n\t\tusage: {\n\t\t\tinput: response.usage.input,\n\t\t\toutput: response.usage.output,\n\t\t\tcacheRead: response.usage.cacheRead,\n\t\t\tcacheWrite: response.usage.cacheWrite,\n\t\t\ttotal: response.usage.totalTokens,\n\t\t},\n\t};\n}\n\nfunction makePaths(agentDir: string, instanceId: string): BridgePaths {\n\tconst rootDir = join(agentDir, \"web-bridge\");\n\tconst discoveryDir = join(rootDir, \"discovery\");\n\tconst socketDir = join(rootDir, \"sockets\");\n\treturn {\n\t\trootDir,\n\t\tdiscoveryDir,\n\t\tsocketDir,\n\t\tdescriptorPath: join(discoveryDir, `${instanceId}.json`),\n\t\tsocketPath: join(socketDir, `${instanceId}.sock`),\n\t};\n}\n\nfunction ensurePrivateDirectory(path: string) {\n\tmkdirSync(path, { recursive: true, mode: 0o700 });\n\tif (process.platform !== \"win32\") chmodSync(path, 0o700);\n}\n\nfunction removeFile(path: string) {\n\ttry {\n\t\tunlinkSync(path);\n\t} catch (error) {\n\t\tif (!isMissingFileError(error)) throw error;\n\t}\n}\n\nfunction tokenMatches(expected: string, received: unknown) {\n\tif (typeof received !== \"string\") return false;\n\tconst expectedBytes = Buffer.from(expected, \"utf8\");\n\tconst receivedBytes = Buffer.from(received, \"utf8\");\n\treturn expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes);\n}\n\nfunction parseRequest(value: JsonRecord): RequestFrame {\n\tif (\n\t\tvalue.v !== TERMINAL_BRIDGE_VERSION ||\n\t\tvalue.type !== \"request\" ||\n\t\ttypeof value.id !== \"string\" ||\n\t\t!value.id ||\n\t\tvalue.id.length > MAX_REQUEST_ID_LENGTH ||\n\t\t!Number.isSafeInteger(value.generation) ||\n\t\ttypeof value.command !== \"string\" ||\n\t\t!value.command\n\t) {\n\t\tthrow new RequestError(\"invalid_request\", \"Invalid request envelope.\");\n\t}\n\tif (value.params !== undefined && !isRecord(value.params)) {\n\t\tthrow new RequestError(\"invalid_request\", \"params must be an object.\");\n\t}\n\treturn {\n\t\tv: TERMINAL_BRIDGE_VERSION,\n\t\ttype: \"request\",\n\t\tid: value.id,\n\t\tgeneration: value.generation as number,\n\t\tcommand: value.command,\n\t\tparams: value.params,\n\t};\n}\n\nexport class TerminalWebBridge {\n\tprivate pendingCommands = 0;\n\tprivate deleting = false;\n\tprivate readonly requestContext = new AsyncLocalStorage<{ request: RequestFrame; client: ClientState }>();\n\tprivate deletionOwner: { request: RequestFrame; client: ClientState } | undefined;\n\n\t/** Gate new commands synchronously before the owner checks/disposes its idle writer. */\n\treserveDeletion(preserveConnection: boolean): () => void {\n\t\tconst owner = preserveConnection ? this.requestContext.getStore() : undefined;\n\t\tif (preserveConnection && owner?.request.command !== \"delete_session\") {\n\t\t\tthrow new RequestError(\"invalid_request\", \"Connection preservation requires its deletion request.\");\n\t\t}\n\t\tif (this.deleting || this.pendingCommands > (preserveConnection ? 1 : 0) || this.questions.getPending()) {\n\t\t\tthrow new RequestError(\"busy\", \"Session has pending bridge commands or a question.\");\n\t\t}\n\t\tthis.deleting = true;\n\t\tthis.deletionOwner = owner;\n\t\treturn () => {\n\t\t\tthis.deleting = false;\n\t\t\tthis.deletionOwner = undefined;\n\t\t};\n\t}\n\n\tasync finishDeletion(): Promise<void> {\n\t\tthis.deletionOwner = undefined;\n\t\tawait this.stop();\n\t}\n\tprivate readonly pi: ExtensionAPI;\n\tprivate readonly options: TerminalWebBridgeOptions;\n\tprivate readonly instanceId = randomUUID().replaceAll(\"-\", \"\").slice(0, 24);\n\tprivate readonly authToken = randomBytes(32).toString(\"base64url\");\n\tprivate readonly startedAt = new Date().toISOString();\n\tprivate readonly clients = new Set<ClientState>();\n\tprivate readonly eventHistory: { sequence: number; encoded: Buffer }[] = [];\n\tprivate eventHistoryBytes = 0;\n\tprivate lastUnreplayableSequence = 0;\n\tprivate readonly maxFrameBytes: number;\n\tprivate readonly maxBufferedBytes: number;\n\tprivate readonly maxClients: number;\n\tprivate server: Server | undefined;\n\tprivate context: ExtensionContext | undefined;\n\tprivate paths: BridgePaths | undefined;\n\tprivate descriptor: TerminalBridgeDescriptor | undefined;\n\tprivate generation = 0;\n\tprivate sequence = 0;\n\tprivate commandQueue: Promise<void> = Promise.resolve();\n\tprivate lifecycleQueue: Promise<void> = Promise.resolve();\n\tprivate binding = new AbortController();\n\tprivate sidecarResult: SpWebLaunchResult | undefined;\n\tprivate readonly questions: QuestionInteraction;\n\tprivate unsubscribeQuestions: (() => void) | undefined;\n\n\tconstructor(pi: ExtensionAPI, options: TerminalWebBridgeOptions = {}) {\n\t\tthis.pi = pi;\n\t\tthis.questions = getQuestionInteraction(pi);\n\t\tthis.questions.webOnly = options.webOwned === true;\n\t\tthis.options = options;\n\t\tthis.maxFrameBytes = options.maxFrameBytes ?? DEFAULT_TERMINAL_BRIDGE_MAX_FRAME_BYTES;\n\t\tthis.maxBufferedBytes = options.maxBufferedBytes ?? DEFAULT_TERMINAL_BRIDGE_MAX_BUFFERED_BYTES;\n\t\tthis.maxClients = options.maxClients ?? DEFAULT_TERMINAL_BRIDGE_MAX_CLIENTS;\n\t}\n\n\tgetDescriptor() {\n\t\treturn this.descriptor;\n\t}\n\n\tgetSidecarResult() {\n\t\treturn this.sidecarResult;\n\t}\n\n\tstart(context: ExtensionContext) {\n\t\tconst task = this.lifecycleQueue.then(() => this.startServer(context));\n\t\tthis.lifecycleQueue = task.catch(() => {});\n\t\treturn task;\n\t}\n\n\tprivate async startServer(context: ExtensionContext) {\n\t\tif (context.mode !== \"tui\" && !this.options.webOwned) {\n\t\t\tawait this.stopServer();\n\t\t\treturn;\n\t\t}\n\t\tthis.context = context;\n\t\tif (process.platform === \"win32\") throw new Error(\"The SP terminal bridge requires Unix sockets.\");\n\t\tif (this.server) {\n\t\t\tthis.advanceGeneration(context);\n\t\t\tthis.options.onReady?.(this);\n\t\t\treturn;\n\t\t}\n\n\t\tconst agentDir = this.options.agentDir ?? getSpAgentDir();\n\t\tthis.paths = makePaths(agentDir, this.instanceId);\n\t\tensurePrivateDirectory(this.paths.rootDir);\n\t\tensurePrivateDirectory(this.paths.discoveryDir);\n\t\tensurePrivateDirectory(this.paths.socketDir);\n\t\tif (Buffer.byteLength(this.paths.socketPath, \"utf8\") > 103) {\n\t\t\tthrow new Error(`SP terminal bridge socket path is too long: ${this.paths.socketPath}`);\n\t\t}\n\t\tremoveFile(this.paths.socketPath);\n\n\t\tconst server = createServer((socket) => this.acceptClient(socket));\n\t\tserver.on(\"error\", () => {});\n\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\tconst onError = (error: Error) => {\n\t\t\t\tserver.off(\"listening\", onListening);\n\t\t\t\treject(error);\n\t\t\t};\n\t\t\tconst onListening = () => {\n\t\t\t\tserver.off(\"error\", onError);\n\t\t\t\tresolve();\n\t\t\t};\n\t\t\tserver.once(\"error\", onError);\n\t\t\tserver.once(\"listening\", onListening);\n\t\t\tserver.listen(this.paths?.socketPath);\n\t\t});\n\t\tthis.server = server;\n\t\ttry {\n\t\t\tchmodSync(this.paths.socketPath, 0o600);\n\t\t\tthis.advanceGeneration(context);\n\t\t\tthis.unsubscribeQuestions = this.questions.subscribe((event, ctx) => this.publish(\"interaction\", event, ctx));\n\t\t\tthis.options.onReady?.(this);\n\t\t\tif (this.options.launchSpWeb !== false) {\n\t\t\t\tthis.sidecarResult = ensureSpWebSidecar(this.paths.discoveryDir, this.options.launcherOptions);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tawait this.stopServer();\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\tupdateContext(context: ExtensionContext) {\n\t\tthis.context = context;\n\t}\n\n\tadvanceGeneration(context: ExtensionContext) {\n\t\tthis.binding.abort();\n\t\tthis.binding = new AbortController();\n\t\tthis.questions.cancel();\n\t\tthis.context = context;\n\t\tif (!this.paths) return;\n\t\tthis.generation += 1;\n\t\tthis.commandQueue = Promise.resolve();\n\t\tthis.sequence = 0;\n\t\tthis.eventHistory.splice(0);\n\t\tthis.eventHistoryBytes = 0;\n\t\tthis.lastUnreplayableSequence = 0;\n\t\tfor (const client of this.clients) client.socket.destroy();\n\t\tthis.descriptor = {\n\t\t\tversion: TERMINAL_BRIDGE_VERSION,\n\t\t\tprotocol: TERMINAL_BRIDGE_PROTOCOL,\n\t\t\tinstanceId: this.instanceId,\n\t\t\tpid: process.pid,\n\t\t\tsessionId: context.sessionManager.getSessionId(),\n\t\t\tsessionFile: context.sessionManager.getSessionFile(),\n\t\t\tcwd: context.cwd,\n\t\t\tsocketPath: this.paths.socketPath,\n\t\t\tauthToken: this.authToken,\n\t\t\tgeneration: this.generation,\n\t\t\tstartedAt: this.startedAt,\n\t\t\t...(this.options.writerLeaseVersion ? { writerLeaseVersion: this.options.writerLeaseVersion } : {}),\n\t\t\tframe: { encoding: \"newline-delimited-json\", maxBytes: this.maxFrameBytes },\n\t\t};\n\t\tconst temporaryPath = `${this.paths.descriptorPath}.${process.pid}.tmp`;\n\t\twriteFileSync(temporaryPath, `${JSON.stringify(this.descriptor, null, \"\\t\")}\\n`, {\n\t\t\tencoding: \"utf8\",\n\t\t\tmode: 0o600,\n\t\t});\n\t\tchmodSync(temporaryPath, 0o600);\n\t\trenameSync(temporaryPath, this.paths.descriptorPath);\n\t\tchmodSync(this.paths.descriptorPath, 0o600);\n\t}\n\n\tpublish(category: EventFrame[\"category\"], event: { type: string }, context: ExtensionContext) {\n\t\tthis.context = context;\n\t\tif (!this.server) return;\n\t\tconst frame: EventFrame = {\n\t\t\tv: TERMINAL_BRIDGE_VERSION,\n\t\t\ttype: \"event\",\n\t\t\tgeneration: this.generation,\n\t\t\tsequence: ++this.sequence,\n\t\t\tevent: event.type,\n\t\t\tcategory,\n\t\t\tpayload: normalizeForJson(event),\n\t\t};\n\t\tlet encoded: Buffer;\n\t\ttry {\n\t\t\tencoded = this.encode(frame);\n\t\t} catch {\n\t\t\t// Never claim that replay can cross an event we could not forward.\n\t\t\tthis.lastUnreplayableSequence = frame.sequence;\n\t\t\tframe.event = \"event_gap\";\n\t\t\tframe.category = \"session\";\n\t\t\tframe.payload = { type: \"event_gap\", code: \"event_gap\", resyncRequired: true };\n\t\t\ttry {\n\t\t\t\tencoded = this.encode(frame);\n\t\t\t} catch {\n\t\t\t\tfor (const client of this.clients) {\n\t\t\t\t\tif (client.subscribed) client.socket.destroy();\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tthis.eventHistory.push({ sequence: frame.sequence, encoded });\n\t\tthis.eventHistoryBytes += encoded.length;\n\t\twhile (this.eventHistory.length > MAX_EVENT_HISTORY || this.eventHistoryBytes > MAX_EVENT_HISTORY_BYTES) {\n\t\t\tthis.eventHistoryBytes -= this.eventHistory.shift()!.encoded.length;\n\t\t}\n\t\tfor (const client of this.clients) {\n\t\t\tif (client.authenticated && client.subscribed) {\n\t\t\t\tthis.writeEncoded(client, encoded);\n\t\t\t\tif (frame.event === \"event_gap\") {\n\t\t\t\t\tclient.subscribed = false;\n\t\t\t\t\t// Older clients also recover through their connection-close snapshot path.\n\t\t\t\t\tclient.socket.end();\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tstop() {\n\t\t// The native delete response must drain before session_shutdown closes its socket.\n\t\tif (this.deletionOwner) return Promise.resolve();\n\t\t// Invalidate commands immediately, even if socket startup has not settled.\n\t\tthis.questions.cancel();\n\t\tthis.binding.abort();\n\t\tthis.context = undefined;\n\t\tfor (const client of this.clients) client.socket.destroy();\n\t\tconst task = this.lifecycleQueue.then(() => this.stopServer());\n\t\tthis.lifecycleQueue = task.catch(() => {});\n\t\treturn task;\n\t}\n\n\t/** Synchronous fallback for process.exit, which cannot await socket shutdown. */\n\tcleanupOnExit() {\n\t\tthis.binding.abort();\n\t\tthis.questions.cancel();\n\t\tfor (const client of this.clients) client.socket.destroy();\n\t\tif (this.paths) {\n\t\t\ttry {\n\t\t\t\tconst value = JSON.parse(readFileSync(this.paths.descriptorPath, \"utf8\")) as unknown;\n\t\t\t\tif (isRecord(value) && value.instanceId === this.instanceId) removeFile(this.paths.descriptorPath);\n\t\t\t} catch (error) {\n\t\t\t\tif (!isMissingFileError(error) && !(error instanceof SyntaxError)) throw error;\n\t\t\t}\n\t\t\tremoveFile(this.paths.socketPath);\n\t\t}\n\t}\n\n\tprivate async stopServer() {\n\t\tthis.binding.abort();\n\t\tthis.questions.cancel();\n\t\tthis.context = undefined;\n\t\tthis.commandQueue = Promise.resolve();\n\t\tthis.unsubscribeQuestions?.();\n\t\tthis.unsubscribeQuestions = undefined;\n\t\tconst server = this.server;\n\t\tthis.server = undefined;\n\t\tfor (const client of this.clients) client.socket.destroy();\n\t\tthis.clients.clear();\n\t\tthis.eventHistory.splice(0);\n\t\tthis.eventHistoryBytes = 0;\n\t\tif (server) {\n\t\t\tawait new Promise<void>((resolve) => server.close(() => resolve()));\n\t\t}\n\t\tconst paths = this.paths;\n\t\tif (paths) {\n\t\t\ttry {\n\t\t\t\tconst value = JSON.parse(readFileSync(paths.descriptorPath, \"utf8\")) as unknown;\n\t\t\t\tif (isRecord(value) && value.instanceId === this.instanceId) removeFile(paths.descriptorPath);\n\t\t\t} catch (error) {\n\t\t\t\tif (!isMissingFileError(error) && !(error instanceof SyntaxError)) throw error;\n\t\t\t}\n\t\t\tremoveFile(paths.socketPath);\n\t\t}\n\t\tthis.descriptor = undefined;\n\t\tthis.options.sessions?.bridgeStopped?.(this);\n\t}\n\n\tprivate acceptClient(socket: Socket) {\n\t\tif (this.clients.size >= this.maxClients) {\n\t\t\tsocket.on(\"error\", () => {});\n\t\t\tsocket.end(`${JSON.stringify({ v: 1, type: \"error\", code: \"too_many_clients\" })}\\n`, () => socket.destroy());\n\t\t\treturn;\n\t\t}\n\t\tsocket.setNoDelay(true);\n\t\tconst client: ClientState = {\n\t\t\tsocket,\n\t\t\tbuffer: Buffer.alloc(0),\n\t\t\tauthenticated: false,\n\t\t\tsubscribed: false,\n\t\t\tpendingRequests: 0,\n\t\t\tseenRequestIds: new Set(),\n\t\t};\n\t\tthis.clients.add(client);\n\t\tsocket.on(\"data\", (chunk: Buffer) => this.receive(client, chunk));\n\t\tsocket.on(\"error\", () => {});\n\t\tsocket.on(\"close\", () => this.clients.delete(client));\n\t}\n\n\tprivate receive(client: ClientState, chunk: Buffer) {\n\t\tif (client.socket.destroyed || client.socket.writableEnded) return;\n\t\tclient.buffer = Buffer.concat([client.buffer, chunk]);\n\t\tif (client.buffer.length > this.maxFrameBytes && client.buffer.indexOf(0x0a) === -1) {\n\t\t\tclient.socket.destroy();\n\t\t\treturn;\n\t\t}\n\t\tlet newline = client.buffer.indexOf(0x0a);\n\t\twhile (newline !== -1) {\n\t\t\tconst frame = client.buffer.subarray(0, newline);\n\t\t\tclient.buffer = client.buffer.subarray(newline + 1);\n\t\t\tif (frame.length + 1 > this.maxFrameBytes || frame.length === 0) {\n\t\t\t\tclient.socket.destroy();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tlet value: unknown;\n\t\t\ttry {\n\t\t\t\tvalue = JSON.parse(frame.toString(\"utf8\")) as unknown;\n\t\t\t} catch {\n\t\t\t\tclient.socket.destroy();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!isRecord(value)) {\n\t\t\t\tclient.socket.destroy();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!client.authenticated) {\n\t\t\t\tthis.authenticate(client, value);\n\t\t\t\tif (!client.authenticated) return;\n\t\t\t} else {\n\t\t\t\tthis.acceptRequest(client, value);\n\t\t\t}\n\t\t\tnewline = client.buffer.indexOf(0x0a);\n\t\t}\n\t\tif (client.buffer.length >= this.maxFrameBytes) client.socket.destroy();\n\t}\n\n\tprivate authenticate(client: ClientState, value: JsonRecord) {\n\t\tif (value.v !== TERMINAL_BRIDGE_VERSION || value.type !== \"hello\" || !tokenMatches(this.authToken, value.token)) {\n\t\t\tthis.send(client, { v: 1, type: \"hello\", ok: false, error: \"authentication_failed\" });\n\t\t\tclient.socket.end();\n\t\t\treturn;\n\t\t}\n\t\tclient.authenticated = true;\n\t\tthis.send(client, {\n\t\t\tv: TERMINAL_BRIDGE_VERSION,\n\t\t\ttype: \"hello\",\n\t\t\tok: true,\n\t\t\tprotocol: TERMINAL_BRIDGE_PROTOCOL,\n\t\t\tgeneration: this.generation,\n\t\t\tsequence: this.sequence,\n\t\t\tinstanceId: this.instanceId,\n\t\t});\n\t}\n\n\tprivate acceptRequest(client: ClientState, value: JsonRecord) {\n\t\tlet request: RequestFrame;\n\t\ttry {\n\t\t\trequest = parseRequest(value);\n\t\t} catch (error) {\n\t\t\tthis.sendRequestError(client, typeof value.id === \"string\" ? value.id : null, error);\n\t\t\treturn;\n\t\t}\n\t\tif (client.seenRequestIds.has(request.id)) {\n\t\t\tthis.sendRequestError(\n\t\t\t\tclient,\n\t\t\t\trequest.id,\n\t\t\t\tnew RequestError(\"duplicate_request\", \"Request ID was already used.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tclient.seenRequestIds.add(request.id);\n\t\tif (client.seenRequestIds.size > MAX_SEEN_REQUEST_IDS) {\n\t\t\tconst oldest = client.seenRequestIds.values().next().value as string | undefined;\n\t\t\tif (oldest) client.seenRequestIds.delete(oldest);\n\t\t}\n\t\tif (client.pendingRequests >= MAX_PENDING_REQUESTS) {\n\t\t\tthis.sendRequestError(client, request.id, new RequestError(\"too_many_requests\", \"Too many pending requests.\"));\n\t\t\treturn;\n\t\t}\n\t\tclient.pendingRequests += 1;\n\t\tthis.pendingCommands++;\n\t\t// Reads and turn cancellation/answers must not wait for model auth or title inference.\n\t\tconst independent = [\n\t\t\t\"state\",\n\t\t\t\"snapshot\",\n\t\t\t\"transcript\",\n\t\t\t\"models\",\n\t\t\t\"subscribe\",\n\t\t\t\"respond_to_question\",\n\t\t\t\"abort\",\n\t\t\t\"create_session\",\n\t\t\t\"resume_session\",\n\t\t\t\"delete_session\",\n\t\t].includes(request.command);\n\t\tconst task = (independent ? Promise.resolve() : this.commandQueue)\n\t\t\t.then(async () => {\n\t\t\t\tconst result = await this.requestContext.run({ request, client }, () => this.execute(request, client));\n\t\t\t\tif (request.command === \"delete_session\") {\n\t\t\t\t\tawait this.writeReplay(client, this.encode(this.successFrame(request.id, result)));\n\t\t\t\t} else {\n\t\t\t\t\tthis.sendSuccess(client, request.id, result);\n\t\t\t\t}\n\t\t\t})\n\t\t\t.catch(async (error: unknown) => {\n\t\t\t\tthis.sendRequestError(client, request.id, error);\n\t\t\t\tif (request.command === \"delete_session\") {\n\t\t\t\t\t// Drain error acknowledgements as well when disposal succeeded but unlink failed.\n\t\t\t\t\tawait this.writeReplay(client, Buffer.alloc(0)).catch(() => {});\n\t\t\t\t}\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tclient.pendingRequests -= 1;\n\t\t\t\tthis.pendingCommands--;\n\t\t\t\tif (this.deletionOwner?.request === request && this.deletionOwner.client === client) {\n\t\t\t\t\tvoid this.finishDeletion().catch(() => {});\n\t\t\t\t}\n\t\t\t});\n\t\tif (!independent) this.commandQueue = task;\n\t}\n\n\tprivate async execute(request: RequestFrame, client: ClientState): Promise<unknown> {\n\t\tif (!this.server || client.socket.destroyed || client.socket.writableEnded) {\n\t\t\tthrow new RequestError(\"not_ready\", \"Connection is closed.\");\n\t\t}\n\t\tif (request.generation !== this.generation) {\n\t\t\tthrow new RequestError(\"stale_generation\", `Current generation is ${this.generation}.`);\n\t\t}\n\t\tconst context = this.context;\n\t\tif (!context) throw new RequestError(\"not_ready\", \"Session context is not ready.\");\n\t\tif (this.deleting) throw new RequestError(\"busy\", \"Session deletion is reserved.\");\n\t\tconst params = request.params ?? {};\n\t\tswitch (request.command) {\n\t\t\tcase \"delete_session\":\n\t\t\t\tif (!this.options.sessions?.deleteSession) {\n\t\t\t\t\tthrow new RequestError(\"unsupported_command\", \"No deletion provider available.\");\n\t\t\t\t}\n\t\t\t\tif (params.confirmed !== true) throw new RequestError(\"invalid_params\", \"confirmed must be true.\");\n\t\t\t\treturn this.options.sessions.deleteSession(\n\t\t\t\t\t{\n\t\t\t\t\t\tsessionId: requireString(params.sessionId, \"sessionId\", 256),\n\t\t\t\t\t\t...(params.sessionFile !== undefined\n\t\t\t\t\t\t\t? { sessionFile: requireString(params.sessionFile, \"sessionFile\", 4096) }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\tconfirmed: true,\n\t\t\t\t\t},\n\t\t\t\t\tthis,\n\t\t\t\t);\n\t\t\tcase \"create_session\":\n\t\t\t\tif (!this.options.sessions) throw new RequestError(\"unsupported_command\", \"No session factory available.\");\n\t\t\t\treturn this.options.sessions.create(requireString(params.cwd, \"cwd\", 4096));\n\t\t\tcase \"resume_session\":\n\t\t\t\tif (!this.options.sessions) throw new RequestError(\"unsupported_command\", \"No session factory available.\");\n\t\t\t\treturn this.options.sessions.resume(requireString(params.sessionFile, \"sessionFile\", 4096));\n\t\t\tcase \"respond_to_question\":\n\t\t\t\tthis.questions.respond(requireString(params.interactionId, \"interactionId\", 128), params.response);\n\t\t\t\treturn { accepted: true };\n\t\t\tcase \"snapshot\": {\n\t\t\t\tconst entries = context.sessionManager.getEntries();\n\t\t\t\tconst state = this.stateSnapshot(context);\n\t\t\t\treturn this.transcriptPage(request.id, entries, 0, MAX_TRANSCRIPT_PAGE_SIZE, state);\n\t\t\t}\n\t\t\tcase \"state\":\n\t\t\t\treturn this.stateSnapshot(context);\n\t\t\tcase \"transcript\": {\n\t\t\t\tconst entries = context.sessionManager.getEntries();\n\t\t\t\tconst cursor = optionalInteger(params.cursor, \"cursor\", 0);\n\t\t\t\tconst limit = Math.min(optionalInteger(params.limit, \"limit\", 50), MAX_TRANSCRIPT_PAGE_SIZE);\n\t\t\t\treturn this.transcriptPage(request.id, entries, cursor, limit);\n\t\t\t}\n\t\t\tcase \"models\":\n\t\t\t\treturn {\n\t\t\t\t\tmodels: availableModels(context),\n\t\t\t\t\tcurrent: context.model ? { provider: context.model.provider, modelId: context.model.id } : null,\n\t\t\t\t};\n\t\t\tcase \"generate_title\":\n\t\t\t\treturn (this.options.titleGenerator ?? generateTitle)(context, this.pi, this.binding.signal);\n\t\t\tcase \"subscribe\": {\n\t\t\t\tlet afterSequence = optionalInteger(params.afterSequence, \"afterSequence\", this.sequence);\n\t\t\t\tclient.subscribed = false;\n\t\t\t\tfor (;;) {\n\t\t\t\t\t// Recheck after each drained write: publishing can evict history while replay waits.\n\t\t\t\t\tconst earliestSequence = this.eventHistory[0]?.sequence ?? this.sequence + 1;\n\t\t\t\t\tif (\n\t\t\t\t\t\tafterSequence < earliestSequence - 1 ||\n\t\t\t\t\t\tafterSequence < this.lastUnreplayableSequence ||\n\t\t\t\t\t\tafterSequence > this.sequence\n\t\t\t\t\t) {\n\t\t\t\t\t\tthrow new RequestError(\"event_gap\", `Earliest retained sequence is ${earliestSequence}.`);\n\t\t\t\t\t}\n\t\t\t\t\tconst event = this.eventHistory.find((entry) => entry.sequence > afterSequence);\n\t\t\t\t\tif (!event) break;\n\t\t\t\t\tawait this.writeReplay(client, event.encoded);\n\t\t\t\t\tafterSequence = event.sequence;\n\t\t\t\t}\n\t\t\t\tclient.subscribed = true;\n\t\t\t\treturn { subscribed: true, sequence: this.sequence };\n\t\t\t}\n\t\t\tcase \"prompt\":\n\t\t\t\tif (!context.isIdle()) throw new RequestError(\"busy\", \"Use steer or follow_up while the agent is running.\");\n\t\t\t\tthis.pi.sendUserMessage(userMessageContent(params), {\n\t\t\t\t\texpandPromptTemplates: params.expandPromptTemplates === true,\n\t\t\t\t});\n\t\t\t\treturn { accepted: true };\n\t\t\tcase \"steer\":\n\t\t\t\tif (context.isIdle()) throw new RequestError(\"idle\", \"There is no running turn to steer.\");\n\t\t\t\tthis.pi.sendUserMessage(userMessageContent(params), { deliverAs: \"steer\" });\n\t\t\t\treturn { accepted: true };\n\t\t\tcase \"follow_up\":\n\t\t\t\tif (context.isIdle()) throw new RequestError(\"idle\", \"There is no running turn to queue a follow-up for.\");\n\t\t\t\tthis.pi.sendUserMessage(userMessageContent(params), { deliverAs: \"followUp\" });\n\t\t\t\treturn { accepted: true };\n\t\t\tcase \"abort\":\n\t\t\t\tif (context.isIdle()) throw new RequestError(\"idle\", \"There is no running turn to abort.\");\n\t\t\t\tthis.questions.cancel();\n\t\t\t\tcontext.abort();\n\t\t\t\treturn { accepted: true };\n\t\t\tcase \"set_thinking\": {\n\t\t\t\tif (!context.isIdle()) throw new RequestError(\"busy\", \"Thinking level can only change while idle.\");\n\t\t\t\tconst level = requireString(params.level, \"level\", 16);\n\t\t\t\tif (!THINKING_LEVELS.includes(level as ThinkingLevel)) {\n\t\t\t\t\tthrow new RequestError(\"invalid_params\", `Unsupported thinking level: ${level}`);\n\t\t\t\t}\n\t\t\t\tthis.pi.setThinkingLevel(level as ThinkingLevel);\n\t\t\t\treturn { accepted: true, level: this.pi.getThinkingLevel() };\n\t\t\t}\n\t\t\tcase \"set_model\": {\n\t\t\t\tif (!context.isIdle()) throw new RequestError(\"busy\", \"Model can only change while idle.\");\n\t\t\t\tconst provider = requireString(params.provider, \"provider\", 256);\n\t\t\t\tconst modelId = requireString(params.modelId, \"modelId\", 512);\n\t\t\t\tif (provider !== \"sp-gateway\") {\n\t\t\t\t\tthrow new RequestError(\"model_out_of_scope\", \"Only sp-gateway models are available in SP.\");\n\t\t\t\t}\n\t\t\t\tconst model = context.modelRegistry.find(provider, modelId);\n\t\t\t\tif (!model) throw new RequestError(\"model_unavailable\", `Model is not available: ${provider}/${modelId}`);\n\t\t\t\tif (\n\t\t\t\t\tcontext.scopedModels.length > 0 &&\n\t\t\t\t\t!context.scopedModels.some((entry) => entry.model.provider === provider && entry.model.id === modelId)\n\t\t\t\t) {\n\t\t\t\t\tthrow new RequestError(\n\t\t\t\t\t\t\"model_out_of_scope\",\n\t\t\t\t\t\t`Model is outside this session's model scope: ${provider}/${modelId}`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tif (!(await this.pi.setModel(model))) {\n\t\t\t\t\tthrow new RequestError(\"model_auth_unavailable\", `No usable credentials for ${provider}/${modelId}.`);\n\t\t\t\t}\n\t\t\t\treturn {\n\t\t\t\t\taccepted: true,\n\t\t\t\t\tmodel: { provider: model.provider, id: model.id, name: model.name, input: model.input },\n\t\t\t\t};\n\t\t\t}\n\t\t\tdefault:\n\t\t\t\tthrow new RequestError(\"unsupported_command\", `Unsupported command: ${request.command}`);\n\t\t}\n\t}\n\n\tprivate stateSnapshot(context: ExtensionContext) {\n\t\tconst model = context.model;\n\t\treturn {\n\t\t\tprotocol: TERMINAL_BRIDGE_PROTOCOL,\n\t\t\tgeneration: this.generation,\n\t\t\tsequence: this.sequence,\n\t\t\tsession: {\n\t\t\t\tid: context.sessionManager.getSessionId(),\n\t\t\t\tfile: context.sessionManager.getSessionFile(),\n\t\t\t\tname: context.sessionManager.getSessionName(),\n\t\t\t\tcwd: context.cwd,\n\t\t\t\tentryCount: context.sessionManager.getEntries().length,\n\t\t\t\tactiveLeafId: context.sessionManager.getLeafId(),\n\t\t\t},\n\t\t\tstatus: {\n\t\t\t\tidle: context.isIdle(),\n\t\t\t\thasPendingMessages: context.hasPendingMessages(),\n\t\t\t\tcontextUsage: context.getContextUsage(),\n\t\t\t},\n\t\t\tmodel: model ? { provider: model.provider, id: model.id, name: model.name, input: model.input } : null,\n\t\t\tthinkingLevel: this.pi.getThinkingLevel(),\n\t\t\tpendingQuestion: this.questions.getPending(),\n\t\t\tcapabilities: {\n\t\t\t\tsnapshot: true,\n\t\t\t\tstate: true,\n\t\t\t\ttranscript: { paginated: true, maximumPageSize: MAX_TRANSCRIPT_PAGE_SIZE },\n\t\t\t\teventStream: { replayLimit: MAX_EVENT_HISTORY, replayByteLimit: MAX_EVENT_HISTORY_BYTES },\n\t\t\t\tprompt: true,\n\t\t\t\timages: true,\n\t\t\t\timageLimits: TERMINAL_BRIDGE_IMAGE_LIMITS,\n\t\t\t\tsteer: true,\n\t\t\t\tfollowUp: true,\n\t\t\t\tabort: true,\n\t\t\t\tsetThinking: true,\n\t\t\t\tsetModel: \"available-and-session-scoped-models-only\",\n\t\t\t\tmodels: true,\n\t\t\t\tgenerateTitle: true,\n\t\t\t\trespondToQuestion: true,\n\t\t\t\tnewSession: !!this.options.sessions,\n\t\t\t\tresumeSession: !!this.options.sessions,\n\t\t\t\tforkSession: false,\n\t\t\t\tdeleteSession: !!this.options.sessions?.deleteSession,\n\t\t\t\tdirectJsonlAccess: false,\n\t\t\t},\n\t\t};\n\t}\n\n\tprivate transcriptPage(id: string, entries: unknown[], cursor: number, limit: number, state?: unknown) {\n\t\tconst page = {\n\t\t\tcursor,\n\t\t\tnextCursor: (cursor < entries.length ? cursor : null) as number | null,\n\t\t\ttotal: entries.length,\n\t\t\tentries: [] as unknown[],\n\t\t};\n\t\tconst result = state === undefined ? page : { state, transcript: page };\n\t\t// Count the exact envelope, including escaped request ID, state and newline.\n\t\tlet bytes = this.encode(this.successFrame(id, result)).length;\n\t\tfor (const entry of entries.slice(cursor, cursor + limit)) {\n\t\t\tconst normalized = normalizeForJson(entry);\n\t\t\tconst nextCursor = cursor + page.entries.length + 1 < entries.length ? cursor + page.entries.length + 1 : null;\n\t\t\tconst addedBytes =\n\t\t\t\tBuffer.byteLength(JSON.stringify(normalized), \"utf8\") +\n\t\t\t\t(page.entries.length > 0 ? 1 : 0) +\n\t\t\t\tJSON.stringify(nextCursor).length -\n\t\t\t\tJSON.stringify(page.nextCursor).length;\n\t\t\tif (bytes + addedBytes > this.maxFrameBytes) {\n\t\t\t\tif (page.entries.length === 0) {\n\t\t\t\t\tthrow new RequestError(\n\t\t\t\t\t\t\"frame_too_large\",\n\t\t\t\t\t\t`Transcript entry at cursor ${cursor} cannot fit in the response.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tpage.entries.push(normalized);\n\t\t\tpage.nextCursor = nextCursor;\n\t\t\tbytes += addedBytes;\n\t\t}\n\t\treturn result;\n\t}\n\n\tprivate sendRequestError(client: ClientState, id: string | null, error: unknown) {\n\t\tconst code =\n\t\t\terror instanceof RequestError || error instanceof QuestionResponseError\n\t\t\t\t? error.code\n\t\t\t\t: error instanceof Error && \"code\" in error && typeof error.code === \"string\"\n\t\t\t\t\t? error.code\n\t\t\t\t\t: \"internal_error\";\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tthis.send(client, {\n\t\t\tv: TERMINAL_BRIDGE_VERSION,\n\t\t\ttype: \"response\",\n\t\t\tid,\n\t\t\tgeneration: this.generation,\n\t\t\tok: false,\n\t\t\terror: { code, message },\n\t\t});\n\t}\n\n\tprivate successFrame(id: string, result: unknown) {\n\t\treturn {\n\t\t\tv: TERMINAL_BRIDGE_VERSION,\n\t\t\ttype: \"response\",\n\t\t\tid,\n\t\t\tgeneration: this.generation,\n\t\t\tok: true,\n\t\t\tresult,\n\t\t};\n\t}\n\n\tprivate sendSuccess(client: ClientState, id: string, result: unknown) {\n\t\ttry {\n\t\t\tthis.writeEncoded(client, this.encode(this.successFrame(id, result)));\n\t\t} catch (error) {\n\t\t\tthis.sendRequestError(client, id, error);\n\t\t}\n\t}\n\n\tprivate encode(value: unknown) {\n\t\tconst encoded = Buffer.from(`${JSON.stringify(value)}\\n`, \"utf8\");\n\t\tif (encoded.length > this.maxFrameBytes) throw new RequestError(\"frame_too_large\", \"Frame exceeds the limit.\");\n\t\treturn encoded;\n\t}\n\n\tprivate send(client: ClientState, value: unknown) {\n\t\tif (client.socket.destroyed) return;\n\t\ttry {\n\t\t\tthis.writeEncoded(client, this.encode(value));\n\t\t} catch {\n\t\t\tclient.socket.destroy();\n\t\t}\n\t}\n\n\tprivate writeEncoded(client: ClientState, encoded: Buffer) {\n\t\tif (client.socket.destroyed) return;\n\t\tif (client.socket.writableLength + encoded.length > this.maxBufferedBytes) {\n\t\t\tclient.socket.destroy();\n\t\t\treturn;\n\t\t}\n\t\tclient.socket.write(encoded);\n\t}\n\n\tprivate writeReplay(client: ClientState, encoded: Buffer) {\n\t\treturn new Promise<void>((resolve, reject) => {\n\t\t\tconst socket = client.socket;\n\t\t\tif (\n\t\t\t\tsocket.destroyed ||\n\t\t\t\tsocket.writableEnded ||\n\t\t\t\tsocket.writableLength + encoded.length > this.maxBufferedBytes\n\t\t\t) {\n\t\t\t\tsocket.destroy();\n\t\t\t\treject(new RequestError(\"not_ready\", \"Replay connection is closed or exceeds the output limit.\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst onClose = () => reject(new RequestError(\"not_ready\", \"Replay connection is closed.\"));\n\t\t\tsocket.once(\"close\", onClose);\n\t\t\t// Drain one frame before enqueueing the next, rather than overflowing the client\n\t\t\t// buffer with an otherwise valid multi-image replay.\n\t\t\tsocket.write(encoded, (error) => {\n\t\t\t\tsocket.off(\"close\", onClose);\n\t\t\t\tif (error) reject(error);\n\t\t\t\telse resolve();\n\t\t\t});\n\t\t});\n\t}\n}\n\nexport function registerTerminalWebBridge(pi: ExtensionAPI, options: TerminalWebBridgeOptions = {}) {\n\tconst bridge = new TerminalWebBridge(pi, options);\n\tpi.on(\"session_start\", async (event, context) => {\n\t\ttry {\n\t\t\tif (context.mode === \"tui\" && !options.webOwned) options.sessions?.terminalStarted(context);\n\t\t\tawait bridge.start(context);\n\t\t\tbridge.publish(\"session\", event, context);\n\t\t\tconst sidecar = bridge.getSidecarResult();\n\t\t\tif (sidecar?.status === \"failed\") context.ui.notify(`SP Web sidecar failed: ${sidecar.error}`, \"warning\");\n\t\t} catch (error) {\n\t\t\tcontext.ui.notify(\n\t\t\t\t`SP terminal bridge failed: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t\"warning\",\n\t\t\t);\n\t\t}\n\t});\n\tpi.on(\"session_info_changed\", (event, context) => bridge.publish(\"session\", event, context));\n\tpi.on(\"session_compact\", (event, context) => bridge.publish(\"session\", event, context));\n\tpi.on(\"session_compact_failed\", (event, context) => bridge.publish(\"session\", event, context));\n\tpi.on(\"session_tree\", (event, context) => bridge.publish(\"session\", event, context));\n\tpi.on(\"agent_start\", (event, context) => bridge.publish(\"agent\", event, context));\n\tpi.on(\"agent_end\", (event, context) => bridge.publish(\"agent\", event, context));\n\tpi.on(\"agent_settled\", (event, context) => bridge.publish(\"agent\", event, context));\n\tpi.on(\"message_start\", (event, context) => bridge.publish(\"message\", event, context));\n\tpi.on(\"message_update\", (event, context) => bridge.publish(\"message\", event, context));\n\tpi.on(\"message_end\", (event, context) => bridge.publish(\"message\", event, context));\n\tpi.on(\"tool_execution_start\", (event, context) => bridge.publish(\"tool\", event, context));\n\tpi.on(\"tool_execution_update\", (event, context) => bridge.publish(\"tool\", event, context));\n\tpi.on(\"tool_execution_end\", (event, context) => bridge.publish(\"tool\", event, context));\n\tpi.on(\"model_select\", (event, context) => bridge.publish(\"model\", event, context));\n\tpi.on(\"thinking_level_select\", (event, context) => bridge.publish(\"thinking\", event, context));\n\tpi.on(\"session_shutdown\", async (event, context) => {\n\t\tbridge.publish(\"session\", event, context);\n\t\ttry {\n\t\t\tawait bridge.stop();\n\t\t\tif (event.reason === \"quit\" && !options.webOwned) await options.sessions?.dispose();\n\t\t} catch {\n\t\t\t// Cleanup failures must not stop SP shutdown.\n\t\t}\n\t});\n\treturn bridge;\n}\n\nexport function readTerminalBridgeDescriptor(path: string): TerminalBridgeDescriptor {\n\tconst value = JSON.parse(readFileSync(path, \"utf8\")) as TerminalBridgeDescriptor;\n\treturn value;\n}\n\nexport function terminalBridgeDescriptorExists(path: string) {\n\treturn existsSync(path);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"terminal-web-bridge.d.ts","sourceRoot":"","sources":["../src/terminal-web-bridge.ts"],"names":[],"mappings":"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","sourcesContent":["import { AsyncLocalStorage } from \"node:async_hooks\";\nimport { randomBytes, randomUUID, timingSafeEqual } from \"node:crypto\";\nimport { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from \"node:fs\";\nimport { createServer, type Server, type Socket } from \"node:net\";\nimport { join } from \"node:path\";\nimport type { ExtensionAPI, ExtensionContext } from \"@senpiper/pi-coding-agent\";\nimport { completeSimple } from \"@senpiper/pi-coding-agent/runtime-deps\";\nimport { getSpAgentDir } from \"./gateway.ts\";\nimport { getQuestionInteraction, type QuestionInteraction, QuestionResponseError } from \"./question-interaction.ts\";\nimport { ensureSpWebSidecar, type SpWebLauncherOptions, type SpWebLaunchResult } from \"./sp-web-launcher.ts\";\n\nexport const TERMINAL_BRIDGE_PROTOCOL = \"sp-terminal-bridge-v1\";\nexport const TERMINAL_BRIDGE_VERSION = 1;\nexport const DEFAULT_TERMINAL_BRIDGE_MAX_FRAME_BYTES = 10 * 1024 * 1024;\nexport const DEFAULT_TERMINAL_BRIDGE_MAX_BUFFERED_BYTES = 10 * 1024 * 1024;\nexport const DEFAULT_TERMINAL_BRIDGE_MAX_CLIENTS = 8;\nexport const TERMINAL_BRIDGE_IMAGE_LIMITS = {\n\tmimeTypes: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\"],\n\tmaxImages: 8,\n\tmaxBase64BytesPerImage: 4 * 1024 * 1024,\n\tmaxBase64BytesTotal: 8 * 1024 * 1024,\n} as const;\n\nconst MAX_REQUEST_ID_LENGTH = 128;\nconst MAX_PENDING_REQUESTS = 32;\nconst MAX_SEEN_REQUEST_IDS = 1024;\nconst MAX_EVENT_HISTORY = 256;\nconst MAX_EVENT_HISTORY_BYTES = 32 * 1024 * 1024;\nconst MAX_TRANSCRIPT_PAGE_SIZE = 100;\nconst MAX_TITLE_CONTEXT_BYTES = 32 * 1024;\nconst MAX_TITLE_LENGTH = 80;\nconst THINKING_LEVELS = [\"off\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const;\n\ntype ThinkingLevel = (typeof THINKING_LEVELS)[number];\ntype JsonRecord = Record<string, unknown>;\n\nexport interface TerminalBridgeDescriptor {\n\tversion: 1;\n\tprotocol: typeof TERMINAL_BRIDGE_PROTOCOL;\n\tinstanceId: string;\n\tpid: number;\n\tsessionId: string;\n\tsessionFile?: string;\n\tcwd: string;\n\tsocketPath: string;\n\tauthToken: string;\n\tgeneration: number;\n\tstartedAt: string;\n\twriterLeaseVersion?: 1;\n\tframe: {\n\t\tencoding: \"newline-delimited-json\";\n\t\tmaxBytes: number;\n\t};\n}\n\nexport interface TerminalWebBridgeOptions {\n\t/** Set only by a distribution that installed the lifetime writer provider before file opening. */\n\twriterLeaseVersion?: 1;\n\t/** Supplied by the verified SP distribution, shared across terminal replacements. */\n\tsessions?: {\n\t\tcreate(cwd: string): Promise<{ sessionId: string }>;\n\t\tresume(sessionFile: string): Promise<{ sessionId: string }>;\n\t\tdeleteSession?(\n\t\t\trequest: { sessionId: string; sessionFile?: string; confirmed: true },\n\t\t\trequester?: TerminalWebBridge,\n\t\t): Promise<{ sessionId: string; deleted: true }>;\n\t\tterminalStarted(context: ExtensionContext): void;\n\t\tbridgeStopped?(bridge: TerminalWebBridge): void;\n\t\tdispose(): Promise<void>;\n\t};\n\twebOwned?: boolean;\n\tonReady?: (bridge: TerminalWebBridge) => void;\n\tagentDir?: string;\n\tmaxFrameBytes?: number;\n\tmaxBufferedBytes?: number;\n\tmaxClients?: number;\n\tlaunchSpWeb?: boolean;\n\tlauncherOptions?: SpWebLauncherOptions;\n\ttitleGenerator?: (context: ExtensionContext, pi: ExtensionAPI, signal: AbortSignal) => Promise<unknown>;\n}\n\ninterface BridgePaths {\n\trootDir: string;\n\tdiscoveryDir: string;\n\tsocketDir: string;\n\tdescriptorPath: string;\n\tsocketPath: string;\n}\n\ninterface ClientState {\n\tsocket: Socket;\n\tbuffer: Buffer;\n\tauthenticated: boolean;\n\tsubscribed: boolean;\n\tpendingRequests: number;\n\tseenRequestIds: Set<string>;\n}\n\ninterface RequestFrame {\n\tv: 1;\n\ttype: \"request\";\n\tid: string;\n\tgeneration: number;\n\tcommand: string;\n\tparams?: JsonRecord;\n}\n\ninterface EventFrame {\n\tv: 1;\n\ttype: \"event\";\n\tgeneration: number;\n\tsequence: number;\n\tevent: string;\n\tcategory: \"message\" | \"agent\" | \"tool\" | \"model\" | \"thinking\" | \"session\" | \"interaction\";\n\tpayload: unknown;\n}\n\nclass RequestError extends Error {\n\treadonly code: string;\n\n\tconstructor(code: string, message: string) {\n\t\tsuper(message);\n\t\tthis.code = code;\n\t}\n}\n\nfunction isRecord(value: unknown): value is JsonRecord {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction isMissingFileError(error: unknown) {\n\treturn isRecord(error) && error.code === \"ENOENT\";\n}\n\nfunction requireString(value: unknown, name: string, maximumLength = 100_000) {\n\tif (typeof value !== \"string\" || !value.trim()) throw new RequestError(\"invalid_params\", `${name} is required.`);\n\tif (Buffer.byteLength(value, \"utf8\") > maximumLength) {\n\t\tthrow new RequestError(\"invalid_params\", `${name} is too long.`);\n\t}\n\treturn value;\n}\n\nfunction userMessageContent(params: JsonRecord): Parameters<ExtensionAPI[\"sendUserMessage\"]>[0] {\n\tconst images = params.images;\n\tif (images === undefined || (Array.isArray(images) && images.length === 0)) {\n\t\treturn requireString(params.text, \"text\");\n\t}\n\tif (!Array.isArray(images) || images.length > TERMINAL_BRIDGE_IMAGE_LIMITS.maxImages) {\n\t\tthrow new RequestError(\"invalid_params\", \"images must be an array of at most 8 images.\");\n\t}\n\tconst content: Exclude<Parameters<ExtensionAPI[\"sendUserMessage\"]>[0], string> = [];\n\tif (params.text !== undefined) {\n\t\tif (typeof params.text !== \"string\" || Buffer.byteLength(params.text, \"utf8\") > 100_000) {\n\t\t\tthrow new RequestError(\"invalid_params\", \"text must be a string of at most 100000 UTF-8 bytes.\");\n\t\t}\n\t\tif (params.text.length > 0) content.push({ type: \"text\", text: params.text });\n\t}\n\tlet total = 0;\n\tfor (const image of images) {\n\t\tif (\n\t\t\t!isRecord(image) ||\n\t\t\tObject.keys(image).some((key) => key !== \"mimeType\" && key !== \"data\") ||\n\t\t\ttypeof image.mimeType !== \"string\" ||\n\t\t\t!TERMINAL_BRIDGE_IMAGE_LIMITS.mimeTypes.some((mimeType) => mimeType === image.mimeType) ||\n\t\t\ttypeof image.data !== \"string\" ||\n\t\t\timage.data.length === 0\n\t\t) {\n\t\t\tthrow new RequestError(\"invalid_params\", \"Each image requires a supported mimeType and base64 data only.\");\n\t\t}\n\t\ttotal += image.data.length;\n\t\tif (\n\t\t\timage.data.length > TERMINAL_BRIDGE_IMAGE_LIMITS.maxBase64BytesPerImage ||\n\t\t\ttotal > TERMINAL_BRIDGE_IMAGE_LIMITS.maxBase64BytesTotal\n\t\t) {\n\t\t\tthrow new RequestError(\"invalid_params\", \"Image base64 exceeds the per-image or aggregate byte limit.\");\n\t\t}\n\t\t// Buffer's decoder alone accepts paths, whitespace, URL-safe and malformed base64.\n\t\tif (/[^A-Za-z0-9+/=]/u.test(image.data) || Buffer.from(image.data, \"base64\").toString(\"base64\") !== image.data) {\n\t\t\tthrow new RequestError(\"invalid_params\", \"Image data must be canonical padded base64, not a path or URL.\");\n\t\t}\n\t\tcontent.push({ type: \"image\", mimeType: image.mimeType, data: image.data });\n\t}\n\treturn content;\n}\n\nfunction optionalInteger(value: unknown, name: string, fallback: number) {\n\tif (value === undefined) return fallback;\n\tif (!Number.isSafeInteger(value) || (value as number) < 0) {\n\t\tthrow new RequestError(\"invalid_params\", `${name} must be a non-negative integer.`);\n\t}\n\treturn value as number;\n}\n\nfunction normalizeForJson(value: unknown, omitImageData = false): unknown {\n\treturn JSON.parse(\n\t\tJSON.stringify(value, (_key, entry: unknown) => {\n\t\t\tif (omitImageData && isRecord(entry) && entry.type === \"image\") {\n\t\t\t\treturn { type: \"image\", mimeType: entry.mimeType };\n\t\t\t}\n\t\t\tif (typeof entry === \"bigint\") return entry.toString();\n\t\t\tif (entry instanceof Error) return { name: entry.name, message: entry.message };\n\t\t\treturn entry;\n\t\t}),\n\t) as unknown;\n}\n\nfunction availableModels(context: ExtensionContext) {\n\tconst scoped = context.scopedModels.map((entry) => entry.model);\n\tconst models = scoped.length > 0 ? scoped : context.modelRegistry.getAvailable();\n\treturn models\n\t\t.filter((model) => model.provider === \"sp-gateway\")\n\t\t.map((model) => ({\n\t\t\tprovider: model.provider,\n\t\t\tid: model.id,\n\t\t\tname: model.name,\n\t\t\tinput: model.input,\n\t\t\treasoning: model.reasoning,\n\t\t\tcontextWindow: model.contextWindow,\n\t\t\tmaxTokens: model.maxTokens,\n\t\t\t...(model.thinkingLevelMap ? { thinkingLevelMap: model.thinkingLevelMap } : {}),\n\t\t}));\n}\n\nfunction parseGeneratedTitle(raw: string) {\n\tlet title = raw.trim().split(/\\r?\\n/u, 1)[0] ?? \"\";\n\ttitle = title.replace(/^(?:session\\s+title|title)\\s*[:-]\\s*/iu, \"\");\n\ttitle = title\n\t\t.replace(/^[\"'`]|[\"'`]$/gu, \"\")\n\t\t.replace(/\\s+/gu, \" \")\n\t\t.replace(/[.!]+$/u, \"\")\n\t\t.trim();\n\tif (!/[\\p{L}\\p{N}]/u.test(title))\n\t\tthrow new RequestError(\"title_generation_failed\", \"The model returned no usable title.\");\n\treturn Array.from(title).slice(0, MAX_TITLE_LENGTH).join(\"\").trim();\n}\n\nasync function generateTitle(context: ExtensionContext, pi: ExtensionAPI, signal: AbortSignal) {\n\tif (!context.isIdle()) throw new RequestError(\"busy\", \"A title can only be generated while the agent is idle.\");\n\tconst activeModel = context.model;\n\tif (!activeModel || activeModel.provider !== \"sp-gateway\") {\n\t\tthrow new RequestError(\"model_unavailable\", \"Title generation requires an active sp-gateway model.\");\n\t}\n\tconst model = context.modelRegistry.find(activeModel.provider, activeModel.id);\n\tif (!model) throw new RequestError(\"model_unavailable\", \"The active sp-gateway model is unavailable.\");\n\tconst auth = await context.modelRegistry.getApiKeyAndHeaders(model);\n\tsignal.throwIfAborted();\n\tif (!auth.ok || !auth.apiKey) {\n\t\tthrow new RequestError(\"model_auth_unavailable\", auth.ok ? \"sp-gateway has no API key available.\" : auth.error);\n\t}\n\tconst serialized = Buffer.from(JSON.stringify(normalizeForJson(context.sessionManager.getEntries(), true)), \"utf8\");\n\tconst transcript =\n\t\tserialized.length <= MAX_TITLE_CONTEXT_BYTES\n\t\t\t? serialized.toString(\"utf8\")\n\t\t\t: serialized.subarray(-MAX_TITLE_CONTEXT_BYTES).toString(\"utf8\");\n\tconst response = await completeSimple(\n\t\tmodel,\n\t\t{\n\t\t\tsystemPrompt: [\n\t\t\t\t\"Create a concise English title for this coding session.\",\n\t\t\t\t\"Use 4-12 words. Describe the concrete task or result.\",\n\t\t\t\t\"Return only the title as plain text. Do not use tools, quotes, labels, or punctuation at the end.\",\n\t\t\t].join(\"\\n\"),\n\t\t\tmessages: [\n\t\t\t\t{\n\t\t\t\t\trole: \"user\",\n\t\t\t\t\tcontent: `Session transcript entries:\\n${transcript}`,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t},\n\t\t\t],\n\t\t\ttools: [],\n\t\t},\n\t\t{\n\t\t\tapiKey: auth.apiKey,\n\t\t\theaders: auth.headers,\n\t\t\tenv: auth.env,\n\t\t\tsignal,\n\t\t\tsessionId: context.sessionManager.getSessionId(),\n\t\t},\n\t);\n\tsignal.throwIfAborted();\n\tif (response.stopReason === \"error\") {\n\t\tthrow new RequestError(\"title_generation_failed\", response.errorMessage ?? \"The title model request failed.\");\n\t}\n\tconst rawTitle = response.content\n\t\t.filter((part): part is { type: \"text\"; text: string } => part.type === \"text\")\n\t\t.map((part) => part.text)\n\t\t.join(\"\\n\");\n\tconst title = parseGeneratedTitle(rawTitle);\n\tpi.setSessionName(title);\n\treturn {\n\t\ttitle,\n\t\tusage: {\n\t\t\tinput: response.usage.input,\n\t\t\toutput: response.usage.output,\n\t\t\tcacheRead: response.usage.cacheRead,\n\t\t\tcacheWrite: response.usage.cacheWrite,\n\t\t\ttotal: response.usage.totalTokens,\n\t\t},\n\t};\n}\n\nfunction makePaths(agentDir: string, instanceId: string): BridgePaths {\n\tconst rootDir = join(agentDir, \"web-bridge\");\n\tconst discoveryDir = join(rootDir, \"discovery\");\n\tconst socketDir = join(rootDir, \"sockets\");\n\treturn {\n\t\trootDir,\n\t\tdiscoveryDir,\n\t\tsocketDir,\n\t\tdescriptorPath: join(discoveryDir, `${instanceId}.json`),\n\t\tsocketPath: join(socketDir, `${instanceId}.sock`),\n\t};\n}\n\nfunction ensurePrivateDirectory(path: string) {\n\tmkdirSync(path, { recursive: true, mode: 0o700 });\n\tif (process.platform !== \"win32\") chmodSync(path, 0o700);\n}\n\nfunction removeFile(path: string) {\n\ttry {\n\t\tunlinkSync(path);\n\t} catch (error) {\n\t\tif (!isMissingFileError(error)) throw error;\n\t}\n}\n\nfunction tokenMatches(expected: string, received: unknown) {\n\tif (typeof received !== \"string\") return false;\n\tconst expectedBytes = Buffer.from(expected, \"utf8\");\n\tconst receivedBytes = Buffer.from(received, \"utf8\");\n\treturn expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes);\n}\n\nfunction parseRequest(value: JsonRecord): RequestFrame {\n\tif (\n\t\tvalue.v !== TERMINAL_BRIDGE_VERSION ||\n\t\tvalue.type !== \"request\" ||\n\t\ttypeof value.id !== \"string\" ||\n\t\t!value.id ||\n\t\tvalue.id.length > MAX_REQUEST_ID_LENGTH ||\n\t\t!Number.isSafeInteger(value.generation) ||\n\t\ttypeof value.command !== \"string\" ||\n\t\t!value.command\n\t) {\n\t\tthrow new RequestError(\"invalid_request\", \"Invalid request envelope.\");\n\t}\n\tif (value.params !== undefined && !isRecord(value.params)) {\n\t\tthrow new RequestError(\"invalid_request\", \"params must be an object.\");\n\t}\n\treturn {\n\t\tv: TERMINAL_BRIDGE_VERSION,\n\t\ttype: \"request\",\n\t\tid: value.id,\n\t\tgeneration: value.generation as number,\n\t\tcommand: value.command,\n\t\tparams: value.params,\n\t};\n}\n\nexport class TerminalWebBridge {\n\tprivate pendingCommands = 0;\n\tprivate deleting = false;\n\tprivate readonly requestContext = new AsyncLocalStorage<{ request: RequestFrame; client: ClientState }>();\n\tprivate deletionOwner: { request: RequestFrame; client: ClientState } | undefined;\n\n\t/** Gate new commands synchronously before the owner checks/disposes its idle writer. */\n\treserveDeletion(preserveConnection: boolean): () => void {\n\t\tconst owner = preserveConnection ? this.requestContext.getStore() : undefined;\n\t\tif (preserveConnection && owner?.request.command !== \"delete_session\") {\n\t\t\tthrow new RequestError(\"invalid_request\", \"Connection preservation requires its deletion request.\");\n\t\t}\n\t\tif (this.deleting || this.pendingCommands > (preserveConnection ? 1 : 0) || this.questions.getPending()) {\n\t\t\tthrow new RequestError(\"busy\", \"Session has pending bridge commands or a question.\");\n\t\t}\n\t\tthis.deleting = true;\n\t\tthis.deletionOwner = owner;\n\t\treturn () => {\n\t\t\tthis.deleting = false;\n\t\t\tthis.deletionOwner = undefined;\n\t\t};\n\t}\n\n\tasync finishDeletion(): Promise<void> {\n\t\tthis.deletionOwner = undefined;\n\t\tawait this.stop();\n\t}\n\tprivate readonly pi: ExtensionAPI;\n\tprivate readonly options: TerminalWebBridgeOptions;\n\tprivate readonly instanceId = randomUUID().replaceAll(\"-\", \"\").slice(0, 24);\n\tprivate readonly authToken = randomBytes(32).toString(\"base64url\");\n\tprivate readonly startedAt = new Date().toISOString();\n\tprivate readonly clients = new Set<ClientState>();\n\tprivate readonly eventHistory: { sequence: number; encoded: Buffer }[] = [];\n\tprivate eventHistoryBytes = 0;\n\tprivate lastUnreplayableSequence = 0;\n\tprivate readonly maxFrameBytes: number;\n\tprivate readonly maxBufferedBytes: number;\n\tprivate readonly maxClients: number;\n\tprivate server: Server | undefined;\n\tprivate context: ExtensionContext | undefined;\n\tprivate paths: BridgePaths | undefined;\n\tprivate descriptor: TerminalBridgeDescriptor | undefined;\n\tprivate generation = 0;\n\tprivate sequence = 0;\n\tprivate commandQueue: Promise<void> = Promise.resolve();\n\tprivate lifecycleQueue: Promise<void> = Promise.resolve();\n\tprivate binding = new AbortController();\n\tprivate sidecarResult: SpWebLaunchResult | undefined;\n\tprivate readonly questions: QuestionInteraction;\n\tprivate unsubscribeQuestions: (() => void) | undefined;\n\n\tconstructor(pi: ExtensionAPI, options: TerminalWebBridgeOptions = {}) {\n\t\tthis.pi = pi;\n\t\tthis.questions = getQuestionInteraction(pi);\n\t\tthis.questions.webOnly = options.webOwned === true;\n\t\tthis.options = options;\n\t\tthis.maxFrameBytes = options.maxFrameBytes ?? DEFAULT_TERMINAL_BRIDGE_MAX_FRAME_BYTES;\n\t\tthis.maxBufferedBytes = options.maxBufferedBytes ?? DEFAULT_TERMINAL_BRIDGE_MAX_BUFFERED_BYTES;\n\t\tthis.maxClients = options.maxClients ?? DEFAULT_TERMINAL_BRIDGE_MAX_CLIENTS;\n\t}\n\n\tgetDescriptor() {\n\t\treturn this.descriptor;\n\t}\n\n\tgetSidecarResult() {\n\t\treturn this.sidecarResult;\n\t}\n\n\tstart(context: ExtensionContext) {\n\t\tconst task = this.lifecycleQueue.then(() => this.startServer(context));\n\t\tthis.lifecycleQueue = task.catch(() => {});\n\t\treturn task;\n\t}\n\n\tprivate async startServer(context: ExtensionContext) {\n\t\tif (context.mode !== \"tui\" && !this.options.webOwned) {\n\t\t\tawait this.stopServer();\n\t\t\treturn;\n\t\t}\n\t\tthis.context = context;\n\t\tif (process.platform === \"win32\") throw new Error(\"The SP terminal bridge requires Unix sockets.\");\n\t\tif (this.server) {\n\t\t\tthis.advanceGeneration(context);\n\t\t\tthis.options.onReady?.(this);\n\t\t\treturn;\n\t\t}\n\n\t\tconst agentDir = this.options.agentDir ?? getSpAgentDir();\n\t\tthis.paths = makePaths(agentDir, this.instanceId);\n\t\tensurePrivateDirectory(this.paths.rootDir);\n\t\tensurePrivateDirectory(this.paths.discoveryDir);\n\t\tensurePrivateDirectory(this.paths.socketDir);\n\t\tif (Buffer.byteLength(this.paths.socketPath, \"utf8\") > 103) {\n\t\t\tthrow new Error(`SP terminal bridge socket path is too long: ${this.paths.socketPath}`);\n\t\t}\n\t\tremoveFile(this.paths.socketPath);\n\n\t\tconst server = createServer((socket) => this.acceptClient(socket));\n\t\tserver.on(\"error\", () => {});\n\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\tconst onError = (error: Error) => {\n\t\t\t\tserver.off(\"listening\", onListening);\n\t\t\t\treject(error);\n\t\t\t};\n\t\t\tconst onListening = () => {\n\t\t\t\tserver.off(\"error\", onError);\n\t\t\t\tresolve();\n\t\t\t};\n\t\t\tserver.once(\"error\", onError);\n\t\t\tserver.once(\"listening\", onListening);\n\t\t\tserver.listen(this.paths?.socketPath);\n\t\t});\n\t\tthis.server = server;\n\t\ttry {\n\t\t\tchmodSync(this.paths.socketPath, 0o600);\n\t\t\tthis.advanceGeneration(context);\n\t\t\tthis.unsubscribeQuestions = this.questions.subscribe((event, ctx) => this.publish(\"interaction\", event, ctx));\n\t\t\tthis.options.onReady?.(this);\n\t\t\tif (this.options.launchSpWeb !== false) {\n\t\t\t\tthis.sidecarResult = ensureSpWebSidecar(this.paths.discoveryDir, this.options.launcherOptions);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tawait this.stopServer();\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\tupdateContext(context: ExtensionContext) {\n\t\tthis.context = context;\n\t}\n\n\tadvanceGeneration(context: ExtensionContext) {\n\t\tthis.binding.abort();\n\t\tthis.binding = new AbortController();\n\t\tthis.questions.cancel();\n\t\tthis.context = context;\n\t\tif (!this.paths) return;\n\t\tthis.generation += 1;\n\t\tthis.commandQueue = Promise.resolve();\n\t\tthis.sequence = 0;\n\t\tthis.eventHistory.splice(0);\n\t\tthis.eventHistoryBytes = 0;\n\t\tthis.lastUnreplayableSequence = 0;\n\t\tfor (const client of this.clients) client.socket.destroy();\n\t\tthis.descriptor = {\n\t\t\tversion: TERMINAL_BRIDGE_VERSION,\n\t\t\tprotocol: TERMINAL_BRIDGE_PROTOCOL,\n\t\t\tinstanceId: this.instanceId,\n\t\t\tpid: process.pid,\n\t\t\tsessionId: context.sessionManager.getSessionId(),\n\t\t\tsessionFile: context.sessionManager.getSessionFile(),\n\t\t\tcwd: context.cwd,\n\t\t\tsocketPath: this.paths.socketPath,\n\t\t\tauthToken: this.authToken,\n\t\t\tgeneration: this.generation,\n\t\t\tstartedAt: this.startedAt,\n\t\t\t...(this.options.writerLeaseVersion ? { writerLeaseVersion: this.options.writerLeaseVersion } : {}),\n\t\t\tframe: { encoding: \"newline-delimited-json\", maxBytes: this.maxFrameBytes },\n\t\t};\n\t\tconst temporaryPath = `${this.paths.descriptorPath}.${process.pid}.tmp`;\n\t\twriteFileSync(temporaryPath, `${JSON.stringify(this.descriptor, null, \"\\t\")}\\n`, {\n\t\t\tencoding: \"utf8\",\n\t\t\tmode: 0o600,\n\t\t});\n\t\tchmodSync(temporaryPath, 0o600);\n\t\trenameSync(temporaryPath, this.paths.descriptorPath);\n\t\tchmodSync(this.paths.descriptorPath, 0o600);\n\t}\n\n\tpublish(category: EventFrame[\"category\"], event: { type: string }, context: ExtensionContext) {\n\t\tthis.context = context;\n\t\tif (!this.server) return;\n\t\tconst frame: EventFrame = {\n\t\t\tv: TERMINAL_BRIDGE_VERSION,\n\t\t\ttype: \"event\",\n\t\t\tgeneration: this.generation,\n\t\t\tsequence: ++this.sequence,\n\t\t\tevent: event.type,\n\t\t\tcategory,\n\t\t\tpayload: normalizeForJson(event),\n\t\t};\n\t\tlet encoded: Buffer;\n\t\ttry {\n\t\t\tencoded = this.encode(frame);\n\t\t} catch {\n\t\t\t// Never claim that replay can cross an event we could not forward.\n\t\t\tthis.lastUnreplayableSequence = frame.sequence;\n\t\t\tframe.event = \"event_gap\";\n\t\t\tframe.category = \"session\";\n\t\t\tframe.payload = { type: \"event_gap\", code: \"event_gap\", resyncRequired: true };\n\t\t\ttry {\n\t\t\t\tencoded = this.encode(frame);\n\t\t\t} catch {\n\t\t\t\tfor (const client of this.clients) {\n\t\t\t\t\tif (client.subscribed) client.socket.destroy();\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tthis.eventHistory.push({ sequence: frame.sequence, encoded });\n\t\tthis.eventHistoryBytes += encoded.length;\n\t\twhile (this.eventHistory.length > MAX_EVENT_HISTORY || this.eventHistoryBytes > MAX_EVENT_HISTORY_BYTES) {\n\t\t\tthis.eventHistoryBytes -= this.eventHistory.shift()!.encoded.length;\n\t\t}\n\t\tfor (const client of this.clients) {\n\t\t\tif (client.authenticated && client.subscribed) {\n\t\t\t\tthis.writeEncoded(client, encoded);\n\t\t\t\tif (frame.event === \"event_gap\") {\n\t\t\t\t\tclient.subscribed = false;\n\t\t\t\t\t// Older clients also recover through their connection-close snapshot path.\n\t\t\t\t\tclient.socket.end();\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tstop() {\n\t\t// The native delete response must drain before session_shutdown closes its socket.\n\t\tif (this.deletionOwner) return Promise.resolve();\n\t\t// Invalidate commands immediately, even if socket startup has not settled.\n\t\tthis.questions.cancel();\n\t\tthis.binding.abort();\n\t\tthis.context = undefined;\n\t\tfor (const client of this.clients) client.socket.destroy();\n\t\tconst task = this.lifecycleQueue.then(() => this.stopServer());\n\t\tthis.lifecycleQueue = task.catch(() => {});\n\t\treturn task;\n\t}\n\n\t/** Synchronous fallback for process.exit, which cannot await socket shutdown. */\n\tcleanupOnExit() {\n\t\tthis.binding.abort();\n\t\tthis.questions.cancel();\n\t\tfor (const client of this.clients) client.socket.destroy();\n\t\tif (this.paths) {\n\t\t\ttry {\n\t\t\t\tconst value = JSON.parse(readFileSync(this.paths.descriptorPath, \"utf8\")) as unknown;\n\t\t\t\tif (isRecord(value) && value.instanceId === this.instanceId) removeFile(this.paths.descriptorPath);\n\t\t\t} catch (error) {\n\t\t\t\tif (!isMissingFileError(error) && !(error instanceof SyntaxError)) throw error;\n\t\t\t}\n\t\t\tremoveFile(this.paths.socketPath);\n\t\t}\n\t}\n\n\tprivate async stopServer() {\n\t\tthis.binding.abort();\n\t\tthis.questions.cancel();\n\t\tthis.context = undefined;\n\t\tthis.commandQueue = Promise.resolve();\n\t\tthis.unsubscribeQuestions?.();\n\t\tthis.unsubscribeQuestions = undefined;\n\t\tconst server = this.server;\n\t\tthis.server = undefined;\n\t\tfor (const client of this.clients) client.socket.destroy();\n\t\tthis.clients.clear();\n\t\tthis.eventHistory.splice(0);\n\t\tthis.eventHistoryBytes = 0;\n\t\tif (server) {\n\t\t\tawait new Promise<void>((resolve) => server.close(() => resolve()));\n\t\t}\n\t\tconst paths = this.paths;\n\t\tif (paths) {\n\t\t\ttry {\n\t\t\t\tconst value = JSON.parse(readFileSync(paths.descriptorPath, \"utf8\")) as unknown;\n\t\t\t\tif (isRecord(value) && value.instanceId === this.instanceId) removeFile(paths.descriptorPath);\n\t\t\t} catch (error) {\n\t\t\t\tif (!isMissingFileError(error) && !(error instanceof SyntaxError)) throw error;\n\t\t\t}\n\t\t\tremoveFile(paths.socketPath);\n\t\t}\n\t\tthis.descriptor = undefined;\n\t\tthis.options.sessions?.bridgeStopped?.(this);\n\t}\n\n\tprivate acceptClient(socket: Socket) {\n\t\tif (this.clients.size >= this.maxClients) {\n\t\t\tsocket.on(\"error\", () => {});\n\t\t\tsocket.end(`${JSON.stringify({ v: 1, type: \"error\", code: \"too_many_clients\" })}\\n`, () => socket.destroy());\n\t\t\treturn;\n\t\t}\n\t\tsocket.setNoDelay(true);\n\t\tconst client: ClientState = {\n\t\t\tsocket,\n\t\t\tbuffer: Buffer.alloc(0),\n\t\t\tauthenticated: false,\n\t\t\tsubscribed: false,\n\t\t\tpendingRequests: 0,\n\t\t\tseenRequestIds: new Set(),\n\t\t};\n\t\tthis.clients.add(client);\n\t\tsocket.on(\"data\", (chunk: Buffer) => this.receive(client, chunk));\n\t\tsocket.on(\"error\", () => {});\n\t\tsocket.on(\"close\", () => this.clients.delete(client));\n\t}\n\n\tprivate receive(client: ClientState, chunk: Buffer) {\n\t\tif (client.socket.destroyed || client.socket.writableEnded) return;\n\t\tclient.buffer = Buffer.concat([client.buffer, chunk]);\n\t\tif (client.buffer.length > this.maxFrameBytes && client.buffer.indexOf(0x0a) === -1) {\n\t\t\tclient.socket.destroy();\n\t\t\treturn;\n\t\t}\n\t\tlet newline = client.buffer.indexOf(0x0a);\n\t\twhile (newline !== -1) {\n\t\t\tconst frame = client.buffer.subarray(0, newline);\n\t\t\tclient.buffer = client.buffer.subarray(newline + 1);\n\t\t\tif (frame.length + 1 > this.maxFrameBytes || frame.length === 0) {\n\t\t\t\tclient.socket.destroy();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tlet value: unknown;\n\t\t\ttry {\n\t\t\t\tvalue = JSON.parse(frame.toString(\"utf8\")) as unknown;\n\t\t\t} catch {\n\t\t\t\tclient.socket.destroy();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!isRecord(value)) {\n\t\t\t\tclient.socket.destroy();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!client.authenticated) {\n\t\t\t\tthis.authenticate(client, value);\n\t\t\t\tif (!client.authenticated) return;\n\t\t\t} else {\n\t\t\t\tthis.acceptRequest(client, value);\n\t\t\t}\n\t\t\tnewline = client.buffer.indexOf(0x0a);\n\t\t}\n\t\tif (client.buffer.length >= this.maxFrameBytes) client.socket.destroy();\n\t}\n\n\tprivate authenticate(client: ClientState, value: JsonRecord) {\n\t\tif (value.v !== TERMINAL_BRIDGE_VERSION || value.type !== \"hello\" || !tokenMatches(this.authToken, value.token)) {\n\t\t\tthis.send(client, { v: 1, type: \"hello\", ok: false, error: \"authentication_failed\" });\n\t\t\tclient.socket.end();\n\t\t\treturn;\n\t\t}\n\t\tclient.authenticated = true;\n\t\tthis.send(client, {\n\t\t\tv: TERMINAL_BRIDGE_VERSION,\n\t\t\ttype: \"hello\",\n\t\t\tok: true,\n\t\t\tprotocol: TERMINAL_BRIDGE_PROTOCOL,\n\t\t\tgeneration: this.generation,\n\t\t\tsequence: this.sequence,\n\t\t\tinstanceId: this.instanceId,\n\t\t});\n\t}\n\n\tprivate acceptRequest(client: ClientState, value: JsonRecord) {\n\t\tlet request: RequestFrame;\n\t\ttry {\n\t\t\trequest = parseRequest(value);\n\t\t} catch (error) {\n\t\t\tthis.sendRequestError(client, typeof value.id === \"string\" ? value.id : null, error);\n\t\t\treturn;\n\t\t}\n\t\tif (client.seenRequestIds.has(request.id)) {\n\t\t\tthis.sendRequestError(\n\t\t\t\tclient,\n\t\t\t\trequest.id,\n\t\t\t\tnew RequestError(\"duplicate_request\", \"Request ID was already used.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tclient.seenRequestIds.add(request.id);\n\t\tif (client.seenRequestIds.size > MAX_SEEN_REQUEST_IDS) {\n\t\t\tconst oldest = client.seenRequestIds.values().next().value as string | undefined;\n\t\t\tif (oldest) client.seenRequestIds.delete(oldest);\n\t\t}\n\t\tif (client.pendingRequests >= MAX_PENDING_REQUESTS) {\n\t\t\tthis.sendRequestError(client, request.id, new RequestError(\"too_many_requests\", \"Too many pending requests.\"));\n\t\t\treturn;\n\t\t}\n\t\tclient.pendingRequests += 1;\n\t\tthis.pendingCommands++;\n\t\t// Reads and turn cancellation/answers must not wait for model auth or title inference.\n\t\tconst independent = [\n\t\t\t\"state\",\n\t\t\t\"snapshot\",\n\t\t\t\"transcript\",\n\t\t\t\"models\",\n\t\t\t\"subscribe\",\n\t\t\t\"respond_to_question\",\n\t\t\t\"abort\",\n\t\t\t\"create_session\",\n\t\t\t\"resume_session\",\n\t\t\t\"delete_session\",\n\t\t].includes(request.command);\n\t\tconst task = (independent ? Promise.resolve() : this.commandQueue)\n\t\t\t.then(async () => {\n\t\t\t\tconst result = await this.requestContext.run({ request, client }, () => this.execute(request, client));\n\t\t\t\tif (request.command === \"delete_session\") {\n\t\t\t\t\tawait this.writeReplay(client, this.encode(this.successFrame(request.id, result)));\n\t\t\t\t} else {\n\t\t\t\t\tthis.sendSuccess(client, request.id, result);\n\t\t\t\t}\n\t\t\t})\n\t\t\t.catch(async (error: unknown) => {\n\t\t\t\tthis.sendRequestError(client, request.id, error);\n\t\t\t\tif (request.command === \"delete_session\") {\n\t\t\t\t\t// Drain error acknowledgements as well when disposal succeeded but unlink failed.\n\t\t\t\t\tawait this.writeReplay(client, Buffer.alloc(0)).catch(() => {});\n\t\t\t\t}\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tclient.pendingRequests -= 1;\n\t\t\t\tthis.pendingCommands--;\n\t\t\t\tif (this.deletionOwner?.request === request && this.deletionOwner.client === client) {\n\t\t\t\t\tvoid this.finishDeletion().catch(() => {});\n\t\t\t\t}\n\t\t\t});\n\t\tif (!independent) this.commandQueue = task;\n\t}\n\n\tprivate async execute(request: RequestFrame, client: ClientState): Promise<unknown> {\n\t\tif (!this.server || client.socket.destroyed || client.socket.writableEnded) {\n\t\t\tthrow new RequestError(\"not_ready\", \"Connection is closed.\");\n\t\t}\n\t\tif (request.generation !== this.generation) {\n\t\t\tthrow new RequestError(\"stale_generation\", `Current generation is ${this.generation}.`);\n\t\t}\n\t\tconst context = this.context;\n\t\tif (!context) throw new RequestError(\"not_ready\", \"Session context is not ready.\");\n\t\tif (this.deleting) throw new RequestError(\"busy\", \"Session deletion is reserved.\");\n\t\tconst params = request.params ?? {};\n\t\tswitch (request.command) {\n\t\t\tcase \"delete_session\":\n\t\t\t\tif (!this.options.sessions?.deleteSession) {\n\t\t\t\t\tthrow new RequestError(\"unsupported_command\", \"No deletion provider available.\");\n\t\t\t\t}\n\t\t\t\tif (params.confirmed !== true) throw new RequestError(\"invalid_params\", \"confirmed must be true.\");\n\t\t\t\treturn this.options.sessions.deleteSession(\n\t\t\t\t\t{\n\t\t\t\t\t\tsessionId: requireString(params.sessionId, \"sessionId\", 256),\n\t\t\t\t\t\t...(params.sessionFile !== undefined\n\t\t\t\t\t\t\t? { sessionFile: requireString(params.sessionFile, \"sessionFile\", 4096) }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\tconfirmed: true,\n\t\t\t\t\t},\n\t\t\t\t\tthis,\n\t\t\t\t);\n\t\t\tcase \"create_session\":\n\t\t\t\tif (!this.options.sessions) throw new RequestError(\"unsupported_command\", \"No session factory available.\");\n\t\t\t\treturn this.options.sessions.create(requireString(params.cwd, \"cwd\", 4096));\n\t\t\tcase \"resume_session\":\n\t\t\t\tif (!this.options.sessions) throw new RequestError(\"unsupported_command\", \"No session factory available.\");\n\t\t\t\treturn this.options.sessions.resume(requireString(params.sessionFile, \"sessionFile\", 4096));\n\t\t\tcase \"respond_to_question\":\n\t\t\t\tthis.questions.respond(requireString(params.interactionId, \"interactionId\", 128), params.response);\n\t\t\t\treturn { accepted: true };\n\t\t\tcase \"snapshot\": {\n\t\t\t\tconst entries = context.sessionManager.getEntries();\n\t\t\t\tconst state = this.stateSnapshot(context);\n\t\t\t\treturn this.transcriptPage(request.id, entries, 0, MAX_TRANSCRIPT_PAGE_SIZE, state);\n\t\t\t}\n\t\t\tcase \"state\":\n\t\t\t\treturn this.stateSnapshot(context);\n\t\t\tcase \"transcript\": {\n\t\t\t\tconst entries = context.sessionManager.getEntries();\n\t\t\t\tconst cursor = optionalInteger(params.cursor, \"cursor\", 0);\n\t\t\t\tconst limit = Math.min(optionalInteger(params.limit, \"limit\", 50), MAX_TRANSCRIPT_PAGE_SIZE);\n\t\t\t\treturn this.transcriptPage(request.id, entries, cursor, limit);\n\t\t\t}\n\t\t\tcase \"models\":\n\t\t\t\treturn {\n\t\t\t\t\tmodels: availableModels(context),\n\t\t\t\t\tcurrent: context.model ? { provider: context.model.provider, modelId: context.model.id } : null,\n\t\t\t\t};\n\t\t\tcase \"generate_title\":\n\t\t\t\treturn (this.options.titleGenerator ?? generateTitle)(context, this.pi, this.binding.signal);\n\t\t\tcase \"subscribe\": {\n\t\t\t\tlet afterSequence = optionalInteger(params.afterSequence, \"afterSequence\", this.sequence);\n\t\t\t\tclient.subscribed = false;\n\t\t\t\tfor (;;) {\n\t\t\t\t\t// Recheck after each drained write: publishing can evict history while replay waits.\n\t\t\t\t\tconst earliestSequence = this.eventHistory[0]?.sequence ?? this.sequence + 1;\n\t\t\t\t\tif (\n\t\t\t\t\t\tafterSequence < earliestSequence - 1 ||\n\t\t\t\t\t\tafterSequence < this.lastUnreplayableSequence ||\n\t\t\t\t\t\tafterSequence > this.sequence\n\t\t\t\t\t) {\n\t\t\t\t\t\tthrow new RequestError(\"event_gap\", `Earliest retained sequence is ${earliestSequence}.`);\n\t\t\t\t\t}\n\t\t\t\t\tconst event = this.eventHistory.find((entry) => entry.sequence > afterSequence);\n\t\t\t\t\tif (!event) break;\n\t\t\t\t\tawait this.writeReplay(client, event.encoded);\n\t\t\t\t\tafterSequence = event.sequence;\n\t\t\t\t}\n\t\t\t\tclient.subscribed = true;\n\t\t\t\treturn { subscribed: true, sequence: this.sequence };\n\t\t\t}\n\t\t\tcase \"prompt\":\n\t\t\t\tif (!context.isIdle()) throw new RequestError(\"busy\", \"Use steer or follow_up while the agent is running.\");\n\t\t\t\tthis.pi.sendUserMessage(userMessageContent(params), {\n\t\t\t\t\texpandPromptTemplates: params.expandPromptTemplates === true,\n\t\t\t\t});\n\t\t\t\treturn { accepted: true };\n\t\t\tcase \"steer\":\n\t\t\t\tif (context.isIdle()) throw new RequestError(\"idle\", \"There is no running turn to steer.\");\n\t\t\t\tthis.pi.sendUserMessage(userMessageContent(params), { deliverAs: \"steer\" });\n\t\t\t\treturn { accepted: true };\n\t\t\tcase \"follow_up\":\n\t\t\t\tif (context.isIdle()) throw new RequestError(\"idle\", \"There is no running turn to queue a follow-up for.\");\n\t\t\t\tthis.pi.sendUserMessage(userMessageContent(params), { deliverAs: \"followUp\" });\n\t\t\t\treturn { accepted: true };\n\t\t\tcase \"abort\":\n\t\t\t\tif (context.isIdle()) throw new RequestError(\"idle\", \"There is no running turn to abort.\");\n\t\t\t\tthis.questions.cancel();\n\t\t\t\tcontext.abort();\n\t\t\t\treturn { accepted: true };\n\t\t\tcase \"set_thinking\": {\n\t\t\t\tif (!context.isIdle()) throw new RequestError(\"busy\", \"Thinking level can only change while idle.\");\n\t\t\t\tconst level = requireString(params.level, \"level\", 16);\n\t\t\t\tif (!THINKING_LEVELS.includes(level as ThinkingLevel)) {\n\t\t\t\t\tthrow new RequestError(\"invalid_params\", `Unsupported thinking level: ${level}`);\n\t\t\t\t}\n\t\t\t\tthis.pi.setThinkingLevel(level as ThinkingLevel);\n\t\t\t\treturn { accepted: true, level: this.pi.getThinkingLevel() };\n\t\t\t}\n\t\t\tcase \"set_model\": {\n\t\t\t\tif (!context.isIdle()) throw new RequestError(\"busy\", \"Model can only change while idle.\");\n\t\t\t\tconst provider = requireString(params.provider, \"provider\", 256);\n\t\t\t\tconst modelId = requireString(params.modelId, \"modelId\", 512);\n\t\t\t\tif (provider !== \"sp-gateway\") {\n\t\t\t\t\tthrow new RequestError(\"model_out_of_scope\", \"Only sp-gateway models are available in SP.\");\n\t\t\t\t}\n\t\t\t\tconst model = context.modelRegistry.find(provider, modelId);\n\t\t\t\tif (!model) throw new RequestError(\"model_unavailable\", `Model is not available: ${provider}/${modelId}`);\n\t\t\t\tif (\n\t\t\t\t\tcontext.scopedModels.length > 0 &&\n\t\t\t\t\t!context.scopedModels.some((entry) => entry.model.provider === provider && entry.model.id === modelId)\n\t\t\t\t) {\n\t\t\t\t\tthrow new RequestError(\n\t\t\t\t\t\t\"model_out_of_scope\",\n\t\t\t\t\t\t`Model is outside this session's model scope: ${provider}/${modelId}`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tif (!(await this.pi.setModel(model))) {\n\t\t\t\t\tthrow new RequestError(\"model_auth_unavailable\", `No usable credentials for ${provider}/${modelId}.`);\n\t\t\t\t}\n\t\t\t\treturn {\n\t\t\t\t\taccepted: true,\n\t\t\t\t\tmodel: { provider: model.provider, id: model.id, name: model.name, input: model.input },\n\t\t\t\t};\n\t\t\t}\n\t\t\tdefault:\n\t\t\t\tthrow new RequestError(\"unsupported_command\", `Unsupported command: ${request.command}`);\n\t\t}\n\t}\n\n\tprivate stateSnapshot(context: ExtensionContext) {\n\t\tconst model = context.model;\n\t\treturn {\n\t\t\tprotocol: TERMINAL_BRIDGE_PROTOCOL,\n\t\t\tgeneration: this.generation,\n\t\t\tsequence: this.sequence,\n\t\t\tsession: {\n\t\t\t\tid: context.sessionManager.getSessionId(),\n\t\t\t\tfile: context.sessionManager.getSessionFile(),\n\t\t\t\tname: context.sessionManager.getSessionName(),\n\t\t\t\tcwd: context.cwd,\n\t\t\t\tentryCount: context.sessionManager.getEntries().length,\n\t\t\t\tactiveLeafId: context.sessionManager.getLeafId(),\n\t\t\t},\n\t\t\tstatus: {\n\t\t\t\tidle: context.isIdle(),\n\t\t\t\thasPendingMessages: context.hasPendingMessages(),\n\t\t\t\tcontextUsage: context.getContextUsage(),\n\t\t\t},\n\t\t\tmodel: model ? { provider: model.provider, id: model.id, name: model.name, input: model.input } : null,\n\t\t\tthinkingLevel: this.pi.getThinkingLevel(),\n\t\t\tpendingQuestion: this.questions.getPending(),\n\t\t\tcapabilities: {\n\t\t\t\tsnapshot: true,\n\t\t\t\tstate: true,\n\t\t\t\ttranscript: { paginated: true, maximumPageSize: MAX_TRANSCRIPT_PAGE_SIZE },\n\t\t\t\teventStream: { replayLimit: MAX_EVENT_HISTORY, replayByteLimit: MAX_EVENT_HISTORY_BYTES },\n\t\t\t\tprompt: true,\n\t\t\t\timages: true,\n\t\t\t\timageLimits: TERMINAL_BRIDGE_IMAGE_LIMITS,\n\t\t\t\tsteer: true,\n\t\t\t\tfollowUp: true,\n\t\t\t\tabort: true,\n\t\t\t\tsetThinking: true,\n\t\t\t\tsetModel: \"available-and-session-scoped-models-only\",\n\t\t\t\tmodels: true,\n\t\t\t\tgenerateTitle: true,\n\t\t\t\trespondToQuestion: true,\n\t\t\t\tnewSession: !!this.options.sessions,\n\t\t\t\tresumeSession: !!this.options.sessions,\n\t\t\t\tforkSession: false,\n\t\t\t\tdeleteSession: !!this.options.sessions?.deleteSession,\n\t\t\t\tdirectJsonlAccess: false,\n\t\t\t},\n\t\t};\n\t}\n\n\tprivate transcriptPage(id: string, entries: unknown[], cursor: number, limit: number, state?: unknown) {\n\t\tconst page = {\n\t\t\tcursor,\n\t\t\tnextCursor: (cursor < entries.length ? cursor : null) as number | null,\n\t\t\ttotal: entries.length,\n\t\t\tentries: [] as unknown[],\n\t\t};\n\t\tconst result = state === undefined ? page : { state, transcript: page };\n\t\t// Count the exact envelope, including escaped request ID, state and newline.\n\t\tlet bytes = this.encode(this.successFrame(id, result)).length;\n\t\tfor (const entry of entries.slice(cursor, cursor + limit)) {\n\t\t\tconst normalized = normalizeForJson(entry);\n\t\t\tconst nextCursor = cursor + page.entries.length + 1 < entries.length ? cursor + page.entries.length + 1 : null;\n\t\t\tconst addedBytes =\n\t\t\t\tBuffer.byteLength(JSON.stringify(normalized), \"utf8\") +\n\t\t\t\t(page.entries.length > 0 ? 1 : 0) +\n\t\t\t\tJSON.stringify(nextCursor).length -\n\t\t\t\tJSON.stringify(page.nextCursor).length;\n\t\t\tif (bytes + addedBytes > this.maxFrameBytes) {\n\t\t\t\tif (page.entries.length === 0) {\n\t\t\t\t\tthrow new RequestError(\n\t\t\t\t\t\t\"frame_too_large\",\n\t\t\t\t\t\t`Transcript entry at cursor ${cursor} cannot fit in the response.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tpage.entries.push(normalized);\n\t\t\tpage.nextCursor = nextCursor;\n\t\t\tbytes += addedBytes;\n\t\t}\n\t\treturn result;\n\t}\n\n\tprivate sendRequestError(client: ClientState, id: string | null, error: unknown) {\n\t\tconst code =\n\t\t\terror instanceof RequestError || error instanceof QuestionResponseError\n\t\t\t\t? error.code\n\t\t\t\t: error instanceof Error && \"code\" in error && typeof error.code === \"string\"\n\t\t\t\t\t? error.code\n\t\t\t\t\t: \"internal_error\";\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tthis.send(client, {\n\t\t\tv: TERMINAL_BRIDGE_VERSION,\n\t\t\ttype: \"response\",\n\t\t\tid,\n\t\t\tgeneration: this.generation,\n\t\t\tok: false,\n\t\t\terror: { code, message },\n\t\t});\n\t}\n\n\tprivate successFrame(id: string, result: unknown) {\n\t\treturn {\n\t\t\tv: TERMINAL_BRIDGE_VERSION,\n\t\t\ttype: \"response\",\n\t\t\tid,\n\t\t\tgeneration: this.generation,\n\t\t\tok: true,\n\t\t\tresult,\n\t\t};\n\t}\n\n\tprivate sendSuccess(client: ClientState, id: string, result: unknown) {\n\t\ttry {\n\t\t\tthis.writeEncoded(client, this.encode(this.successFrame(id, result)));\n\t\t} catch (error) {\n\t\t\tthis.sendRequestError(client, id, error);\n\t\t}\n\t}\n\n\tprivate encode(value: unknown) {\n\t\tconst encoded = Buffer.from(`${JSON.stringify(value)}\\n`, \"utf8\");\n\t\tif (encoded.length > this.maxFrameBytes) throw new RequestError(\"frame_too_large\", \"Frame exceeds the limit.\");\n\t\treturn encoded;\n\t}\n\n\tprivate send(client: ClientState, value: unknown) {\n\t\tif (client.socket.destroyed) return;\n\t\ttry {\n\t\t\tthis.writeEncoded(client, this.encode(value));\n\t\t} catch {\n\t\t\tclient.socket.destroy();\n\t\t}\n\t}\n\n\tprivate writeEncoded(client: ClientState, encoded: Buffer) {\n\t\tif (client.socket.destroyed) return;\n\t\tif (client.socket.writableLength + encoded.length > this.maxBufferedBytes) {\n\t\t\tclient.socket.destroy();\n\t\t\treturn;\n\t\t}\n\t\tclient.socket.write(encoded);\n\t}\n\n\tprivate writeReplay(client: ClientState, encoded: Buffer) {\n\t\treturn new Promise<void>((resolve, reject) => {\n\t\t\tconst socket = client.socket;\n\t\t\tif (\n\t\t\t\tsocket.destroyed ||\n\t\t\t\tsocket.writableEnded ||\n\t\t\t\tsocket.writableLength + encoded.length > this.maxBufferedBytes\n\t\t\t) {\n\t\t\t\tsocket.destroy();\n\t\t\t\treject(new RequestError(\"not_ready\", \"Replay connection is closed or exceeds the output limit.\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst onClose = () => reject(new RequestError(\"not_ready\", \"Replay connection is closed.\"));\n\t\t\tsocket.once(\"close\", onClose);\n\t\t\t// Drain one frame before enqueueing the next, rather than overflowing the client\n\t\t\t// buffer with an otherwise valid multi-image replay.\n\t\t\tsocket.write(encoded, (error) => {\n\t\t\t\tsocket.off(\"close\", onClose);\n\t\t\t\tif (error) reject(error);\n\t\t\t\telse resolve();\n\t\t\t});\n\t\t});\n\t}\n}\n\nexport function registerTerminalWebBridge(pi: ExtensionAPI, options: TerminalWebBridgeOptions = {}) {\n\tconst bridge = new TerminalWebBridge(pi, options);\n\tpi.on(\"session_start\", async (event, context) => {\n\t\ttry {\n\t\t\tif (context.mode === \"tui\" && !options.webOwned) options.sessions?.terminalStarted(context);\n\t\t\tawait bridge.start(context);\n\t\t\tbridge.publish(\"session\", event, context);\n\t\t\tconst sidecar = bridge.getSidecarResult();\n\t\t\tif (sidecar?.status === \"failed\") context.ui.notify(`SP Web sidecar failed: ${sidecar.error}`, \"warning\");\n\t\t} catch (error) {\n\t\t\tcontext.ui.notify(\n\t\t\t\t`SP terminal bridge failed: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t\"warning\",\n\t\t\t);\n\t\t}\n\t});\n\tpi.on(\"session_info_changed\", (event, context) => bridge.publish(\"session\", event, context));\n\tpi.on(\"session_compact\", (event, context) => bridge.publish(\"session\", event, context));\n\tpi.on(\"session_compact_failed\", (event, context) => bridge.publish(\"session\", event, context));\n\tpi.on(\"session_tree\", (event, context) => bridge.publish(\"session\", event, context));\n\tpi.on(\"agent_start\", (event, context) => bridge.publish(\"agent\", event, context));\n\tpi.on(\"agent_end\", (event, context) => bridge.publish(\"agent\", event, context));\n\tpi.on(\"agent_settled\", (event, context) => bridge.publish(\"agent\", event, context));\n\tpi.on(\"message_start\", (event, context) => bridge.publish(\"message\", event, context));\n\tpi.on(\"message_update\", (event, context) => bridge.publish(\"message\", event, context));\n\tpi.on(\"message_end\", (event, context) => bridge.publish(\"message\", event, context));\n\tpi.on(\"tool_execution_start\", (event, context) => bridge.publish(\"tool\", event, context));\n\tpi.on(\"tool_execution_update\", (event, context) => bridge.publish(\"tool\", event, context));\n\tpi.on(\"tool_execution_end\", (event, context) => bridge.publish(\"tool\", event, context));\n\tpi.on(\"model_select\", (event, context) => bridge.publish(\"model\", event, context));\n\tpi.on(\"thinking_level_select\", (event, context) => bridge.publish(\"thinking\", event, context));\n\tpi.on(\"session_shutdown\", async (event, context) => {\n\t\tbridge.publish(\"session\", event, context);\n\t\ttry {\n\t\t\tawait bridge.stop();\n\t\t\tif (event.reason === \"quit\" && !options.webOwned) await options.sessions?.dispose();\n\t\t} catch {\n\t\t\t// Cleanup failures must not stop SP shutdown.\n\t\t}\n\t});\n\treturn bridge;\n}\n\nexport function readTerminalBridgeDescriptor(path: string): TerminalBridgeDescriptor {\n\tconst value = JSON.parse(readFileSync(path, \"utf8\")) as TerminalBridgeDescriptor;\n\treturn value;\n}\n\nexport function terminalBridgeDescriptorExists(path: string) {\n\treturn existsSync(path);\n}\n"]}
|
|
@@ -168,7 +168,13 @@ async function generateTitle(context, pi, signal) {
|
|
|
168
168
|
},
|
|
169
169
|
],
|
|
170
170
|
tools: [],
|
|
171
|
-
}, {
|
|
171
|
+
}, {
|
|
172
|
+
apiKey: auth.apiKey,
|
|
173
|
+
headers: auth.headers,
|
|
174
|
+
env: auth.env,
|
|
175
|
+
signal,
|
|
176
|
+
sessionId: context.sessionManager.getSessionId(),
|
|
177
|
+
});
|
|
172
178
|
signal.throwIfAborted();
|
|
173
179
|
if (response.stopReason === "error") {
|
|
174
180
|
throw new RequestError("title_generation_failed", response.errorMessage ?? "The title model request failed.");
|