@evandu/super-pi 0.1.21 → 0.1.23

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.
Files changed (51) hide show
  1. package/README.md +58 -1
  2. package/agents/super-pi-memory/index.ts +34 -0
  3. package/dist/client/assets/index-BKKZnCis.css +1 -0
  4. package/dist/client/assets/index-LWO_0omC.js +50 -0
  5. package/dist/client/favicon.ico +0 -0
  6. package/dist/client/favicon.png +0 -0
  7. package/dist/client/favicon.svg +10 -0
  8. package/dist/client/index.html +5 -2
  9. package/dist/server/active-memory.d.ts +118 -0
  10. package/dist/server/active-memory.js +214 -0
  11. package/dist/server/application/conversation-service.js +1 -0
  12. package/dist/server/application/run-service.d.ts +12 -0
  13. package/dist/server/application/run-service.js +48 -4
  14. package/dist/server/command-handler.d.ts +16 -2
  15. package/dist/server/command-handler.js +24 -2
  16. package/dist/server/files.d.ts +1 -0
  17. package/dist/server/files.js +77 -46
  18. package/dist/server/http-server.d.ts +17 -1
  19. package/dist/server/http-server.js +185 -20
  20. package/dist/server/index.js +97 -29
  21. package/dist/server/memory.d.ts +25 -0
  22. package/dist/server/memory.js +88 -0
  23. package/dist/server/persistence/app-state-repository.d.ts +27 -0
  24. package/dist/server/persistence/app-state-repository.js +1 -0
  25. package/dist/server/persistence/json-state-repository.d.ts +12 -0
  26. package/dist/server/persistence/json-state-repository.js +24 -0
  27. package/dist/server/persistence/open-state-store.d.ts +3 -0
  28. package/dist/server/persistence/open-state-store.js +35 -0
  29. package/dist/server/persistence/rename.d.ts +22 -0
  30. package/dist/server/persistence/rename.js +44 -0
  31. package/dist/server/persistence/sqlite-state-repository.d.ts +13 -0
  32. package/dist/server/persistence/sqlite-state-repository.js +114 -0
  33. package/dist/server/runtime.d.ts +7 -1
  34. package/dist/server/runtime.js +23 -10
  35. package/dist/server/server-context.d.ts +12 -0
  36. package/dist/server/store.d.ts +12 -3
  37. package/dist/server/store.js +55 -82
  38. package/dist/server/subagent-runs.d.ts +2 -0
  39. package/dist/server/subagent-runs.js +6 -0
  40. package/dist/server/subagents.d.ts +2 -0
  41. package/dist/server/subagents.js +11 -3
  42. package/dist/server/terminal.d.ts +139 -0
  43. package/dist/server/terminal.js +366 -0
  44. package/dist/server/types.d.ts +69 -2
  45. package/dist/server/workflow-engine.d.ts +2 -0
  46. package/dist/server/workflow-engine.js +6 -0
  47. package/dist/server/ws-server.d.ts +11 -0
  48. package/dist/server/ws-server.js +42 -2
  49. package/package.json +2 -129
  50. package/dist/client/assets/index-B43FWHQ6.js +0 -43
  51. package/dist/client/assets/index-BY__2nV9.css +0 -1
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The right rail's terminal, as a SERVER-SIDE SESSION MANAGER.
3
+ *
4
+ * ---------------------------------------------------------------------------------------------
5
+ * WHY THIS FILE EXISTS AT ALL, stated plainly because it is the most consequential file in the project
6
+ * ---------------------------------------------------------------------------------------------
7
+ *
8
+ * Everything else in Super Pi is a control plane for a MODEL. This file hands a browser a SHELL on the
9
+ * machine Super Pi runs on. That is categorically different in kind, not in degree: a chat message can
10
+ * at worst make the model write a file inside a workspace, whereas one keystroke here runs whatever the
11
+ * user typed, with the full privileges of the server process, with no workspace containment and no
12
+ * capability scope.
13
+ *
14
+ * The server binds `0.0.0.0` by default (see `index.ts`), which exists so a phone on the same Wi-Fi can
15
+ * reach Super Pi without an SSH tunnel. Combined with a shell, that turns "anyone on the LAN who can
16
+ * open a web page" into "anyone on the LAN who can open a web page has a root-or-you shell". The
17
+ * project already knows this shape of problem - `http-server.ts` guards file writes and uploads behind
18
+ * `isLocalOrPrivateAddress` for exactly this reason - but a file write is bounded by the workspace root
19
+ * and this is not.
20
+ *
21
+ * So the guards here are deliberately redundant, and each one closes a hole the others do not:
22
+ *
23
+ * 1. OPT-IN, OFF BY DEFAULT. `SUPER_PI_TERMINAL` must be set to `1`/`true`/`on` before a single shell
24
+ * is spawned. The default install has no terminal at all - the tab is not even rendered. Enabling
25
+ * it is a deliberate act by someone who knows what it does.
26
+ * 2. NETWORK SCOPE. Even enabled, a command is refused unless the WebSocket arrived from loopback or
27
+ * an RFC 1918 / link-local / ULA address (`isLocalOrPrivateAddress`, the same predicate the file
28
+ * endpoints use). A request that arrives from a routed or proxied address is refused by name.
29
+ * 3. THE BROWSER NEVER CHOOSES THE WORKING DIRECTORY. `terminal.open` carries a WORKSPACE ID and
30
+ * nothing else; this file resolves the id against the store and spawns in that workspace's path.
31
+ * A browser that asked for `C:\` or `/etc` gets its own workspace's directory instead, because
32
+ * there is no code path on which a client-supplied path reaches `spawn`.
33
+ * 4. BOUNDED SESSIONS. `MAX_SESSIONS` shells, one per client tab, capped so a loop of reconnects
34
+ * cannot accumulate processes.
35
+ *
36
+ * What is NOT here is a per-command allowlist. A terminal that refuses `rm` or `curl` is not a terminal,
37
+ * and pretending otherwise would be worse than the honest version: with (1) and (2) the operator has
38
+ * chosen to expose a shell to their own LAN, and from there every command is the operator's own.
39
+ *
40
+ * ---------------------------------------------------------------------------------------------
41
+ * WHY `node-pty` AND NOT `child_process.spawn`
42
+ * ---------------------------------------------------------------------------------------------
43
+ *
44
+ * `spawn("bash", { stdio: "pipe" })` gives a program three pipes and no terminal. Programs that ask
45
+ * whether stdout is a TTY take the "I am being piped" branch and behave differently: no colours, no
46
+ * progress bars, and `vim` / `top` / `less` refuse outright with "not a terminal". A terminal panel
47
+ * that cannot run those is a command runner wearing a terminal's clothes.
48
+ *
49
+ * `node-pty` allocates a real pseudo-terminal (ConPTY on Windows, `forkpty` elsewhere), which is the
50
+ * only way to get them. The cost is a native module, and it is paid only by operators who set
51
+ * `SUPER_PI_TERMINAL=1` - see `loadPty` below, which is reached lazily and never at import time.
52
+ */
53
+ import type { ServerEvent, Workspace } from "./types.js";
54
+ /** One shell per browser tab, and a hard ceiling so reconnects cannot accumulate processes. */
55
+ export declare const MAX_SESSIONS = 8;
56
+ /**
57
+ * The terminal's wire protocol, as members of the EXISTING `ClientCommand` / `ServerEvent` unions rather
58
+ * than a second transport.
59
+ *
60
+ * Reusing the command channel is what makes this feature cheap to wire: the WebSocket already carries
61
+ * authentication, and `ws-server.ts` already answers exactly one socket. A parallel HTTP endpoint with
62
+ * server-sent events would have had to re-implement both, and the terminal's traffic is bidirectional -
63
+ * keystrokes have to flow back the same way output does.
64
+ */
65
+ export type TerminalCommand = {
66
+ type: "terminal.open";
67
+ sessionId: string;
68
+ workspaceId: string;
69
+ cols?: number;
70
+ rows?: number;
71
+ } | {
72
+ type: "terminal.input";
73
+ sessionId: string;
74
+ data: string;
75
+ } | {
76
+ type: "terminal.resize";
77
+ sessionId: string;
78
+ cols: number;
79
+ rows: number;
80
+ } | {
81
+ type: "terminal.close";
82
+ sessionId: string;
83
+ };
84
+ export type TerminalEvent = {
85
+ type: "terminal.output";
86
+ sessionId: string;
87
+ data: string;
88
+ } | {
89
+ type: "terminal.exit";
90
+ sessionId: string;
91
+ exitCode: number;
92
+ } | {
93
+ type: "terminal.error";
94
+ sessionId: string;
95
+ message: string;
96
+ } | {
97
+ type: "terminal.status";
98
+ enabled: boolean;
99
+ reason?: string;
100
+ };
101
+ /**
102
+ * Reads the opt-in flag. Anything other than an explicit truthy string leaves the terminal OFF, so a
103
+ * typo in a deployment config fails closed rather than silently exposing a shell.
104
+ */
105
+ export declare function isTerminalEnabled(env?: NodeJS.ProcessEnv): boolean;
106
+ /** The sentence a browser shows when the terminal is missing. Says WHICH of the three gates closed. */
107
+ export declare function terminalDisabledReason(env?: NodeJS.ProcessEnv): string;
108
+ export declare class TerminalService {
109
+ private readonly sessions;
110
+ private readonly workspaceFor;
111
+ /** Whether a command from this peer is allowed to touch a shell at all. */
112
+ private readonly isTrustedPeer;
113
+ constructor(options: {
114
+ workspaceFor: (workspaceId: string) => Workspace | undefined;
115
+ isTrustedPeer: (remoteAddress: string | undefined) => boolean;
116
+ enabled?: boolean;
117
+ });
118
+ readonly enabled: boolean;
119
+ /**
120
+ * WHY THE PEER CHECK LIVES ON EVERY COMMAND AND NOT ONLY ON `open`.
121
+ *
122
+ * The obvious implementation authorises `terminal.open` and then trusts the session. That leaves a
123
+ * live shell reachable by a socket opened BEFORE the address changed - and, on this server, the
124
+ * browser is same-origin on whatever host it was opened from, so a user who tunnels in, opens a
125
+ * terminal, and then forwards their port to a colleague has silently widened the audience for every
126
+ * keystroke that follows.
127
+ *
128
+ * `isTrustedPeer` is re-evaluated per command. It is a pure function of the socket's remote address,
129
+ * so the cost is one predicate per keystroke and the guarantee is that no session outlives its
130
+ * socket's authorisation.
131
+ */
132
+ private assertUsable;
133
+ handle(command: TerminalCommand, remoteAddress: string | undefined, send: (event: ServerEvent) => void): Promise<void>;
134
+ private enqueue;
135
+ private flush;
136
+ private kill;
137
+ /** Every shell, for shutdown. Exits without reporting anything - the server is going away. */
138
+ closeAll(): void;
139
+ }
@@ -0,0 +1,366 @@
1
+ /**
2
+ * The right rail's terminal, as a SERVER-SIDE SESSION MANAGER.
3
+ *
4
+ * ---------------------------------------------------------------------------------------------
5
+ * WHY THIS FILE EXISTS AT ALL, stated plainly because it is the most consequential file in the project
6
+ * ---------------------------------------------------------------------------------------------
7
+ *
8
+ * Everything else in Super Pi is a control plane for a MODEL. This file hands a browser a SHELL on the
9
+ * machine Super Pi runs on. That is categorically different in kind, not in degree: a chat message can
10
+ * at worst make the model write a file inside a workspace, whereas one keystroke here runs whatever the
11
+ * user typed, with the full privileges of the server process, with no workspace containment and no
12
+ * capability scope.
13
+ *
14
+ * The server binds `0.0.0.0` by default (see `index.ts`), which exists so a phone on the same Wi-Fi can
15
+ * reach Super Pi without an SSH tunnel. Combined with a shell, that turns "anyone on the LAN who can
16
+ * open a web page" into "anyone on the LAN who can open a web page has a root-or-you shell". The
17
+ * project already knows this shape of problem - `http-server.ts` guards file writes and uploads behind
18
+ * `isLocalOrPrivateAddress` for exactly this reason - but a file write is bounded by the workspace root
19
+ * and this is not.
20
+ *
21
+ * So the guards here are deliberately redundant, and each one closes a hole the others do not:
22
+ *
23
+ * 1. OPT-IN, OFF BY DEFAULT. `SUPER_PI_TERMINAL` must be set to `1`/`true`/`on` before a single shell
24
+ * is spawned. The default install has no terminal at all - the tab is not even rendered. Enabling
25
+ * it is a deliberate act by someone who knows what it does.
26
+ * 2. NETWORK SCOPE. Even enabled, a command is refused unless the WebSocket arrived from loopback or
27
+ * an RFC 1918 / link-local / ULA address (`isLocalOrPrivateAddress`, the same predicate the file
28
+ * endpoints use). A request that arrives from a routed or proxied address is refused by name.
29
+ * 3. THE BROWSER NEVER CHOOSES THE WORKING DIRECTORY. `terminal.open` carries a WORKSPACE ID and
30
+ * nothing else; this file resolves the id against the store and spawns in that workspace's path.
31
+ * A browser that asked for `C:\` or `/etc` gets its own workspace's directory instead, because
32
+ * there is no code path on which a client-supplied path reaches `spawn`.
33
+ * 4. BOUNDED SESSIONS. `MAX_SESSIONS` shells, one per client tab, capped so a loop of reconnects
34
+ * cannot accumulate processes.
35
+ *
36
+ * What is NOT here is a per-command allowlist. A terminal that refuses `rm` or `curl` is not a terminal,
37
+ * and pretending otherwise would be worse than the honest version: with (1) and (2) the operator has
38
+ * chosen to expose a shell to their own LAN, and from there every command is the operator's own.
39
+ *
40
+ * ---------------------------------------------------------------------------------------------
41
+ * WHY `node-pty` AND NOT `child_process.spawn`
42
+ * ---------------------------------------------------------------------------------------------
43
+ *
44
+ * `spawn("bash", { stdio: "pipe" })` gives a program three pipes and no terminal. Programs that ask
45
+ * whether stdout is a TTY take the "I am being piped" branch and behave differently: no colours, no
46
+ * progress bars, and `vim` / `top` / `less` refuse outright with "not a terminal". A terminal panel
47
+ * that cannot run those is a command runner wearing a terminal's clothes.
48
+ *
49
+ * `node-pty` allocates a real pseudo-terminal (ConPTY on Windows, `forkpty` elsewhere), which is the
50
+ * only way to get them. The cost is a native module, and it is paid only by operators who set
51
+ * `SUPER_PI_TERMINAL=1` - see `loadPty` below, which is reached lazily and never at import time.
52
+ */
53
+ import { createRequire } from "node:module";
54
+ import { existsSync } from "node:fs";
55
+ import { isLocalOrPrivateAddress } from "./http-server.js";
56
+ let ptyModule;
57
+ let ptyLoadFailure;
58
+ /**
59
+ * WHY THIS IS LAZY, AND WHY IT IS `createRequire` RATHER THAN `import`.
60
+ *
61
+ * `import("node-pty")` would be evaluated by the bundler/compiler as a real dynamic import of a package
62
+ * the project does not depend on by default, and its rejection is a load-time failure of the WHOLE
63
+ * server rather than of the terminal. The operator who never sets `SUPER_PI_TERMINAL` should not be
64
+ * able to tell that node-pty exists.
65
+ *
66
+ * `createRequire` sidesteps the module-resolution question entirely: this file is compiled to ESM by
67
+ * `tsconfig.server.json`, `node-pty` is CommonJS, and `require` is the one call that loads CJS from ESM
68
+ * without an interop wrapper. The result is cached in `ptyModule` so the native handle is resolved once.
69
+ *
70
+ * The failure is CACHED AS A STRING because a missing native module must produce the SAME refusal on
71
+ * every subsequent keystroke rather than a different native stack trace each time, and a missing
72
+ * terminal is a far better outcome than an unstable one.
73
+ */
74
+ function loadPty() {
75
+ if (ptyModule)
76
+ return ptyModule;
77
+ if (ptyLoadFailure)
78
+ return undefined;
79
+ try {
80
+ const require = createRequire(import.meta.url);
81
+ ptyModule = require("node-pty");
82
+ return ptyModule;
83
+ }
84
+ catch (error) {
85
+ ptyLoadFailure = error instanceof Error ? error.message : String(error);
86
+ return undefined;
87
+ }
88
+ }
89
+ /**
90
+ * The node-pty version this file is written against, and WHY IT IS NOT 1.1.0.
91
+ *
92
+ * node-pty 1.1.0's Windows `kill()` forks a helper process that calls `AttachConsole(shellPid)` in order
93
+ * to enumerate the console's process list so it can kill stragglers. When the console has already gone,
94
+ * `AttachConsole` fails, the helper throws an unhandled error, and - reproducibly, on Windows - the
95
+ * NODE PROCESS ITSELF dies with `STATUS_ENTRYPOINT_NOT_FOUND` (0xC0000139).
96
+ *
97
+ * That is not a recoverable bug in a server. A user clicking "close terminal" would take down every
98
+ * running conversation, every open WebSocket and the persistence handle with it. `scripts/
99
+ * terminal-pty.test.mjs` asserts the spawn/write/kill/respawn cycle end to end so a downgrade back to
100
+ * 1.1.0 fails a test instead of killing the server at runtime.
101
+ *
102
+ * 1.2.0-beta.15 replaces that kill path and survives it; the abnormal 0xC0000139 exit code it still
103
+ * reports for a KILLED shell is filtered in `onExit` below so it is never shown to a user as if it were
104
+ * a command's exit status.
105
+ */
106
+ const MINIMUM_PTY_VERSION = "1.2.0-beta.15";
107
+ /**
108
+ * WHICH SHELL, and why it is not `COMSPEC`.
109
+ *
110
+ * `process.env.COMSPEC` on Windows IS `cmd.exe`, which is what this function used to spawn - while the
111
+ * comment beside it claimed PowerShell. The mismatch was visible in practice: `cmd.exe` is a DOS-era
112
+ * console with a legacy code page, so a Chinese filename printed by `dir` arrives as mojibake and the
113
+ * prompt echoes the active code page. Super Pi's whole interface is Chinese, so a terminal that cannot
114
+ * print Chinese is not a working terminal for the people who would use it.
115
+ *
116
+ * PowerShell is therefore the default, chosen over `pwsh` by name because `powershell.exe` ships with
117
+ * every supported Windows version while `pwsh` is a separate install. `SUPER_PI_TERMINAL_SHELL` overrides
118
+ * it for anyone who wants `cmd.exe`, `pwsh`, or Git Bash.
119
+ *
120
+ * On Unix the user's own `$SHELL` is right and a login shell is requested, because the terminal should
121
+ * see the same PATH and aliases as the terminal they use elsewhere.
122
+ *
123
+ * The ARGS differ by family and are not an arbitrary detail: `-l` is a bash/sh login flag, and passing it
124
+ * to PowerShell would make the shell refuse to start.
125
+ */
126
+ function defaultShell(env = process.env) {
127
+ const override = env.SUPER_PI_TERMINAL_SHELL?.trim();
128
+ if (override) {
129
+ // Split on whitespace so an override can carry its own flags (`"C:\Program Files\Git\bin\bash.exe" -l`
130
+ // has both a spaced path and a flag). A quoted first token is honoured.
131
+ const parts = override.match(/"[^"]+"|\S+/g) ?? [];
132
+ const file = (parts.shift() ?? "").replace(/^"|"$/g, "");
133
+ if (file)
134
+ return { file, args: parts };
135
+ }
136
+ if (process.platform === "win32") {
137
+ return { file: env.COMSPEC?.toLowerCase().includes("pwsh") ? "pwsh.exe" : "powershell.exe", args: [] };
138
+ }
139
+ return { file: env.SHELL || "/bin/bash", args: ["-l"] };
140
+ }
141
+ // ---------------------------------------------------------------------------------------------
142
+ // Bounds
143
+ // ---------------------------------------------------------------------------------------------
144
+ /** One shell per browser tab, and a hard ceiling so reconnects cannot accumulate processes. */
145
+ export const MAX_SESSIONS = 8;
146
+ /** Below the client panel's own minimum, and small enough that a `ls` cannot stall the socket. */
147
+ const MIN_COLS = 20;
148
+ const MIN_ROWS = 5;
149
+ const MAX_COLS = 500;
150
+ const MAX_ROWS = 200;
151
+ /**
152
+ * OUTPUT IS COALESCED, NOT SENT PER CHUNK.
153
+ *
154
+ * A `yes` or a fast build writes thousands of `onData` callbacks per second. Forwarding each one as its
155
+ * own WebSocket frame makes the browser the bottleneck and lets a single command saturate the server's
156
+ * event loop. Chunks are queued and flushed on a timer, with a byte ceiling per frame so one flush
157
+ * cannot build a megabyte-long string.
158
+ *
159
+ * The ceiling is applied to the QUEUE, not to the flush, because dropping a partial ANSI escape sequence
160
+ * would corrupt the stream: the split is forced at the START of the next flush rather than mid-escape.
161
+ */
162
+ const FLUSH_INTERVAL_MS = 16;
163
+ const MAX_CHUNK_BYTES = 64 * 1024;
164
+ /** Hard per-session backlog. Beyond this the session is considered runaway and is killed. */
165
+ const MAX_BACKLOG_BYTES = 4 * 1024 * 1024;
166
+ /**
167
+ * Reads the opt-in flag. Anything other than an explicit truthy string leaves the terminal OFF, so a
168
+ * typo in a deployment config fails closed rather than silently exposing a shell.
169
+ */
170
+ export function isTerminalEnabled(env = process.env) {
171
+ const raw = env.SUPER_PI_TERMINAL?.trim().toLowerCase();
172
+ return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
173
+ }
174
+ /** The sentence a browser shows when the terminal is missing. Says WHICH of the three gates closed. */
175
+ export function terminalDisabledReason(env = process.env) {
176
+ if (!isTerminalEnabled(env))
177
+ return "终端未启用。设置 SUPER_PI_TERMINAL=1 后重启 Super Pi 即可使用。";
178
+ if (ptyLoadFailure)
179
+ return `node-pty 未能加载(${ptyLoadFailure})。请重新安装依赖,或确认 node-pty 版本不低于 ${MINIMUM_PTY_VERSION}。`;
180
+ return "";
181
+ }
182
+ export class TerminalService {
183
+ sessions = new Map();
184
+ workspaceFor;
185
+ /** Whether a command from this peer is allowed to touch a shell at all. */
186
+ isTrustedPeer;
187
+ constructor(options) {
188
+ this.workspaceFor = options.workspaceFor;
189
+ this.isTrustedPeer = options.isTrustedPeer;
190
+ this.enabled = options.enabled ?? isTerminalEnabled();
191
+ }
192
+ enabled;
193
+ /**
194
+ * WHY THE PEER CHECK LIVES ON EVERY COMMAND AND NOT ONLY ON `open`.
195
+ *
196
+ * The obvious implementation authorises `terminal.open` and then trusts the session. That leaves a
197
+ * live shell reachable by a socket opened BEFORE the address changed - and, on this server, the
198
+ * browser is same-origin on whatever host it was opened from, so a user who tunnels in, opens a
199
+ * terminal, and then forwards their port to a colleague has silently widened the audience for every
200
+ * keystroke that follows.
201
+ *
202
+ * `isTrustedPeer` is re-evaluated per command. It is a pure function of the socket's remote address,
203
+ * so the cost is one predicate per keystroke and the guarantee is that no session outlives its
204
+ * socket's authorisation.
205
+ */
206
+ assertUsable(remoteAddress, send) {
207
+ if (!this.enabled) {
208
+ send({ type: "terminal.status", enabled: false, reason: terminalDisabledReason() });
209
+ return false;
210
+ }
211
+ if (!loadPty()) {
212
+ send({ type: "terminal.status", enabled: false, reason: terminalDisabledReason() });
213
+ return false;
214
+ }
215
+ if (!isLocalOrPrivateAddress(remoteAddress)) {
216
+ // Named explicitly rather than folded into the disabled message: the operator who enabled the
217
+ // terminal and is reaching it from a non-private address needs to know which of the gates closed.
218
+ send({ type: "terminal.error", sessionId: "", message: "终端只接受来自本机或局域网地址的连接。" });
219
+ return false;
220
+ }
221
+ return true;
222
+ }
223
+ async handle(command, remoteAddress, send) {
224
+ if (!this.assertUsable(remoteAddress, send))
225
+ return;
226
+ if (command.type === "terminal.open") {
227
+ if (this.sessions.has(command.sessionId))
228
+ return;
229
+ if (this.sessions.size >= MAX_SESSIONS) {
230
+ send({ type: "terminal.error", sessionId: command.sessionId, message: `终端会话已达上限(${MAX_SESSIONS} 个),请先关闭一个。` });
231
+ return;
232
+ }
233
+ // The workspace id is the ONLY thing the browser supplies, and it is resolved here. A client that
234
+ // sends a path instead gets nothing - there is no overload that accepts one.
235
+ const workspace = this.workspaceFor(command.workspaceId);
236
+ if (!workspace) {
237
+ send({ type: "terminal.error", sessionId: command.sessionId, message: "工作区不存在,无法打开终端。" });
238
+ return;
239
+ }
240
+ // A workspace whose directory was deleted or renamed must not spawn a shell in `process.cwd()`,
241
+ // which would silently run the terminal in the SERVER's directory instead of an error.
242
+ if (!existsSync(workspace.path)) {
243
+ send({ type: "terminal.error", sessionId: command.sessionId, message: `工作区目录不存在:${workspace.path}` });
244
+ return;
245
+ }
246
+ const pty = loadPty();
247
+ const shell = defaultShell(process.env);
248
+ const child = pty.spawn(shell.file, shell.args, {
249
+ // `xterm-256color` is what makes a shell enable colour and line editing; the client understands
250
+ // the same terminfo name, so the two ends agree on what the escape sequences mean.
251
+ name: "xterm-256color",
252
+ cols: clamp(command.cols, MIN_COLS, MAX_COLS, 80),
253
+ rows: clamp(command.rows, MIN_ROWS, MAX_ROWS, 24),
254
+ cwd: workspace.path,
255
+ env: { ...process.env, TERM: "xterm-256color" },
256
+ });
257
+ const session = { pty: child, closed: false, killed: false, pending: "" };
258
+ this.sessions.set(command.sessionId, session);
259
+ child.onData((data) => this.enqueue(session, command.sessionId, data, send));
260
+ child.onExit(({ exitCode }) => {
261
+ this.flush(session, command.sessionId, send);
262
+ this.sessions.delete(command.sessionId);
263
+ // A killed shell on Windows reports 0xC0000139 (STATUS_ENTRYPOINT_NOT_FOUND) because that is how
264
+ // ConPTY tears down, not because a command failed. Reporting it verbatim would tell the user a
265
+ // command exited with a status no program can produce, so a session WE killed reports 0.
266
+ if (session.closed && session.killed)
267
+ return;
268
+ session.closed = true;
269
+ if (session.timer)
270
+ clearTimeout(session.timer);
271
+ send({ type: "terminal.exit", sessionId: command.sessionId, exitCode: session.killed ? 0 : exitCode });
272
+ });
273
+ return;
274
+ }
275
+ const session = this.sessions.get(command.sessionId);
276
+ if (!session) {
277
+ send({ type: "terminal.error", sessionId: command.sessionId, message: "终端会话已结束,请重新打开。" });
278
+ return;
279
+ }
280
+ if (command.type === "terminal.input") {
281
+ // A keystroke is at most a few bytes; the cap exists to stop a client from using `terminal.input`
282
+ // as an unbounded write into a shell, not to limit typing.
283
+ if (command.data.length > 64 * 1024)
284
+ return;
285
+ session.pty.write(command.data);
286
+ return;
287
+ }
288
+ if (command.type === "terminal.resize") {
289
+ try {
290
+ session.pty.resize(clamp(command.cols, MIN_COLS, MAX_COLS, 80), clamp(command.rows, MIN_ROWS, MAX_ROWS, 24));
291
+ }
292
+ catch {
293
+ // A resize against a shell that exited between the lookup and here is not an error worth a
294
+ // message; the exit event is already on its way.
295
+ }
296
+ return;
297
+ }
298
+ // terminal.close
299
+ this.kill(command.sessionId, session, send);
300
+ }
301
+ enqueue(session, sessionId, data, send) {
302
+ session.pending += data;
303
+ // Flush when a chunk arrives while no timer is pending, so output appears at interactive latency
304
+ // rather than waiting for the next chunk that may never come.
305
+ if (session.timer)
306
+ return;
307
+ session.timer = setTimeout(() => this.flush(session, sessionId, send), FLUSH_INTERVAL_MS);
308
+ }
309
+ flush(session, sessionId, send) {
310
+ if (session.timer) {
311
+ clearTimeout(session.timer);
312
+ session.timer = undefined;
313
+ }
314
+ if (!session.pending)
315
+ return;
316
+ if (session.pending.length > MAX_BACKLOG_BYTES) {
317
+ this.kill(sessionId, session, send);
318
+ send({ type: "terminal.error", sessionId, message: "输出量过大,已停止该终端会话。" });
319
+ return;
320
+ }
321
+ let data = session.pending;
322
+ session.pending = "";
323
+ if (data.length > MAX_CHUNK_BYTES)
324
+ data = data.slice(0, MAX_CHUNK_BYTES);
325
+ send({ type: "terminal.output", sessionId, data });
326
+ }
327
+ kill(sessionId, session, send) {
328
+ this.flush(session, sessionId, send);
329
+ session.closed = true;
330
+ session.killed = true;
331
+ if (session.timer) {
332
+ clearTimeout(session.timer);
333
+ session.timer = undefined;
334
+ }
335
+ this.sessions.delete(sessionId);
336
+ try {
337
+ session.pty.kill();
338
+ }
339
+ catch {
340
+ // A shell that already exited makes `kill()` throw on some platforms. The session is removed
341
+ // either way, so there is nothing left to leak.
342
+ }
343
+ send({ type: "terminal.exit", sessionId, exitCode: 0 });
344
+ }
345
+ /** Every shell, for shutdown. Exits without reporting anything - the server is going away. */
346
+ closeAll() {
347
+ for (const [sessionId, session] of this.sessions) {
348
+ session.closed = true;
349
+ session.killed = true;
350
+ if (session.timer)
351
+ clearTimeout(session.timer);
352
+ try {
353
+ session.pty.kill();
354
+ }
355
+ catch {
356
+ /* already gone */
357
+ }
358
+ this.sessions.delete(sessionId);
359
+ }
360
+ }
361
+ }
362
+ function clamp(value, min, max, fallback) {
363
+ if (typeof value !== "number" || !Number.isFinite(value))
364
+ return fallback;
365
+ return Math.min(max, Math.max(min, Math.floor(value)));
366
+ }
@@ -624,7 +624,40 @@ export type ExtensionUiAnswer = {
624
624
  export type ClientCommand = ClientCommandBody & {
625
625
  operationRequestId?: string;
626
626
  };
627
- export type ClientCommandBody = {
627
+ export type ClientCommandBody =
628
+ /**
629
+ * THE TERMINAL, and the reason `terminal.open` CARRIES NO PATH.
630
+ *
631
+ * Every other command in this union that names a location lets the browser supply it. This one does
632
+ * not: it carries a workspace ID, and the server resolves that ID to a directory it already trusts
633
+ * (`terminal.ts`). A browser that asked for `C:\Windows` gets a refusal, because there is no field on
634
+ * this command for it to arrive in. That is the difference between a shell scoped to the workspaces
635
+ * the user already configured and a shell scoped to the whole disk, and it is structural rather than a
636
+ * check that a later edit could forget.
637
+ */
638
+ {
639
+ type: "terminal.open";
640
+ sessionId: string;
641
+ workspaceId: string;
642
+ cols?: number;
643
+ rows?: number;
644
+ }
645
+ /** Keystrokes and pasted text, as the RAW bytes a terminal would receive. */
646
+ | {
647
+ type: "terminal.input";
648
+ sessionId: string;
649
+ data: string;
650
+ }
651
+ /** The panel's measured character grid, so reflowed text reflows in the shell rather than wrapping twice. */
652
+ | {
653
+ type: "terminal.resize";
654
+ sessionId: string;
655
+ cols: number;
656
+ rows: number;
657
+ } | {
658
+ type: "terminal.close";
659
+ sessionId: string;
660
+ } | {
628
661
  type: "model.rules.set";
629
662
  rules: ModelFilterRule[];
630
663
  } | {
@@ -1006,4 +1039,38 @@ export type ServerEvent = {
1006
1039
  | ({
1007
1040
  type: "pi.queue";
1008
1041
  conversationId: string;
1009
- } & PiQueueSnapshot);
1042
+ } & PiQueueSnapshot)
1043
+ /**
1044
+ * Bytes from a terminal shell, as ONE event per coalescing window rather than one per `onData` call.
1045
+ *
1046
+ * `data` is RAW, including escape sequences, and is deliberately NOT modelled as a buffer of lines:
1047
+ * the point of `node-pty` is that the shell knows it is attached to a TTY, so it emits colours,
1048
+ * cursor moves and redraws that a line buffer cannot represent. The client owns rendering them.
1049
+ *
1050
+ * A failed spawn is a separate `terminal.error` rather than a `terminal.exit`, because a shell that
1051
+ * refused to start is a configuration problem the user must read, and an exit code would let them
1052
+ * scroll past it.
1053
+ */
1054
+ | {
1055
+ type: "terminal.output";
1056
+ sessionId: string;
1057
+ data: string;
1058
+ }
1059
+ /** The shell ended. `exitCode` is 0 whenever the server killed the session - see `terminal.ts`. */
1060
+ | {
1061
+ type: "terminal.exit";
1062
+ sessionId: string;
1063
+ exitCode: number;
1064
+ }
1065
+ /** A refused command, with a sentence naming WHICH gate refused it. */
1066
+ | {
1067
+ type: "terminal.error";
1068
+ sessionId: string;
1069
+ message: string;
1070
+ }
1071
+ /** Whether the terminal exists at all, and if not, why. Sent on a refused command. */
1072
+ | {
1073
+ type: "terminal.status";
1074
+ enabled: boolean;
1075
+ reason?: string;
1076
+ };
@@ -77,6 +77,8 @@ export declare class WorkflowEngine {
77
77
  stopConversation(conversationId: string): number;
78
78
  /** Aborts every plan in flight. Called on shutdown, so no child outlives the server that owns it. */
79
79
  abortAll(): void;
80
+ /** Waits until aborted plans have committed their terminal state. */
81
+ waitForIdle(): Promise<void>;
80
82
  /** The conversation a plan belongs to, which only this map knows while the plan is running. */
81
83
  private conversationOf;
82
84
  /**
@@ -50,6 +50,12 @@ export class WorkflowEngine {
50
50
  for (const controller of this.controllers.values())
51
51
  controller.abort();
52
52
  }
53
+ /** Waits until aborted plans have committed their terminal state. */
54
+ async waitForIdle() {
55
+ while (this.controllers.size > 0) {
56
+ await new Promise((resolve) => setTimeout(resolve, 10));
57
+ }
58
+ }
53
59
  /** The conversation a plan belongs to, which only this map knows while the plan is running. */
54
60
  conversationOf(workflowId) {
55
61
  return this.owners.get(workflowId);
@@ -26,6 +26,16 @@ export interface WsServerOptions {
26
26
  catalogService: CatalogService;
27
27
  accessToken?: string;
28
28
  rootDir?: string;
29
+ /**
30
+ * Terminal sessions this connection opened, so they can be killed when the socket goes away.
31
+ *
32
+ * An ORPHANED SHELL IS THE FAILURE MODE THIS EXISTS TO PREVENT. A terminal outlives its tab by
33
+ * default: close the browser tab, or lose the Wi-Fi, or hit refresh mid-build, and the PTY keeps
34
+ * running with nobody reading its output. On a laptop that is a battery drain; on a machine running
35
+ * a long `npm install` it is a process nobody can interrupt from the UI any more. Tracking the ids
36
+ * per connection makes "the socket closed" and "the shells it started are gone" the same event.
37
+ */
38
+ onSocketClose?: (terminalSessionIds: readonly string[]) => void;
29
39
  }
30
40
  /**
31
41
  * WebSocket transport manager handling upgrade, connection lifecycles, and command dispatch.
@@ -37,6 +47,7 @@ export declare class WsServer {
37
47
  private readonly catalogService;
38
48
  private readonly accessToken?;
39
49
  private readonly rootDir?;
50
+ private readonly onSocketClose?;
40
51
  constructor(options: WsServerOptions);
41
52
  private setupUpgrade;
42
53
  private setupConnectionHandling;