pi-web-ui 0.1.1

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.
@@ -0,0 +1,214 @@
1
+ /**
2
+ * pi-web-ui server entry.
3
+ *
4
+ * - Serves the built frontend (web/dist) in production; in dev, Vite serves it
5
+ * on :5173 and proxies /ws to this server.
6
+ * - Exposes /api/health and a WebSocket endpoint at /ws carrying the chat
7
+ * protocol defined in protocol.ts.
8
+ *
9
+ * Env:
10
+ * PORT HTTP port (default 8787)
11
+ * PI_WEB_CWD workspace the agent operates in (default: process.cwd())
12
+ * PI_WEB_DATA_DIR where per-client session dirs are stored (default: <cwd>/.pi-web)
13
+ * PI_CODING_AGENT_DIR pi config dir (auth/models/skills) — passed to the SDK
14
+ */
15
+ import { existsSync } from "node:fs";
16
+ import { createServer } from "node:http";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { randomUUID } from "node:crypto";
20
+ import express from "express";
21
+ import { WebSocket, WebSocketServer } from "ws";
22
+ import { VERSION } from "@earendil-works/pi-coding-agent";
23
+ import { AgentService } from "./agent-service.js";
24
+ const PORT = Number(process.env.PORT ?? 8787);
25
+ const CWD = resolve(process.env.PI_WEB_CWD ?? process.cwd());
26
+ const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(CWD, ".pi-web"));
27
+ const SESSION_DIR_ROOT = join(DATA_DIR, "sessions");
28
+ const app = express();
29
+ app.use(express.json({ limit: "10mb" }));
30
+ app.get("/api/health", (_req, res) => {
31
+ res.json({ ok: true, piVersion: VERSION, cwd: CWD, pid: process.pid });
32
+ });
33
+ // Production: serve the built frontend from web/dist. Resolve relative to this
34
+ // module so it works when installed as a package (global/npx/Docker), not just
35
+ // from the repo root. In dev, Vite serves the UI on :5173 and proxies /ws.
36
+ const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/dist/server or <pkg>/server
37
+ const pkgRoot = resolve(here, "..", "..");
38
+ const webDist = join(pkgRoot, "web", "dist");
39
+ if (existsSync(webDist)) {
40
+ app.use(express.static(webDist));
41
+ app.get(/^\/(?!api\/|ws).*/, (_req, res) => {
42
+ res.sendFile(join(webDist, "index.html"));
43
+ });
44
+ }
45
+ const httpServer = createServer(app);
46
+ const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
47
+ // Heartbeat: lets clients detect half-open connections (server killed without
48
+ // closing sockets, sleep/wake, network partitions). Idle connections otherwise
49
+ // carry no traffic and TCP keepalive defaults are far too slow (~2h).
50
+ const heartbeatTimer = setInterval(() => {
51
+ for (const ws of wss.clients) {
52
+ if (ws.readyState === WebSocket.OPEN) {
53
+ ws.send(JSON.stringify({ type: "heartbeat" }));
54
+ }
55
+ }
56
+ }, 10_000);
57
+ const service = new AgentService(CWD, SESSION_DIR_ROOT);
58
+ wss.on("connection", (ws) => {
59
+ let clientId = null;
60
+ let closed = false;
61
+ /** Commands received while the session is still being created — replayed after attach. */
62
+ let pending = [];
63
+ const send = (msg) => {
64
+ if (!closed && ws.readyState === WebSocket.OPEN) {
65
+ ws.send(JSON.stringify(msg));
66
+ }
67
+ };
68
+ const dispatch = (msg) => {
69
+ if (!clientId) {
70
+ pending.push(msg);
71
+ return;
72
+ }
73
+ const cs = service.get(clientId);
74
+ if (!cs) {
75
+ // Session not ready yet (hello processing) — hold the command.
76
+ pending.push(msg);
77
+ return;
78
+ }
79
+ switch (msg.type) {
80
+ case "prompt":
81
+ void cs.prompt(msg.text, msg.attachments);
82
+ break;
83
+ case "abort":
84
+ void cs.abort();
85
+ break;
86
+ case "new_chat":
87
+ void cs.newChat();
88
+ break;
89
+ case "cycle_model":
90
+ void cs.cycleModel();
91
+ break;
92
+ case "cycle_thinking":
93
+ cs.cycleThinking();
94
+ break;
95
+ case "get_state":
96
+ cs.flushSnapshot();
97
+ break;
98
+ case "list_sessions":
99
+ void cs.refreshSessions();
100
+ break;
101
+ case "switch_session":
102
+ void cs.switchSession(msg.path);
103
+ break;
104
+ case "list_files":
105
+ void cs.listFiles(msg.path);
106
+ break;
107
+ case "list_models":
108
+ void cs.listModels();
109
+ break;
110
+ case "set_model":
111
+ void cs.setModel(msg.modelId);
112
+ break;
113
+ case "set_thinking":
114
+ cs.setThinking(msg.level);
115
+ break;
116
+ case "set_cwd":
117
+ void cs.setCwd(msg.path);
118
+ break;
119
+ case "complete_path":
120
+ void cs.completePath(msg.path);
121
+ break;
122
+ case "dialog_response":
123
+ cs.resolveDialog(msg.id, msg.value);
124
+ break;
125
+ case "terminal_create":
126
+ cs.terminals.create(msg.terminalId, msg.cwd, msg.cols, msg.rows, cs.cwd);
127
+ break;
128
+ case "terminal_input":
129
+ cs.terminals.input(msg.terminalId, msg.data);
130
+ break;
131
+ case "terminal_resize":
132
+ cs.terminals.resize(msg.terminalId, msg.cols, msg.rows);
133
+ break;
134
+ case "terminal_kill":
135
+ cs.terminals.kill(msg.terminalId);
136
+ break;
137
+ case "run_command":
138
+ cs.terminals.runCommand(msg.terminalId, msg.command, msg.cols, msg.rows, cs.cwd);
139
+ break;
140
+ case "list_commands":
141
+ void cs.listCommands();
142
+ break;
143
+ case "save_commands":
144
+ void cs.saveCommands(msg.commands);
145
+ break;
146
+ default:
147
+ break;
148
+ }
149
+ };
150
+ ws.on("message", (data) => {
151
+ let msg;
152
+ try {
153
+ msg = JSON.parse(data.toString());
154
+ }
155
+ catch {
156
+ return;
157
+ }
158
+ if (msg.type === "hello") {
159
+ const cid = msg.clientId || randomUUID();
160
+ clientId = cid;
161
+ service
162
+ .attach(cid, send)
163
+ .then((cs) => {
164
+ if (closed)
165
+ return;
166
+ send({ type: "ready", clientId: cid, serverVersion: VERSION });
167
+ cs.flushSnapshot();
168
+ // Replay anything that arrived while the session was starting.
169
+ const queued = pending;
170
+ pending = [];
171
+ for (const m of queued)
172
+ dispatch(m);
173
+ })
174
+ .catch((err) => {
175
+ send({
176
+ type: "notice",
177
+ level: "error",
178
+ text: `会话初始化失败:${err.message}`,
179
+ });
180
+ });
181
+ return;
182
+ }
183
+ dispatch(msg);
184
+ });
185
+ ws.on("close", () => {
186
+ closed = true;
187
+ pending = [];
188
+ if (clientId)
189
+ service.detach(clientId, send);
190
+ });
191
+ });
192
+ httpServer.listen(PORT, () => {
193
+ console.log("");
194
+ console.log(" ⚡ pi-web-ui — web chat for the pi coding agent");
195
+ console.log(` http://localhost:${PORT}`);
196
+ console.log(` workspace : ${CWD}`);
197
+ console.log(` session dir : ${SESSION_DIR_ROOT}`);
198
+ console.log(` pi SDK : v${VERSION}`);
199
+ console.log("");
200
+ });
201
+ let shuttingDown = false;
202
+ async function shutdown() {
203
+ if (shuttingDown)
204
+ return;
205
+ shuttingDown = true;
206
+ console.log("\nshutting down…");
207
+ clearInterval(heartbeatTimer);
208
+ await service.disposeAll();
209
+ wss.close();
210
+ httpServer.close();
211
+ process.exit(0);
212
+ }
213
+ process.on("SIGINT", () => void shutdown());
214
+ process.on("SIGTERM", () => void shutdown());
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Wire protocol between the browser client and the pi-web-ui server.
3
+ * Pure JSON over WebSocket. The web frontend mirrors these types in
4
+ * web/src/types.ts (kept in sync by hand — types only, no shared runtime code).
5
+ */
6
+ export {};
@@ -0,0 +1,157 @@
1
+ const TEXT_CAP = 200_000;
2
+ const TOOL_OUTPUT_CAP = 100_000;
3
+ const ARGS_CAP = 20_000;
4
+ function truncate(s, cap) {
5
+ if (s.length <= cap)
6
+ return { text: s, truncated: false };
7
+ return { text: `${s.slice(0, cap)}\n\n… [truncated]`, truncated: true };
8
+ }
9
+ function serializeUserContent(content) {
10
+ if (typeof content === "string")
11
+ return [{ type: "text", text: content }];
12
+ return content.map((b) => {
13
+ if (b.type === "image") {
14
+ const img = b;
15
+ // Canonical ImageContent shape is { type, data, mimeType }; tolerate the
16
+ // legacy { source } wrapper too.
17
+ if (typeof img.data === "string" && img.data.length > 0) {
18
+ return {
19
+ type: "image",
20
+ dataUrl: `data:${img.mimeType ?? "image/png"};base64,${img.data}`,
21
+ mimeType: img.mimeType,
22
+ };
23
+ }
24
+ const src = img.source;
25
+ if (src?.type === "base64" && src.data) {
26
+ return {
27
+ type: "image",
28
+ dataUrl: `data:${src.mediaType ?? "image/png"};base64,${src.data}`,
29
+ mimeType: src.mediaType,
30
+ };
31
+ }
32
+ return { type: "image", dataUrl: src?.url };
33
+ }
34
+ return { type: "text", text: String(b.text ?? "") };
35
+ });
36
+ }
37
+ function serializeAssistantContent(content) {
38
+ return content.map((b) => {
39
+ if (b.type === "text") {
40
+ const { text, truncated } = truncate(b.text, TEXT_CAP);
41
+ return { type: "text", text, truncated };
42
+ }
43
+ if (b.type === "thinking") {
44
+ return { type: "thinking", thinking: b.thinking };
45
+ }
46
+ if (b.type === "toolCall") {
47
+ if (b.arguments === undefined) {
48
+ return { type: "toolCall", id: b.id, name: b.name };
49
+ }
50
+ const { text, truncated } = truncate(JSON.stringify(b.arguments), ARGS_CAP);
51
+ return {
52
+ type: "toolCall",
53
+ id: b.id,
54
+ name: b.name,
55
+ argumentsText: text,
56
+ argumentsTruncated: truncated,
57
+ };
58
+ }
59
+ return { type: "unknown", ...b };
60
+ });
61
+ }
62
+ export function serializeMessage(m, seq) {
63
+ switch (m.role) {
64
+ case "user":
65
+ return {
66
+ id: `u-${m.timestamp}-${seq}`,
67
+ role: "user",
68
+ content: serializeUserContent(m.content),
69
+ timestamp: m.timestamp,
70
+ };
71
+ case "assistant":
72
+ return {
73
+ id: `a-${m.timestamp}-${seq}`,
74
+ role: "assistant",
75
+ content: serializeAssistantContent(m.content),
76
+ timestamp: m.timestamp,
77
+ model: m.model,
78
+ provider: m.provider,
79
+ stopReason: m.stopReason,
80
+ errorMessage: m.errorMessage,
81
+ };
82
+ case "toolResult": {
83
+ const raw = m.content
84
+ .map((c) => (c.type === "text" ? c.text : "[image result]"))
85
+ .join("\n");
86
+ const { text, truncated } = truncate(raw, TOOL_OUTPUT_CAP);
87
+ return {
88
+ id: `t-${m.toolCallId}`,
89
+ role: "toolResult",
90
+ content: [{ type: "text", text, truncated }],
91
+ toolCallId: m.toolCallId,
92
+ toolName: m.toolName,
93
+ isError: m.isError,
94
+ timestamp: m.timestamp,
95
+ };
96
+ }
97
+ case "bashExecution": {
98
+ const { text, truncated } = truncate(m.output, TOOL_OUTPUT_CAP);
99
+ return {
100
+ id: `b-${m.timestamp}-${seq}`,
101
+ role: "bashExecution",
102
+ content: [
103
+ {
104
+ type: "bash",
105
+ command: m.command,
106
+ output: text,
107
+ exitCode: m.exitCode,
108
+ cancelled: m.cancelled,
109
+ truncated,
110
+ },
111
+ ],
112
+ timestamp: m.timestamp,
113
+ };
114
+ }
115
+ case "custom": {
116
+ // Third-party extension messages with display:false are UI-hidden
117
+ // (they still go into LLM context — the SDK handles that).
118
+ if (m.display === false) {
119
+ return null;
120
+ }
121
+ const content = serializeUserContent(m.content);
122
+ return {
123
+ id: `c-${m.timestamp}-${seq}`,
124
+ role: "custom",
125
+ content,
126
+ customType: m.customType,
127
+ details: m.details,
128
+ timestamp: m.timestamp,
129
+ };
130
+ }
131
+ case "branchSummary": {
132
+ const { text, truncated } = truncate(m.summary, TEXT_CAP);
133
+ return {
134
+ id: `bs-${m.timestamp}-${seq}`,
135
+ role: "branchSummary",
136
+ content: [{ type: "text", text, truncated }],
137
+ timestamp: m.timestamp,
138
+ };
139
+ }
140
+ case "compactionSummary": {
141
+ const { text, truncated } = truncate(m.summary, TEXT_CAP);
142
+ return {
143
+ id: `cs-${m.timestamp}-${seq}`,
144
+ role: "compactionSummary",
145
+ content: [{ type: "text", text, truncated }],
146
+ timestamp: m.timestamp,
147
+ };
148
+ }
149
+ default:
150
+ return {
151
+ id: `x-${seq}`,
152
+ role: String(m.role ?? "unknown"),
153
+ content: [],
154
+ timestamp: m.timestamp,
155
+ };
156
+ }
157
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * TerminalManager — per-client PTY sessions (node-pty) bridged over the
3
+ * WebSocket protocol, plus the user command list persisted in
4
+ * `<workspaceRoot>/.pi/commands.json`.
5
+ *
6
+ * Each browser client gets its own manager; terminals are shared across that
7
+ * client's tabs (they broadcast through the session's emit). When the last
8
+ * socket for a client detaches, all its PTYs are killed so no orphaned
9
+ * processes survive a closed tab / dropped connection.
10
+ *
11
+ * Commands file format:
12
+ * { "commands": [ { "name": "dev", "command": "npm run dev", "cwd": "${pwd}" } ] }
13
+ * `${pwd}` inside cwd/command resolves to the agent session's current working
14
+ * directory (the same directory the agent operates in — see set_cwd).
15
+ */
16
+ import { existsSync } from "node:fs";
17
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { isAbsolute, join, resolve } from "node:path";
20
+ import { spawn } from "node-pty";
21
+ /** Location of the command list for a project: <workspaceRoot>/.pi/commands.json */
22
+ export function commandsFilePath(workspaceRoot) {
23
+ return join(workspaceRoot, ".pi", "commands.json");
24
+ }
25
+ /** Expand ${pwd} (and ~) in a cwd/command string against the session's cwd. */
26
+ export function expandPwd(input, pwd) {
27
+ let out = input.replace(/\$\{pwd\}/g, pwd);
28
+ if (out === "~")
29
+ return homedir();
30
+ if (out.startsWith("~/"))
31
+ out = join(homedir(), out.slice(2));
32
+ return out;
33
+ }
34
+ /** Resolve a command's directory: default to the session cwd, expand ${pwd}/~, resolve relative paths. */
35
+ export function resolveCommandCwd(cwd, pwd) {
36
+ if (!cwd || cwd.trim() === "")
37
+ return pwd;
38
+ const expanded = expandPwd(cwd.trim(), pwd);
39
+ return isAbsolute(expanded) ? expanded : resolve(pwd, expanded);
40
+ }
41
+ /** Read the command list; missing file → empty list; malformed → empty list + warning text. */
42
+ export async function loadCommands(workspaceRoot) {
43
+ const path = commandsFilePath(workspaceRoot);
44
+ const { commands, warning } = await readCommandsFile(path);
45
+ return { commands, path, warning };
46
+ }
47
+ async function readCommandsFile(path) {
48
+ if (!existsSync(path))
49
+ return { commands: [] };
50
+ let raw;
51
+ try {
52
+ raw = await readFile(path, "utf8");
53
+ }
54
+ catch (err) {
55
+ return {
56
+ commands: [],
57
+ warning: `读取命令文件失败:${err.message}`,
58
+ };
59
+ }
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(raw);
63
+ }
64
+ catch {
65
+ return { commands: [], warning: `命令文件不是有效 JSON:${path}` };
66
+ }
67
+ if (Array.isArray(parsed)) {
68
+ // Tolerate a bare array: [{name, command, cwd}]
69
+ return {
70
+ commands: parsed
71
+ .filter((c) => typeof c === "object" &&
72
+ c !== null &&
73
+ typeof c.name === "string" &&
74
+ typeof c.command === "string")
75
+ .map((c) => ({ name: c.name, command: c.command, cwd: c.cwd })),
76
+ };
77
+ }
78
+ const obj = parsed;
79
+ if (obj && Array.isArray(obj.commands)) {
80
+ return {
81
+ commands: obj.commands
82
+ .filter((c) => typeof c === "object" &&
83
+ c !== null &&
84
+ typeof c.name === "string" &&
85
+ typeof c.command === "string")
86
+ .map((c) => ({ name: c.name, command: c.command, cwd: c.cwd })),
87
+ };
88
+ }
89
+ return { commands: [], warning: `命令文件格式不正确:${path}` };
90
+ }
91
+ /** Persist the command list, creating .pi/ if needed. */
92
+ export async function saveCommandsFile(workspaceRoot, commands) {
93
+ const path = commandsFilePath(workspaceRoot);
94
+ try {
95
+ await mkdir(join(workspaceRoot, ".pi"), { recursive: true });
96
+ const payload = { commands };
97
+ await writeFile(path, JSON.stringify(payload, null, 2) + "\n", "utf8");
98
+ return { path };
99
+ }
100
+ catch (err) {
101
+ return { path, error: `保存命令文件失败:${err.message}` };
102
+ }
103
+ }
104
+ const SHELL = process.env.SHELL || "bash";
105
+ const SHELL_ARGS = ["-i"];
106
+ /**
107
+ * Owns one or more PTYs for a client. All output is forwarded as
108
+ * `terminal_output` messages through the provided emit (broadcast to every
109
+ * socket of the client). Returns false from create/runCommand when the spawn
110
+ * failed (an error notice + terminal_exit are emitted instead).
111
+ */
112
+ export class TerminalManager {
113
+ emit;
114
+ terms = new Map();
115
+ seq = 0;
116
+ constructor(emit) {
117
+ this.emit = emit;
118
+ }
119
+ /** Start a plain interactive shell in the given directory. */
120
+ create(id, cwd, cols, rows, fallbackCwd) {
121
+ if (this.terms.has(id))
122
+ return;
123
+ this.spawnShell(id, cwd || fallbackCwd, cols, rows, `终端 ${++this.seq}`);
124
+ }
125
+ /**
126
+ * Start a shell in the command's directory and run the command in it.
127
+ *
128
+ * If a terminal with this id already exists it is RESTARTED in place: the
129
+ * running process is killed and a fresh shell runs the command again in the
130
+ * same terminal (used when re-running a command by clicking its entry).
131
+ */
132
+ runCommand(id, def, cols, rows, pwd) {
133
+ const dir = resolveCommandCwd(def.cwd, pwd);
134
+ const command = expandPwd(def.command.trim(), pwd);
135
+ const title = def.name || command || `终端 ${++this.seq}`;
136
+ const existing = this.terms.get(id);
137
+ if (existing) {
138
+ // Re-run in place: interrupt the current process (kill the PTY's
139
+ // process group) and start a fresh shell with the same id. Keep the
140
+ // last known size so the replacement matches the xterm's dimensions.
141
+ if (!existing.exited) {
142
+ existing.exited = true;
143
+ try {
144
+ existing.pty.kill();
145
+ }
146
+ catch {
147
+ // already dead
148
+ }
149
+ }
150
+ cols = existing.cols || cols;
151
+ rows = existing.rows || rows;
152
+ this.terms.delete(id);
153
+ }
154
+ const ok = this.spawnShell(id, dir, cols, rows, title);
155
+ if (!ok)
156
+ return;
157
+ // Clear the previous run's output, then show a banner and run the command
158
+ // (the PTY input buffer holds it until the shell is ready).
159
+ this.writeOut(id, "\x1b[2J\x1b[3J\x1b[H");
160
+ this.writeOut(id, `\x1b[90m> ${command}\x1b[0m \x1b[90m(${dir})\x1b[0m\r\n`);
161
+ if (command)
162
+ this.input(id, command + "\r");
163
+ }
164
+ /** Spawn the user's shell as a PTY. Returns false when the spawn failed. */
165
+ spawnShell(id, cwd, cols, rows, title) {
166
+ let abs = cwd;
167
+ if (!abs)
168
+ abs = homedir();
169
+ else if (!isAbsolute(abs))
170
+ abs = resolve(abs);
171
+ if (!existsSync(abs)) {
172
+ this.fail(id, `目录不存在:${abs}`);
173
+ return false;
174
+ }
175
+ let pty;
176
+ try {
177
+ pty = spawn(SHELL, SHELL_ARGS, {
178
+ name: "xterm-256color",
179
+ cols: Math.max(2, Math.floor(cols) || 80),
180
+ rows: Math.max(2, Math.floor(rows) || 24),
181
+ cwd: abs,
182
+ env: { ...process.env, TERM: "xterm-256color" },
183
+ });
184
+ }
185
+ catch (err) {
186
+ this.fail(id, `启动终端失败:${err.message}`);
187
+ return false;
188
+ }
189
+ const entry = {
190
+ id,
191
+ pty,
192
+ title,
193
+ cwd: abs,
194
+ cols: Math.max(2, Math.floor(cols) || 80),
195
+ rows: Math.max(2, Math.floor(rows) || 24),
196
+ exited: false,
197
+ };
198
+ this.terms.set(id, entry);
199
+ // The closures capture `entry`: after a restart the map points at the
200
+ // replacement, so a late event from the OLD pty must be ignored.
201
+ pty.onData((data) => {
202
+ if (this.terms.get(id) !== entry)
203
+ return;
204
+ this.writeOut(id, data);
205
+ });
206
+ pty.onExit(({ exitCode }) => {
207
+ if (this.terms.get(id) !== entry)
208
+ return;
209
+ this.exit(id, exitCode);
210
+ });
211
+ return true;
212
+ }
213
+ writeOut(id, data) {
214
+ const entry = this.terms.get(id);
215
+ if (!entry || entry.exited)
216
+ return;
217
+ this.emit({ type: "terminal_output", terminalId: id, data });
218
+ }
219
+ /** Emit a terminal failure (bad cwd, spawn error) and mark the terminal dead. */
220
+ fail(id, text) {
221
+ this.emit({ type: "notice", level: "error", text });
222
+ this.emit({
223
+ type: "terminal_output",
224
+ terminalId: id,
225
+ data: `\x1b[91m${text}\x1b[0m\r\n`,
226
+ });
227
+ this.emit({ type: "terminal_exit", terminalId: id, exitCode: null });
228
+ }
229
+ exit(id, exitCode) {
230
+ const entry = this.terms.get(id);
231
+ if (!entry || entry.exited)
232
+ return;
233
+ entry.exited = true;
234
+ this.writeOut(id, `\r\n\x1b[90m[进程已退出,退出码 ${exitCode}]\x1b[0m\r\n`);
235
+ this.emit({ type: "terminal_exit", terminalId: id, exitCode });
236
+ }
237
+ input(id, data) {
238
+ const entry = this.terms.get(id);
239
+ if (entry && !entry.exited)
240
+ entry.pty.write(data);
241
+ }
242
+ resize(id, cols, rows) {
243
+ const entry = this.terms.get(id);
244
+ if (!entry || entry.exited)
245
+ return;
246
+ try {
247
+ entry.pty.resize(Math.max(2, Math.floor(cols) || 80), Math.max(2, Math.floor(rows) || 24));
248
+ // Remember the size so an in-place restart spawns at the same dims.
249
+ entry.cols = Math.max(2, Math.floor(cols) || 80);
250
+ entry.rows = Math.max(2, Math.floor(rows) || 24);
251
+ }
252
+ catch {
253
+ // PTY already gone — nothing to do.
254
+ }
255
+ }
256
+ /** Kill one terminal (tab closed). The exit event is emitted by node-pty. */
257
+ kill(id) {
258
+ const entry = this.terms.get(id);
259
+ if (!entry || entry.exited)
260
+ return;
261
+ entry.exited = true;
262
+ try {
263
+ entry.pty.kill();
264
+ }
265
+ catch {
266
+ // already dead
267
+ }
268
+ this.terms.delete(id);
269
+ this.emit({ type: "terminal_exit", terminalId: id, exitCode: null });
270
+ }
271
+ /** Kill every terminal of this client (disconnect / dispose). */
272
+ killAll() {
273
+ for (const entry of this.terms.values()) {
274
+ if (entry.exited)
275
+ continue;
276
+ entry.exited = true;
277
+ try {
278
+ entry.pty.kill();
279
+ }
280
+ catch {
281
+ // already dead
282
+ }
283
+ }
284
+ this.terms.clear();
285
+ }
286
+ }