@exagone313/dsh-podman 0.2.0-rc.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/README.zh.md +14 -2
- package/cordis.patch.yml +58 -0
- package/dist/approval-reasons.d.ts +3 -4
- package/dist/approval-reasons.js +40 -49
- package/dist/approval-reasons.test.js +43 -15
- package/dist/approval.js +27 -10
- package/dist/approval.test.js +165 -34
- package/dist/card-route.d.ts +3 -2
- package/dist/card-route.js +103 -35
- package/dist/card-route.test.js +60 -43
- package/dist/card-test-support.js +39 -66
- package/dist/client/ContainerCard.d.ts +1 -1
- package/dist/client/ContainerCard.js +76 -70
- package/dist/client/card-protocol.d.ts +3 -3
- package/dist/client/container-card-caches.js +11 -6
- package/dist/client/container-card-controller.d.ts +13 -18
- package/dist/client/container-card-controller.js +86 -40
- package/dist/client/container-card-create-modal.d.ts +2 -2
- package/dist/client/container-card-create-modal.js +13 -5
- package/dist/client/container-card-default-env.d.ts +10 -0
- package/dist/client/container-card-default-env.js +69 -0
- package/dist/client/container-card-directory.d.ts +2 -2
- package/dist/client/container-card-directory.js +12 -4
- package/dist/client/container-card-editors.d.ts +3 -3
- package/dist/client/container-card-editors.js +104 -32
- package/dist/client/container-card-images.d.ts +4 -4
- package/dist/client/container-card-images.js +19 -6
- package/dist/client/container-card-paths.d.ts +3 -3
- package/dist/client/container-card-paths.js +18 -11
- package/dist/client/container-card-row.d.ts +10 -6
- package/dist/client/container-card-row.js +82 -13
- package/dist/client/container-card-secrets.d.ts +3 -3
- package/dist/client/container-card-secrets.js +8 -6
- package/dist/client/container-card-shared.d.ts +4 -17
- package/dist/client/container-card-shared.js +9 -25
- package/dist/client/container-card-styles.d.ts +2 -8
- package/dist/client/container-card-styles.js +14 -52
- package/dist/client/container-card-volumes.d.ts +2 -2
- package/dist/client/container-card-volumes.js +9 -5
- package/dist/client/container-card-workspace.d.ts +8 -6
- package/dist/client/container-card-workspace.js +9 -3
- package/dist/client/directory-picker.js +2 -1
- package/dist/client/index.js +13109 -2117
- package/dist/client/locales.d.ts +6 -1
- package/dist/client/locales.js +88 -38
- package/dist/client/podman-terminal-guide.d.ts +11 -0
- package/dist/client/podman-terminal-guide.js +208 -0
- package/dist/client/podman-terminal-title.d.ts +7 -0
- package/dist/client/podman-terminal-title.js +30 -0
- package/dist/client/podman-terminal.d.ts +26 -0
- package/dist/client/podman-terminal.js +412 -0
- package/dist/client/read-only-approval.d.ts +2 -2
- package/dist/client/read-only-approval.js +13 -3
- package/dist/client/slot-contract.d.ts +0 -11
- package/dist/client/terminal-preference.d.ts +26 -0
- package/dist/client/terminal-preference.js +67 -0
- package/dist/client/terminal-protocol.d.ts +85 -0
- package/dist/client/terminal-protocol.js +14 -0
- package/dist/client/terminal-tab.d.ts +60 -0
- package/dist/client/terminal-tab.js +35 -0
- package/dist/client/terminal-targets.d.ts +33 -0
- package/dist/client/terminal-targets.js +65 -0
- package/dist/client/terminal-titles.d.ts +23 -0
- package/dist/client/terminal-titles.js +52 -0
- package/dist/client/terminal-transport.d.ts +63 -0
- package/dist/client/terminal-transport.js +207 -0
- package/dist/client/tool-views.js +215 -49
- package/dist/container-env.d.ts +11 -0
- package/dist/container-env.js +96 -0
- package/dist/container-env.test.d.ts +1 -0
- package/dist/container-env.test.js +93 -0
- package/dist/containers.test.js +196 -18
- package/dist/daemons.test.js +18 -4
- package/dist/dsh-version.js +2 -1
- package/dist/env-rows.d.ts +14 -0
- package/dist/env-rows.js +52 -0
- package/dist/env-rows.test.d.ts +1 -0
- package/dist/env-rows.test.js +43 -0
- package/dist/fs-provider.d.ts +1 -0
- package/dist/fs-provider.js +26 -12
- package/dist/fs-provider.test.js +51 -10
- package/dist/generated/version.d.ts +2 -2
- package/dist/generated/version.js +2 -2
- package/dist/grpc/proto/dshctl/v1/control.proto +8 -1
- package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
- package/dist/grpc/runtime-client.d.ts +3 -1
- package/dist/grpc/runtime-client.js +90 -17
- package/dist/guest-rpc.d.ts +15 -19
- package/dist/guest-rpc.js +289 -70
- package/dist/guest-rpc.test.d.ts +1 -0
- package/dist/guest-rpc.test.js +241 -0
- package/dist/guest-terminal.d.ts +30 -0
- package/dist/guest-terminal.js +196 -0
- package/dist/images.test.js +10 -3
- package/dist/index.d.ts +11 -16
- package/dist/index.js +42 -36
- package/dist/locales.test.d.ts +1 -0
- package/dist/locales.test.js +34 -0
- package/dist/misc.test.js +38 -10
- package/dist/mount-enums.js +2 -1
- package/dist/mount-input.d.ts +2 -0
- package/dist/mount-input.js +38 -22
- package/dist/mounts.test.js +81 -11
- package/dist/naming.test.d.ts +1 -0
- package/dist/naming.test.js +28 -0
- package/dist/output-reader.js +36 -4
- package/dist/package-deps.test.d.ts +1 -0
- package/dist/package-deps.test.js +49 -0
- package/dist/paths.test.js +4 -2
- package/dist/plugin-meta.test.d.ts +1 -0
- package/dist/plugin-meta.test.js +29 -0
- package/dist/project-path.js +33 -7
- package/dist/project-path.test.js +14 -0
- package/dist/prompts.d.ts +0 -4
- package/dist/prompts.js +15 -84
- package/dist/read-only-shell.js +3 -1
- package/dist/read-only-shell.test.js +80 -25
- package/dist/runtime-client.test.d.ts +1 -0
- package/dist/runtime-client.test.js +53 -0
- package/dist/secrets.test.js +21 -6
- package/dist/settings-commands.test.js +61 -14
- package/dist/settings-create.test.js +32 -10
- package/dist/settings-default-env.test.d.ts +1 -0
- package/dist/settings-default-env.test.js +175 -0
- package/dist/settings-mounts.test.js +46 -7
- package/dist/settings-schema.d.ts +6 -11
- package/dist/settings-schema.js +4 -8
- package/dist/settings-schema.test.d.ts +1 -0
- package/dist/settings-schema.test.js +35 -0
- package/dist/settings-workspaces.test.js +36 -63
- package/dist/spill-store.d.ts +1 -0
- package/dist/spill-store.js +17 -4
- package/dist/spill-store.test.js +31 -8
- package/dist/subprocess.d.ts +4 -0
- package/dist/subprocess.js +148 -162
- package/dist/subprocess.test.js +118 -8
- package/dist/terminal-preference.test.d.ts +1 -0
- package/dist/terminal-preference.test.js +62 -0
- package/dist/terminal-route.d.ts +10 -0
- package/dist/terminal-route.js +270 -0
- package/dist/terminal-route.test.d.ts +1 -0
- package/dist/terminal-route.test.js +265 -0
- package/dist/terminal-sessions.d.ts +69 -0
- package/dist/terminal-sessions.js +252 -0
- package/dist/terminal-shells.d.ts +22 -0
- package/dist/terminal-shells.js +99 -0
- package/dist/terminal-tab.test.d.ts +1 -0
- package/dist/terminal-tab.test.js +58 -0
- package/dist/terminal-targets.test.d.ts +1 -0
- package/dist/terminal-targets.test.js +43 -0
- package/dist/terminal-titles.test.d.ts +1 -0
- package/dist/terminal-titles.test.js +36 -0
- package/dist/test-support.d.ts +35 -5
- package/dist/test-support.js +91 -29
- package/dist/tool-defs.js +19 -4
- package/dist/tool-handlers.js +80 -28
- package/dist/tool-params.js +19 -5
- package/dist/volumes.test.js +1 -1
- package/dist/workspace-binding.d.ts +18 -9
- package/dist/workspace-binding.js +244 -77
- package/dist/workspace-binding.test.js +189 -25
- package/docs/architecture.md +62 -21
- package/docs/architecture.zh.md +57 -13
- package/docs/configuration.md +61 -20
- package/docs/configuration.zh.md +53 -19
- package/docs/development-prompt.md +72 -0
- package/docs/development-prompt.zh.md +69 -0
- package/docs/development.md +141 -21
- package/docs/development.zh.md +125 -17
- package/docs/install-dsh-and-dsh-podman.md +21 -8
- package/docs/install-dsh-and-dsh-podman.zh.md +18 -8
- package/docs/uninstall.md +83 -0
- package/docs/uninstall.zh.md +79 -0
- package/docs/update.md +120 -0
- package/docs/update.zh.md +114 -0
- package/docs/usage.md +108 -61
- package/docs/usage.zh.md +76 -38
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -25
- package/quadlet/dsh-podman-orchestrator.container +46 -0
- package/quadlet/dsh.container +35 -0
- package/LICENSE.pkg +0 -17103
- package/dist/preferences.d.ts +0 -6
- package/dist/preferences.js +0 -27
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { readTerminalTarget, TERMINAL_TARGET_KEY, writeTerminalTarget, } from "./client/terminal-preference.js";
|
|
7
|
+
function fakeStorage(initial) {
|
|
8
|
+
const values = new Map();
|
|
9
|
+
if (initial !== undefined)
|
|
10
|
+
values.set(TERMINAL_TARGET_KEY, initial);
|
|
11
|
+
return {
|
|
12
|
+
values,
|
|
13
|
+
getItem: (key) => values.get(key) ?? null,
|
|
14
|
+
setItem: (key, value) => {
|
|
15
|
+
values.set(key, value);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
test("the terminal card remembers the target it started", () => {
|
|
20
|
+
const storage = fakeStorage();
|
|
21
|
+
assert.equal(readTerminalTarget(storage), undefined);
|
|
22
|
+
writeTerminalTarget({ container: "dev", shell: "/usr/bin/bash" }, storage);
|
|
23
|
+
assert.deepEqual(readTerminalTarget(storage), {
|
|
24
|
+
container: "dev",
|
|
25
|
+
shell: "/usr/bin/bash",
|
|
26
|
+
});
|
|
27
|
+
// The default container is a real choice and must survive the round trip.
|
|
28
|
+
writeTerminalTarget({ container: "", shell: "/bin/sh" }, storage);
|
|
29
|
+
assert.deepEqual(readTerminalTarget(storage), {
|
|
30
|
+
container: "",
|
|
31
|
+
shell: "/bin/sh",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
test("unusable stored values leave the card without a preference", () => {
|
|
35
|
+
for (const raw of [
|
|
36
|
+
"",
|
|
37
|
+
"{",
|
|
38
|
+
"null",
|
|
39
|
+
"[]",
|
|
40
|
+
'"text"',
|
|
41
|
+
"{}",
|
|
42
|
+
'{"container":1}',
|
|
43
|
+
'{"shell":2}',
|
|
44
|
+
'{"container":null,"shell":false}',
|
|
45
|
+
]) {
|
|
46
|
+
assert.equal(readTerminalTarget(fakeStorage(raw)), undefined, raw);
|
|
47
|
+
}
|
|
48
|
+
// A partial record keeps the half that is usable.
|
|
49
|
+
assert.deepEqual(readTerminalTarget(fakeStorage('{"shell":"/bin/sh","extra":true}')), { shell: "/bin/sh" });
|
|
50
|
+
});
|
|
51
|
+
test("a storage that refuses to answer never breaks the card", () => {
|
|
52
|
+
const failing = {
|
|
53
|
+
getItem() {
|
|
54
|
+
throw new Error("denied");
|
|
55
|
+
},
|
|
56
|
+
setItem() {
|
|
57
|
+
throw new Error("denied");
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
assert.equal(readTerminalTarget(failing), undefined);
|
|
61
|
+
assert.doesNotThrow(() => writeTerminalTarget({ container: "", shell: "/bin/sh" }, failing));
|
|
62
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type TerminalSessions } from "./terminal-sessions.js";
|
|
2
|
+
import { type WorkspaceResolver } from "./workspace-binding.js";
|
|
3
|
+
/**
|
|
4
|
+
* Register the terminal routes on the connection service.
|
|
5
|
+
* @param ctx - plugin host context carrying the connection service.
|
|
6
|
+
* @param resolver - workspace/container resolver.
|
|
7
|
+
* @param sessions - live terminal registry.
|
|
8
|
+
* @param workspaceRegistry - the harness workspace registry, when present.
|
|
9
|
+
*/
|
|
10
|
+
export declare function registerTerminalRoutes(ctx: any, resolver: WorkspaceResolver, sessions: TerminalSessions, workspaceRegistry?: any): void;
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
// The browser-facing terminal routes. They are served below the harness API
|
|
5
|
+
// path, so the carrier applies its Host/Origin fence and browser authentication
|
|
6
|
+
// before any handler runs; the handlers themselves only speak the terminal
|
|
7
|
+
// protocol declared in client/terminal-protocol.ts.
|
|
8
|
+
import { TERMINAL_PATH, TERMINAL_RETAINED_PATH, TERMINAL_SHELLS_PATH, TERMINAL_TARGET_PATH, } from "./client/terminal-protocol.js";
|
|
9
|
+
import { discoverShells } from "./terminal-shells.js";
|
|
10
|
+
import { workspaceSlug } from "./workspace-binding.js";
|
|
11
|
+
const MIN_COLS = 2;
|
|
12
|
+
const MAX_COLS = 500;
|
|
13
|
+
const MIN_ROWS = 1;
|
|
14
|
+
const MAX_ROWS = 200;
|
|
15
|
+
function json(status, body) {
|
|
16
|
+
return new Response(JSON.stringify(body), {
|
|
17
|
+
status,
|
|
18
|
+
headers: { "content-type": "application/json" },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function message(error) {
|
|
22
|
+
return error instanceof Error ? error.message : String(error);
|
|
23
|
+
}
|
|
24
|
+
function integer(raw, min, max) {
|
|
25
|
+
if (raw === null || raw === "")
|
|
26
|
+
return undefined;
|
|
27
|
+
const value = Number(raw);
|
|
28
|
+
if (!Number.isInteger(value) || value < min || value > max)
|
|
29
|
+
return undefined;
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
// The workspace's mirrored path under the projects root, or the session's own
|
|
33
|
+
// working directory when the request names no workspace.
|
|
34
|
+
function workspaceCwd(ctx, workspaceRegistry, projectsRoot, projectName, sessionId) {
|
|
35
|
+
const list = workspaceRegistry?.list?.() ?? [];
|
|
36
|
+
if (projectName !== "") {
|
|
37
|
+
for (const workspace of list) {
|
|
38
|
+
const path = String(workspace.path ?? "");
|
|
39
|
+
const name = path.startsWith(`${projectsRoot}/`)
|
|
40
|
+
? path.slice(projectsRoot.length + 1)
|
|
41
|
+
: path;
|
|
42
|
+
if (name === projectName)
|
|
43
|
+
return path;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const cwd = ctx?.get?.("sessions")?.get?.(sessionId)?.header?.cwd;
|
|
48
|
+
return typeof cwd === "string" && cwd !== "" ? cwd : undefined;
|
|
49
|
+
}
|
|
50
|
+
// The workspace one session runs in, resolved from the session's own working
|
|
51
|
+
// directory: a Session belongs to one workspace, so the client never asks for
|
|
52
|
+
// one. The longest matching registry path wins, which keeps nested workspace
|
|
53
|
+
// roots unambiguous. Everything the client needs about the target comes from
|
|
54
|
+
// here, so the browser never reads harness Session internals.
|
|
55
|
+
function sessionWorkspace(ctx, workspaceRegistry, projectsRoot, sessionId) {
|
|
56
|
+
const cwd = ctx?.get?.("sessions")?.get?.(sessionId)?.header?.cwd;
|
|
57
|
+
if (typeof cwd !== "string" || cwd === "")
|
|
58
|
+
return undefined;
|
|
59
|
+
let best;
|
|
60
|
+
for (const workspace of workspaceRegistry?.list?.() ?? []) {
|
|
61
|
+
const path = String(workspace.path ?? "");
|
|
62
|
+
if (path === "" || (cwd !== path && !cwd.startsWith(`${path}/`)))
|
|
63
|
+
continue;
|
|
64
|
+
if (best !== undefined && path.length <= best.path.length)
|
|
65
|
+
continue;
|
|
66
|
+
const projectName = path.startsWith(`${projectsRoot}/`)
|
|
67
|
+
? path.slice(projectsRoot.length + 1)
|
|
68
|
+
: path;
|
|
69
|
+
best = { path, projectName, slug: workspaceSlug(workspace.id) };
|
|
70
|
+
}
|
|
71
|
+
return best === undefined
|
|
72
|
+
? undefined
|
|
73
|
+
: { workspace: best.projectName, workspaceSlug: best.slug };
|
|
74
|
+
}
|
|
75
|
+
function handleTarget(request, ctx, resolver, workspaceRegistry) {
|
|
76
|
+
const sessionId = new URL(request.url).searchParams.get("sessionId") ?? "";
|
|
77
|
+
if (sessionId === "")
|
|
78
|
+
return json(400, { error: "sessionId is required" });
|
|
79
|
+
const target = sessionWorkspace(ctx, workspaceRegistry, resolver.getConfig().projectsRoot, sessionId);
|
|
80
|
+
if (target === undefined) {
|
|
81
|
+
return json(400, { error: "Cannot determine this session's workspace" });
|
|
82
|
+
}
|
|
83
|
+
return json(200, target);
|
|
84
|
+
}
|
|
85
|
+
function controlOf(body) {
|
|
86
|
+
if (typeof body?.terminalId !== "string" || body.terminalId === "") {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const terminalId = body.terminalId;
|
|
90
|
+
switch (body.kind) {
|
|
91
|
+
case "input":
|
|
92
|
+
return typeof body.data === "string"
|
|
93
|
+
? { terminalId, kind: "input", data: body.data }
|
|
94
|
+
: undefined;
|
|
95
|
+
case "resize": {
|
|
96
|
+
const cols = integer(String(body.cols ?? ""), MIN_COLS, MAX_COLS);
|
|
97
|
+
const rows = integer(String(body.rows ?? ""), MIN_ROWS, MAX_ROWS);
|
|
98
|
+
return cols === undefined || rows === undefined
|
|
99
|
+
? undefined
|
|
100
|
+
: { terminalId, kind: "resize", cols, rows };
|
|
101
|
+
}
|
|
102
|
+
case "rename":
|
|
103
|
+
return typeof body.title === "string" && body.title.trim() !== ""
|
|
104
|
+
? { terminalId, kind: "rename", title: body.title.trim().slice(0, 120) }
|
|
105
|
+
: undefined;
|
|
106
|
+
case "close":
|
|
107
|
+
return { terminalId, kind: "close" };
|
|
108
|
+
default:
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function bindingFor(resolver, cwd, container, signal) {
|
|
113
|
+
return container === "" || container === "default"
|
|
114
|
+
? await resolver.resolveForPath(cwd, cwd, signal)
|
|
115
|
+
: await resolver.containerBinding(cwd, container, signal);
|
|
116
|
+
}
|
|
117
|
+
async function handleOpen(request, ctx, resolver, sessions, workspaceRegistry) {
|
|
118
|
+
const url = new URL(request.url);
|
|
119
|
+
let options;
|
|
120
|
+
try {
|
|
121
|
+
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
122
|
+
const tabId = url.searchParams.get("tabId") ?? "";
|
|
123
|
+
const workspace = url.searchParams.get("workspace") ?? "";
|
|
124
|
+
const container = url.searchParams.get("container") ?? "";
|
|
125
|
+
const shell = url.searchParams.get("shell") ?? "";
|
|
126
|
+
const cols = integer(url.searchParams.get("cols"), MIN_COLS, MAX_COLS);
|
|
127
|
+
const rows = integer(url.searchParams.get("rows"), MIN_ROWS, MAX_ROWS);
|
|
128
|
+
if (sessionId === "" || tabId === "" || shell === "" || !shell.startsWith("/")) {
|
|
129
|
+
throw new Error("sessionId, tabId and an absolute shell are required");
|
|
130
|
+
}
|
|
131
|
+
if (cols === undefined || rows === undefined) {
|
|
132
|
+
throw new Error(`cols must be ${MIN_COLS}..${MAX_COLS} and rows ${MIN_ROWS}..${MAX_ROWS}`);
|
|
133
|
+
}
|
|
134
|
+
const cwd = workspaceCwd(ctx, workspaceRegistry, resolver.getConfig().projectsRoot, workspace, sessionId);
|
|
135
|
+
if (cwd === undefined) {
|
|
136
|
+
throw new Error(`unknown workspace ${JSON.stringify(workspace)}`);
|
|
137
|
+
}
|
|
138
|
+
options = {
|
|
139
|
+
sessionId,
|
|
140
|
+
tabId,
|
|
141
|
+
workspace,
|
|
142
|
+
cwd,
|
|
143
|
+
container,
|
|
144
|
+
shell,
|
|
145
|
+
cols,
|
|
146
|
+
rows,
|
|
147
|
+
reopen: url.searchParams.get("reopen") === "1",
|
|
148
|
+
signal: request.signal,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
return json(400, { error: message(error) });
|
|
153
|
+
}
|
|
154
|
+
let prepared;
|
|
155
|
+
try {
|
|
156
|
+
prepared = await sessions.prepare(options);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
return json(400, { error: message(error) });
|
|
160
|
+
}
|
|
161
|
+
const encoder = new TextEncoder();
|
|
162
|
+
const stream = new ReadableStream({
|
|
163
|
+
start(controller) {
|
|
164
|
+
const write = (frame) => {
|
|
165
|
+
try {
|
|
166
|
+
controller.enqueue(encoder.encode(`${JSON.stringify(frame)}\n`));
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// The consumer is gone; the request signal releases the attachment.
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const detach = sessions.listen(prepared.terminalId, write, request.signal, prepared.snapshot);
|
|
173
|
+
request.signal.addEventListener("abort", () => {
|
|
174
|
+
detach();
|
|
175
|
+
try {
|
|
176
|
+
controller.close();
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Already closed by the consumer.
|
|
180
|
+
}
|
|
181
|
+
}, { once: true });
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
return new Response(stream, {
|
|
185
|
+
headers: {
|
|
186
|
+
"content-type": "application/x-ndjson",
|
|
187
|
+
"cache-control": "no-store",
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
async function handleControl(request, sessions) {
|
|
192
|
+
let body;
|
|
193
|
+
try {
|
|
194
|
+
body = await request.json();
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return json(400, { error: "invalid terminal control" });
|
|
198
|
+
}
|
|
199
|
+
const control = controlOf(body);
|
|
200
|
+
if (control === undefined) {
|
|
201
|
+
return json(400, { error: "unknown terminal control" });
|
|
202
|
+
}
|
|
203
|
+
return sessions.control(control)
|
|
204
|
+
? json(200, { ok: true })
|
|
205
|
+
: json(404, { error: "unknown terminal" });
|
|
206
|
+
}
|
|
207
|
+
async function handleShells(request, ctx, resolver, workspaceRegistry) {
|
|
208
|
+
const url = new URL(request.url);
|
|
209
|
+
const workspace = url.searchParams.get("workspace") ?? "";
|
|
210
|
+
const container = url.searchParams.get("container") ?? "";
|
|
211
|
+
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
212
|
+
const cwd = workspaceCwd(ctx, workspaceRegistry, resolver.getConfig().projectsRoot, workspace, sessionId);
|
|
213
|
+
if (cwd === undefined) {
|
|
214
|
+
return json(400, {
|
|
215
|
+
error: `unknown workspace ${JSON.stringify(workspace)}`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const binding = await bindingFor(resolver, cwd, container, request.signal);
|
|
220
|
+
return json(200, {
|
|
221
|
+
shells: await discoverShells(binding, cwd, request.signal),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
return json(502, { error: message(error) });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function handleRetained(request, sessions) {
|
|
229
|
+
const sessionId = new URL(request.url).searchParams.get("sessionId") ?? "";
|
|
230
|
+
if (sessionId === "")
|
|
231
|
+
return json(400, { error: "sessionId is required" });
|
|
232
|
+
return json(200, { terminals: sessions.retained(sessionId) });
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Register the terminal routes on the connection service.
|
|
236
|
+
* @param ctx - plugin host context carrying the connection service.
|
|
237
|
+
* @param resolver - workspace/container resolver.
|
|
238
|
+
* @param sessions - live terminal registry.
|
|
239
|
+
* @param workspaceRegistry - the harness workspace registry, when present.
|
|
240
|
+
*/
|
|
241
|
+
export function registerTerminalRoutes(ctx, resolver, sessions, workspaceRegistry) {
|
|
242
|
+
ctx.inject(["connection"], (connectionCtx) => {
|
|
243
|
+
connectionCtx.connection.fetch.register({
|
|
244
|
+
path: TERMINAL_PATH,
|
|
245
|
+
methods: ["GET", "POST"],
|
|
246
|
+
requestBody: "buffered",
|
|
247
|
+
fetch: (request) => request.method === "POST"
|
|
248
|
+
? handleControl(request, sessions)
|
|
249
|
+
: handleOpen(request, ctx, resolver, sessions, workspaceRegistry),
|
|
250
|
+
});
|
|
251
|
+
connectionCtx.connection.fetch.register({
|
|
252
|
+
path: TERMINAL_SHELLS_PATH,
|
|
253
|
+
methods: ["GET"],
|
|
254
|
+
requestBody: "buffered",
|
|
255
|
+
fetch: (request) => handleShells(request, ctx, resolver, workspaceRegistry),
|
|
256
|
+
});
|
|
257
|
+
connectionCtx.connection.fetch.register({
|
|
258
|
+
path: TERMINAL_TARGET_PATH,
|
|
259
|
+
methods: ["GET"],
|
|
260
|
+
requestBody: "buffered",
|
|
261
|
+
fetch: (request) => handleTarget(request, ctx, resolver, workspaceRegistry),
|
|
262
|
+
});
|
|
263
|
+
connectionCtx.connection.fetch.register({
|
|
264
|
+
path: TERMINAL_RETAINED_PATH,
|
|
265
|
+
methods: ["GET"],
|
|
266
|
+
requestBody: "buffered",
|
|
267
|
+
fetch: (request) => handleRetained(request, sessions),
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { FakeExecStream, FakeTerminalCall, WORKSPACE_ID, } from "./test-support.js";
|
|
7
|
+
import { TERMINAL_PATH, TERMINAL_RETAINED_PATH, TERMINAL_SHELLS_PATH, TERMINAL_TARGET_PATH, } from "./client/terminal-protocol.js";
|
|
8
|
+
import { workspaceSlug } from "./workspace-binding.js";
|
|
9
|
+
import { discoverShells, requireShell } from "./terminal-shells.js";
|
|
10
|
+
import { TerminalSessions } from "./terminal-sessions.js";
|
|
11
|
+
import { registerTerminalRoutes } from "./terminal-route.js";
|
|
12
|
+
const ROOT = "/projects/team";
|
|
13
|
+
function fakeGuest(options = {}) {
|
|
14
|
+
const terminal = new FakeTerminalCall();
|
|
15
|
+
const starts = [];
|
|
16
|
+
return {
|
|
17
|
+
terminal,
|
|
18
|
+
starts,
|
|
19
|
+
binding: {
|
|
20
|
+
guest: {
|
|
21
|
+
exec: () => new FakeExecStream(starts, { stdout: options.stdout ?? "" }),
|
|
22
|
+
terminal: () => terminal,
|
|
23
|
+
},
|
|
24
|
+
token: "t",
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function fakeResolver(binding) {
|
|
29
|
+
const calls = [];
|
|
30
|
+
return {
|
|
31
|
+
calls,
|
|
32
|
+
resolveForPath: async () => {
|
|
33
|
+
calls.push("default");
|
|
34
|
+
return binding;
|
|
35
|
+
},
|
|
36
|
+
containerBinding: async (_cwd, container) => {
|
|
37
|
+
calls.push(container);
|
|
38
|
+
return binding;
|
|
39
|
+
},
|
|
40
|
+
getConfig: () => ({ projectsRoot: "/projects" }),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function fakeRegistry() {
|
|
44
|
+
return { list: () => [{ id: WORKSPACE_ID, path: ROOT }] };
|
|
45
|
+
}
|
|
46
|
+
function routeHarness(sessions, resolver, sessionStore) {
|
|
47
|
+
const routes = new Map();
|
|
48
|
+
const ctx = {
|
|
49
|
+
inject: (_names, fn) => {
|
|
50
|
+
fn({
|
|
51
|
+
connection: {
|
|
52
|
+
fetch: {
|
|
53
|
+
register: (route) => {
|
|
54
|
+
routes.set(route.path, route);
|
|
55
|
+
return async () => { };
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
get: (name) => name === "sessions" ? sessionStore : undefined,
|
|
62
|
+
};
|
|
63
|
+
registerTerminalRoutes(ctx, resolver, sessions, fakeRegistry());
|
|
64
|
+
return routes;
|
|
65
|
+
}
|
|
66
|
+
test("shell discovery parses the probe, dedups and prefers the most capable shell", async () => {
|
|
67
|
+
const { binding } = fakeGuest({
|
|
68
|
+
stdout: "sh\t/usr/bin/sh\nbash\t/usr/bin/bash\nzsh\t/opt/tools/bin/zsh\nnot a path\nbash\t/bin/bash\ndash\t/usr/bin/dash\nfish\t/usr/bin/fish\n",
|
|
69
|
+
});
|
|
70
|
+
const shells = await discoverShells(binding, ROOT);
|
|
71
|
+
assert.deepEqual(shells.map((shell) => `${shell.name}=${shell.path}`), [
|
|
72
|
+
"zsh=/opt/tools/bin/zsh",
|
|
73
|
+
"bash=/usr/bin/bash",
|
|
74
|
+
"fish=/usr/bin/fish",
|
|
75
|
+
"dash=/usr/bin/dash",
|
|
76
|
+
"sh=/usr/bin/sh",
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
test("shell discovery ignores names outside the candidate list", async () => {
|
|
80
|
+
const { binding } = fakeGuest({
|
|
81
|
+
stdout: "rbash\t/usr/bin/rbash\ngit-shell\t/usr/bin/git-shell\n",
|
|
82
|
+
});
|
|
83
|
+
assert.deepEqual(await discoverShells(binding, ROOT), []);
|
|
84
|
+
});
|
|
85
|
+
test("the host resolves a session's workspace from its own directory", async () => {
|
|
86
|
+
const { binding } = fakeGuest();
|
|
87
|
+
const resolver = fakeResolver(binding);
|
|
88
|
+
const sessions = new TerminalSessions({ resolver, retentionMs: 60_000 });
|
|
89
|
+
try {
|
|
90
|
+
const routes = routeHarness(sessions, resolver, {
|
|
91
|
+
get: (id) => id === "s1" ? { header: { cwd: `${ROOT}/src` } } : undefined,
|
|
92
|
+
});
|
|
93
|
+
const target = routes.get(TERMINAL_TARGET_PATH);
|
|
94
|
+
assert.ok(target);
|
|
95
|
+
const found = await target.fetch(new Request(`http://dsh.internal${TERMINAL_TARGET_PATH}?sessionId=s1`));
|
|
96
|
+
assert.equal(found.status, 200);
|
|
97
|
+
assert.deepEqual(await found.json(), {
|
|
98
|
+
workspace: "team",
|
|
99
|
+
workspaceSlug: workspaceSlug(WORKSPACE_ID),
|
|
100
|
+
});
|
|
101
|
+
// A missing id, an unknown session, an empty directory and a directory
|
|
102
|
+
// outside every workspace all refuse rather than guessing.
|
|
103
|
+
const missingId = await target.fetch(new Request(`http://dsh.internal${TERMINAL_TARGET_PATH}`));
|
|
104
|
+
assert.equal(missingId.status, 400);
|
|
105
|
+
assert.deepEqual(await missingId.json(), {
|
|
106
|
+
error: "sessionId is required",
|
|
107
|
+
});
|
|
108
|
+
for (const store of [
|
|
109
|
+
{ get: () => undefined },
|
|
110
|
+
{ get: () => ({ header: { cwd: "" } }) },
|
|
111
|
+
{ get: () => ({ header: { cwd: "/elsewhere" } }) },
|
|
112
|
+
]) {
|
|
113
|
+
const other = routeHarness(sessions, resolver, store).get(TERMINAL_TARGET_PATH);
|
|
114
|
+
const refused = await other.fetch(new Request(`http://dsh.internal${TERMINAL_TARGET_PATH}?sessionId=s1`));
|
|
115
|
+
assert.equal(refused.status, 400);
|
|
116
|
+
assert.deepEqual(await refused.json(), {
|
|
117
|
+
error: "Cannot determine this session's workspace",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
sessions.dispose();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
test("a shell missing from the probe is refused", async () => {
|
|
126
|
+
const { binding } = fakeGuest({ stdout: "bash\t/usr/bin/bash\n" });
|
|
127
|
+
await assert.rejects(() => requireShell(binding, ROOT, "/bin/zsh"), /not available in this container/);
|
|
128
|
+
});
|
|
129
|
+
test("terminal sessions stream frames, retain the shell and control it", async () => {
|
|
130
|
+
const { binding, terminal } = fakeGuest({ stdout: "bash\t/usr/bin/bash\n" });
|
|
131
|
+
const resolver = fakeResolver(binding);
|
|
132
|
+
const sessions = new TerminalSessions({ resolver, retentionMs: 60_000 });
|
|
133
|
+
try {
|
|
134
|
+
const prepared = await sessions.prepare({
|
|
135
|
+
sessionId: "s1",
|
|
136
|
+
tabId: "t1",
|
|
137
|
+
workspace: "team",
|
|
138
|
+
cwd: ROOT,
|
|
139
|
+
container: "",
|
|
140
|
+
shell: "/usr/bin/bash",
|
|
141
|
+
cols: 80,
|
|
142
|
+
rows: 24,
|
|
143
|
+
});
|
|
144
|
+
assert.equal(prepared.attached, false);
|
|
145
|
+
assert.deepEqual(resolver.calls, ["default"]);
|
|
146
|
+
const frames = [];
|
|
147
|
+
const detach = sessions.listen(prepared.terminalId, (frame) => frames.push(frame), new AbortController().signal);
|
|
148
|
+
assert.equal(frames[0]?.type, "ready");
|
|
149
|
+
terminal.emitStdout(Buffer.from("hi"));
|
|
150
|
+
assert.deepEqual(frames.at(-1), {
|
|
151
|
+
type: "data",
|
|
152
|
+
data: Buffer.from("hi").toString("base64"),
|
|
153
|
+
});
|
|
154
|
+
assert.equal(sessions.control({
|
|
155
|
+
terminalId: prepared.terminalId,
|
|
156
|
+
kind: "input",
|
|
157
|
+
data: Buffer.from("x").toString("base64"),
|
|
158
|
+
}), true);
|
|
159
|
+
assert.deepEqual(terminal.stdinChunks, [Buffer.from("x")]);
|
|
160
|
+
assert.equal(sessions.control({
|
|
161
|
+
terminalId: prepared.terminalId,
|
|
162
|
+
kind: "resize",
|
|
163
|
+
cols: 100,
|
|
164
|
+
rows: 30,
|
|
165
|
+
}), true);
|
|
166
|
+
assert.deepEqual(terminal.resizes, [{ cols: 100, rows: 30 }]);
|
|
167
|
+
assert.equal(sessions.control({
|
|
168
|
+
terminalId: prepared.terminalId,
|
|
169
|
+
kind: "rename",
|
|
170
|
+
title: "dev",
|
|
171
|
+
}), true);
|
|
172
|
+
assert.deepEqual(frames.at(-1), { type: "title", title: "dev" });
|
|
173
|
+
terminal.emitExit(0);
|
|
174
|
+
// The exit frame is written from the guest's done promise.
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
176
|
+
assert.equal(frames.at(-1)?.type, "exit");
|
|
177
|
+
// Reattaching replays the serialized screen and the exit state.
|
|
178
|
+
const again = await sessions.prepare({
|
|
179
|
+
sessionId: "s1",
|
|
180
|
+
tabId: "t1",
|
|
181
|
+
workspace: "team",
|
|
182
|
+
cwd: ROOT,
|
|
183
|
+
container: "",
|
|
184
|
+
shell: "/usr/bin/bash",
|
|
185
|
+
cols: 80,
|
|
186
|
+
rows: 24,
|
|
187
|
+
});
|
|
188
|
+
assert.equal(again.attached, true);
|
|
189
|
+
assert.equal(again.terminalId, prepared.terminalId);
|
|
190
|
+
const reattached = [];
|
|
191
|
+
sessions.listen(again.terminalId, (frame) => reattached.push(frame), new AbortController().signal, again.snapshot);
|
|
192
|
+
assert.equal(reattached[0]?.type, "ready");
|
|
193
|
+
assert.equal(reattached[1]?.type, "snapshot");
|
|
194
|
+
assert.equal(reattached.at(-1)?.type, "exit");
|
|
195
|
+
// A named container resolves through containerBinding.
|
|
196
|
+
const other = await sessions.prepare({
|
|
197
|
+
sessionId: "s1",
|
|
198
|
+
tabId: "t2",
|
|
199
|
+
workspace: "team",
|
|
200
|
+
cwd: ROOT,
|
|
201
|
+
container: "dev",
|
|
202
|
+
shell: "/usr/bin/bash",
|
|
203
|
+
cols: 80,
|
|
204
|
+
rows: 24,
|
|
205
|
+
});
|
|
206
|
+
assert.deepEqual(resolver.calls, ["default", "dev"]);
|
|
207
|
+
assert.equal(sessions.retained("s1").length, 2);
|
|
208
|
+
assert.equal(sessions.control({ terminalId: other.terminalId, kind: "close" }), true);
|
|
209
|
+
assert.equal(sessions.retained("s1").length, 1);
|
|
210
|
+
detach();
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
sessions.dispose();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
test("terminal routes open, control, list and validate", async () => {
|
|
217
|
+
const { binding, terminal } = fakeGuest({ stdout: "bash\t/usr/bin/bash\n" });
|
|
218
|
+
const resolver = fakeResolver(binding);
|
|
219
|
+
const sessions = new TerminalSessions({ resolver, retentionMs: 60_000 });
|
|
220
|
+
try {
|
|
221
|
+
const routes = routeHarness(sessions, resolver);
|
|
222
|
+
const open = routes.get(TERMINAL_PATH);
|
|
223
|
+
assert.ok(open);
|
|
224
|
+
const missingShell = await open.fetch(new Request(`http://dsh.internal${TERMINAL_PATH}?sessionId=s1&tabId=t1&workspace=team&cols=80&rows=24`));
|
|
225
|
+
assert.equal(missingShell.status, 400);
|
|
226
|
+
const unknownWorkspace = await open.fetch(new Request(`http://dsh.internal${TERMINAL_PATH}?sessionId=s1&tabId=t1&workspace=nope&shell=/usr/bin/bash&cols=80&rows=24`));
|
|
227
|
+
assert.equal(unknownWorkspace.status, 400);
|
|
228
|
+
const response = await open.fetch(new Request(`http://dsh.internal${TERMINAL_PATH}?sessionId=s1&tabId=t1&workspace=team&shell=/usr/bin/bash&cols=80&rows=24`));
|
|
229
|
+
assert.equal(response.status, 200);
|
|
230
|
+
assert.equal(response.headers.get("content-type"), "application/x-ndjson");
|
|
231
|
+
const reader = response.body.getReader();
|
|
232
|
+
const first = new TextDecoder().decode((await reader.read()).value);
|
|
233
|
+
assert.match(first, /"type":"ready"/);
|
|
234
|
+
const ready = JSON.parse(first.trim().split("\n")[0]);
|
|
235
|
+
const control = await open.fetch(new Request(`http://dsh.internal${TERMINAL_PATH}`, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
body: JSON.stringify({
|
|
238
|
+
terminalId: ready.terminalId,
|
|
239
|
+
kind: "resize",
|
|
240
|
+
cols: 90,
|
|
241
|
+
rows: 30,
|
|
242
|
+
}),
|
|
243
|
+
}));
|
|
244
|
+
assert.equal(control.status, 200);
|
|
245
|
+
assert.deepEqual(terminal.resizes, [{ cols: 90, rows: 30 }]);
|
|
246
|
+
const unknownTerminal = await open.fetch(new Request(`http://dsh.internal${TERMINAL_PATH}`, {
|
|
247
|
+
method: "POST",
|
|
248
|
+
body: JSON.stringify({ terminalId: "nope", kind: "close" }),
|
|
249
|
+
}));
|
|
250
|
+
assert.equal(unknownTerminal.status, 404);
|
|
251
|
+
const shellsResponse = await routes.get(TERMINAL_SHELLS_PATH).fetch(new Request(`http://dsh.internal${TERMINAL_SHELLS_PATH}?workspace=team&sessionId=s1`));
|
|
252
|
+
const shellsBody = (await shellsResponse.json());
|
|
253
|
+
assert.deepEqual(shellsBody.shells.map((shell) => shell.path), [
|
|
254
|
+
"/usr/bin/bash",
|
|
255
|
+
]);
|
|
256
|
+
const retainedResponse = await routes.get(TERMINAL_RETAINED_PATH).fetch(new Request(`http://dsh.internal${TERMINAL_RETAINED_PATH}?sessionId=s1`));
|
|
257
|
+
const retainedBody = (await retainedResponse.json());
|
|
258
|
+
assert.equal(retainedBody.terminals.length, 1);
|
|
259
|
+
assert.equal(retainedBody.terminals[0]?.tabId, "t1");
|
|
260
|
+
await reader.cancel();
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
sessions.dispose();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type TerminalControl, type TerminalFrame, type TerminalRetainedView } from "./client/terminal-protocol.js";
|
|
2
|
+
import { type WorkspaceResolver } from "./workspace-binding.js";
|
|
3
|
+
export declare const TERMINAL_RETENTION_MS = 7200000;
|
|
4
|
+
export interface TerminalOpenOptions {
|
|
5
|
+
sessionId: string;
|
|
6
|
+
tabId: string;
|
|
7
|
+
workspace: string;
|
|
8
|
+
cwd: string;
|
|
9
|
+
container: string;
|
|
10
|
+
shell: string;
|
|
11
|
+
cols: number;
|
|
12
|
+
rows: number;
|
|
13
|
+
reopen?: boolean;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
}
|
|
16
|
+
export interface TerminalPrepared {
|
|
17
|
+
terminalId: string;
|
|
18
|
+
workspace: string;
|
|
19
|
+
container: string;
|
|
20
|
+
shell: string;
|
|
21
|
+
title: string;
|
|
22
|
+
attached: boolean;
|
|
23
|
+
snapshot?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface TerminalSessionsOptions {
|
|
26
|
+
resolver: WorkspaceResolver;
|
|
27
|
+
scrollback?: number;
|
|
28
|
+
retentionMs?: number;
|
|
29
|
+
}
|
|
30
|
+
export declare class TerminalSessions {
|
|
31
|
+
private readonly options;
|
|
32
|
+
private readonly entries;
|
|
33
|
+
private readonly byId;
|
|
34
|
+
private readonly scrollback;
|
|
35
|
+
private readonly retentionMs;
|
|
36
|
+
private readonly sweep;
|
|
37
|
+
constructor(options: TerminalSessionsOptions);
|
|
38
|
+
/**
|
|
39
|
+
* Validate a request and make sure its terminal exists.
|
|
40
|
+
* @param options - session/tab identity, target container and shell, dimensions.
|
|
41
|
+
* @returns the terminal identity and, when reattaching, its serialized screen.
|
|
42
|
+
*/
|
|
43
|
+
prepare(options: TerminalOpenOptions): Promise<TerminalPrepared>;
|
|
44
|
+
/**
|
|
45
|
+
* Attach one browser stream as the terminal's input owner.
|
|
46
|
+
* @param terminalId - a prepared terminal.
|
|
47
|
+
* @param write - frame sink for this attachment.
|
|
48
|
+
* @param signal - the request lifetime; aborting detaches without closing.
|
|
49
|
+
* @param snapshot - serialized screen to replay before live output.
|
|
50
|
+
* @returns a detach function, idempotent and safe after the entry is gone.
|
|
51
|
+
*/
|
|
52
|
+
listen(terminalId: string, write: (frame: TerminalFrame) => void, signal: AbortSignal, snapshot?: string): () => void;
|
|
53
|
+
/**
|
|
54
|
+
* Apply one control request.
|
|
55
|
+
* @param control - input, resize, rename, or close.
|
|
56
|
+
* @returns whether the terminal was found.
|
|
57
|
+
*/
|
|
58
|
+
control(control: TerminalControl): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* List the terminals one session still holds.
|
|
61
|
+
* @param sessionId - the owning session.
|
|
62
|
+
* @returns retained terminal metadata, oldest first.
|
|
63
|
+
*/
|
|
64
|
+
retained(sessionId: string): TerminalRetainedView[];
|
|
65
|
+
/** Close every terminal and stop the retention sweep. */
|
|
66
|
+
dispose(): void;
|
|
67
|
+
private drop;
|
|
68
|
+
private reap;
|
|
69
|
+
}
|