dsh-better-sidebar 0.10.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/LICENSE +21 -0
- package/README.md +139 -0
- package/README_EN.md +139 -0
- package/lib/client-docx.js +7649 -0
- package/lib/client-editor.js +32677 -0
- package/lib/client-pptx.js +63438 -0
- package/lib/client-registry.js +8021 -0
- package/lib/client-terminal.js +8060 -0
- package/lib/client-xlsx.js +359755 -0
- package/lib/client.js +8021 -0
- package/lib/index.js +2604 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +220 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +13 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +10 -0
- package/lib/types/client/ExplorerView.d.ts +9 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/PptxView.d.ts +6 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +27 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +38 -0
- package/lib/types/client/TerminalView.d.ts +8 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/api.d.ts +170 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +17 -0
- package/lib/types/client/builtins/tabs.d.ts +6 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +74 -0
- package/lib/types/client/chunks/docx.d.ts +8 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/pptx.d.ts +8 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/chunks/xlsx.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/docx-view.d.ts +8 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/icons.d.ts +47 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +10 -0
- package/lib/types/client/intercept.d.ts +20 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +35 -0
- package/lib/types/client/locales.d.ts +237 -0
- package/lib/types/client/office-shared.d.ts +33 -0
- package/lib/types/client/office-types.d.ts +36 -0
- package/lib/types/client/office-view.d.ts +12 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +19 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/prefs.d.ts +30 -0
- package/lib/types/client/produced-files.d.ts +27 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +214 -0
- package/lib/types/client/split-pane.d.ts +29 -0
- package/lib/types/client/state.d.ts +322 -0
- package/lib/types/client/subagent-activity.d.ts +35 -0
- package/lib/types/client/subagent-detect.d.ts +47 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/theme.d.ts +28 -0
- package/lib/types/client/xlsx-to-univer.d.ts +33 -0
- package/lib/types/client/xlsx-view.d.ts +10 -0
- package/lib/types/config.d.ts +41 -0
- package/lib/types/context-types.d.ts +431 -0
- package/lib/types/fs-tree.d.ts +41 -0
- package/lib/types/git.d.ts +75 -0
- package/lib/types/html-route.d.ts +48 -0
- package/lib/types/index.d.ts +39 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/prefs-shared.d.ts +106 -0
- package/lib/types/pty-manager.d.ts +76 -0
- package/lib/types/tools.d.ts +28 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +168 -0
- package/src/agent-pty.ts +519 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +130 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +266 -0
- package/src/client/EditorHost.tsx +101 -0
- package/src/client/ExplorerView.tsx +283 -0
- package/src/client/GitView.tsx +540 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/PptxView.tsx +131 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +271 -0
- package/src/client/SideCardSection.tsx +426 -0
- package/src/client/Sidebar.tsx +861 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +866 -0
- package/src/client/TabBar.tsx +188 -0
- package/src/client/TerminalView.tsx +233 -0
- package/src/client/TextEditor.tsx +382 -0
- package/src/client/api.ts +215 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +33 -0
- package/src/client/builtins/tabs.tsx +211 -0
- package/src/client/builtins/viewers.tsx +151 -0
- package/src/client/chunk-loader.ts +196 -0
- package/src/client/chunks/docx.tsx +8 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/pptx.tsx +8 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/chunks/xlsx.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/docx-view.tsx +113 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/icons.tsx +137 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +238 -0
- package/src/client/intercept.tsx +94 -0
- package/src/client/lang.ts +95 -0
- package/src/client/layout.css +54 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +66 -0
- package/src/client/locales.ts +476 -0
- package/src/client/office-shared.tsx +40 -0
- package/src/client/office-types.ts +48 -0
- package/src/client/office-view.tsx +12 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +28 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/prefs.ts +100 -0
- package/src/client/produced-files.ts +87 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +434 -0
- package/src/client/sidebar.module.css +1917 -0
- package/src/client/split-pane.tsx +292 -0
- package/src/client/state.ts +1048 -0
- package/src/client/subagent-activity.ts +67 -0
- package/src/client/subagent-detect.ts +125 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/theme.ts +44 -0
- package/src/client/xlsx-to-univer.ts +260 -0
- package/src/client/xlsx-view.tsx +116 -0
- package/src/config.ts +95 -0
- package/src/context-types.ts +445 -0
- package/src/fs-tree.ts +115 -0
- package/src/git.ts +236 -0
- package/src/html-route.ts +70 -0
- package/src/index.ts +897 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/prefs-shared.ts +127 -0
- package/src/pty-manager.ts +199 -0
- package/src/tools.ts +477 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +94 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,2604 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { mkdir, open, opendir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
5
|
+
import z from "schemastery";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { SettingsConflictError, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
10
|
+
import { chmodSync, existsSync } from "node:fs";
|
|
11
|
+
import * as nodePty from "node-pty";
|
|
12
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
13
|
+
//#region src/prefs-shared.ts
|
|
14
|
+
/**
|
|
15
|
+
* Shared "Side card" preference vocabulary (types + constants), consumed by
|
|
16
|
+
* BOTH halves: the host registers the schemastery schema over these values
|
|
17
|
+
* (config.ts) and the client reads/writes them through the settings RPC
|
|
18
|
+
* (client/prefs.ts, client/SideCardSection.tsx). Kept free of schemastery so
|
|
19
|
+
* the browser bundle never pulls the schema runtime in.
|
|
20
|
+
*/
|
|
21
|
+
/** The user-settings namespace holding the side card preferences. */
|
|
22
|
+
const SIDEBAR_PREFS_NS = "dsh-better-sidebar";
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/config.ts
|
|
25
|
+
/**
|
|
26
|
+
* Serializable configuration and defaults for the sidebar host half. Loader
|
|
27
|
+
* schema validation normally fills defaults; {@link resolveSidebarConfig}
|
|
28
|
+
* applies the same defaults for direct callers that bypass the Loader.
|
|
29
|
+
* @module dsh-better-sidebar/config
|
|
30
|
+
*/
|
|
31
|
+
/** Schemastery schema for the plugin configuration. */
|
|
32
|
+
const Config = z.object({
|
|
33
|
+
readLimit: z.number().step(1).min(1).default(524288),
|
|
34
|
+
mediaLimit: z.number().step(1).min(1).default(20971520),
|
|
35
|
+
listLimit: z.number().step(1).min(1).default(1e3),
|
|
36
|
+
terminalsPerSession: z.number().step(1).min(1).default(3),
|
|
37
|
+
reconnectGraceMs: z.number().step(1).min(0).default(3e4)
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* Apply direct-call defaults after Loader schema validation has normally run.
|
|
41
|
+
*
|
|
42
|
+
* @param config - Deployment-provided sidebar host settings.
|
|
43
|
+
* @returns Complete settings consumed by the host half.
|
|
44
|
+
*/
|
|
45
|
+
function resolveSidebarConfig(config) {
|
|
46
|
+
return {
|
|
47
|
+
readLimit: config?.readLimit ?? 524288,
|
|
48
|
+
mediaLimit: config?.mediaLimit ?? 20971520,
|
|
49
|
+
listLimit: config?.listLimit ?? 1e3,
|
|
50
|
+
terminalsPerSession: config?.terminalsPerSession ?? 3,
|
|
51
|
+
reconnectGraceMs: config?.reconnectGraceMs ?? 3e4
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Schemastery schema for the user-facing preferences (validated by the settings service). */
|
|
55
|
+
const PrefsSchema = z.object({
|
|
56
|
+
openByDefault: z.boolean().default(true),
|
|
57
|
+
defaultWidthPercent: z.number().step(1).min(20).max(60).default(30),
|
|
58
|
+
autoOpenSubagent: z.boolean().default(true),
|
|
59
|
+
autoOpenJobs: z.boolean().default(true),
|
|
60
|
+
agentTerminalTools: z.boolean().default(false),
|
|
61
|
+
bottomPanelAutoTerminal: z.boolean().default(true),
|
|
62
|
+
interceptOpenPath: z.boolean().default(true),
|
|
63
|
+
htmlViewerNoSandbox: z.boolean().default(false),
|
|
64
|
+
htmlViewerDefaultUnsafe: z.boolean().default(false),
|
|
65
|
+
browserNoSandbox: z.boolean().default(false),
|
|
66
|
+
browserInterceptLinks: z.boolean().default(true),
|
|
67
|
+
tabsEnabled: z.dict(z.boolean()).default({}),
|
|
68
|
+
viewersEnabled: z.dict(z.boolean()).default({})
|
|
69
|
+
});
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/wire.ts
|
|
72
|
+
/** One API failure with its wire code and HTTP status. */
|
|
73
|
+
var SidebarError = class extends Error {
|
|
74
|
+
code;
|
|
75
|
+
status;
|
|
76
|
+
constructor(code, message, status = 400) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.code = code;
|
|
79
|
+
this.status = status;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
/** Body size bound of one JSON request (defense against unbounded reads). */
|
|
83
|
+
const MAX_BODY_BYTES = 1 << 20;
|
|
84
|
+
/** Read and parse the JSON request body (bounded; malformed → bad-request). */
|
|
85
|
+
async function readJsonBody(req) {
|
|
86
|
+
const chunks = [];
|
|
87
|
+
let total = 0;
|
|
88
|
+
for await (const chunk of req) {
|
|
89
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
90
|
+
total += buffer.length;
|
|
91
|
+
if (total > MAX_BODY_BYTES) throw new SidebarError("bad-request", "request body too large");
|
|
92
|
+
chunks.push(buffer);
|
|
93
|
+
}
|
|
94
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
95
|
+
if (text.trim() === "") return {};
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(text);
|
|
98
|
+
} catch {
|
|
99
|
+
throw new SidebarError("bad-request", "request body is not valid JSON");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Write a JSON response with the given status. */
|
|
103
|
+
function writeJson(res, status, body) {
|
|
104
|
+
const payload = JSON.stringify(body);
|
|
105
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
106
|
+
res.end(payload);
|
|
107
|
+
}
|
|
108
|
+
/** Write the success envelope. */
|
|
109
|
+
function writeOk(res, value) {
|
|
110
|
+
writeJson(res, 200, {
|
|
111
|
+
ok: true,
|
|
112
|
+
value
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** Write the failure envelope for any thrown value (unknown → internal 500). */
|
|
116
|
+
function writeError(res, error) {
|
|
117
|
+
if (error instanceof SidebarError) {
|
|
118
|
+
writeJson(res, error.status, {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: {
|
|
121
|
+
code: error.code,
|
|
122
|
+
message: error.message
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
writeJson(res, 500, {
|
|
128
|
+
ok: false,
|
|
129
|
+
error: {
|
|
130
|
+
code: "internal",
|
|
131
|
+
message: error instanceof Error ? error.message : String(error)
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/** Narrow an unknown payload value to a string, else throw bad-request. */
|
|
136
|
+
function requireString(payload, key) {
|
|
137
|
+
const value = payload?.[key];
|
|
138
|
+
if (typeof value !== "string" || value === "") throw new SidebarError("bad-request", `missing or invalid "${key}"`);
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/fs-tree.ts
|
|
143
|
+
/**
|
|
144
|
+
* Single-level directory listing for the sidebar explorer. Streams the level
|
|
145
|
+
* with opendir, sorts directories first then names (case-insensitive), and
|
|
146
|
+
* marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
|
|
147
|
+
* reported as files without probing their target — the explorer shows what
|
|
148
|
+
* dirent says, keeping the read cheap for arbitrarily large levels.
|
|
149
|
+
*/
|
|
150
|
+
/** Directory-first, case-insensitive name ordering (VSCode explorer order). */
|
|
151
|
+
function compareEntries(a, b) {
|
|
152
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
153
|
+
return a.name.localeCompare(b.name, void 0, { sensitivity: "base" });
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* List one directory level.
|
|
157
|
+
* @param path - absolute directory path.
|
|
158
|
+
* @param maxEntries - row bound of one level (extra rows flag `truncated`).
|
|
159
|
+
* @returns the sorted listing.
|
|
160
|
+
* @throws {SidebarError} fs-error when the level is unreadable or not a directory.
|
|
161
|
+
*/
|
|
162
|
+
async function listDirectory(path, maxEntries = 1e3) {
|
|
163
|
+
let level;
|
|
164
|
+
try {
|
|
165
|
+
level = await opendir(path);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
throw new SidebarError("fs-error", `cannot list "${path}": ${messageOf(error)}`, 400);
|
|
168
|
+
}
|
|
169
|
+
const rows = [];
|
|
170
|
+
let overflow = 0;
|
|
171
|
+
try {
|
|
172
|
+
for await (const dirent of level) {
|
|
173
|
+
if (rows.length >= maxEntries) {
|
|
174
|
+
overflow += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
rows.push({
|
|
178
|
+
name: dirent.name,
|
|
179
|
+
path: join(path, dirent.name),
|
|
180
|
+
isDir: dirent.isDirectory(),
|
|
181
|
+
hidden: dirent.name.startsWith(".")
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
throw new SidebarError("fs-error", `cannot list "${path}": ${messageOf(error)}`, 400);
|
|
186
|
+
}
|
|
187
|
+
rows.sort(compareEntries);
|
|
188
|
+
return {
|
|
189
|
+
path,
|
|
190
|
+
entries: rows,
|
|
191
|
+
truncated: overflow > 0
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
|
|
195
|
+
function rootLabel(path) {
|
|
196
|
+
const base = basename(path);
|
|
197
|
+
return base !== "" ? base : path;
|
|
198
|
+
}
|
|
199
|
+
/** Parent of a path, or undefined at the filesystem root (the explorer's "up" target). */
|
|
200
|
+
function parentOf(path) {
|
|
201
|
+
const parent = dirname(path);
|
|
202
|
+
return parent === path ? void 0 : parent;
|
|
203
|
+
}
|
|
204
|
+
/** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */
|
|
205
|
+
function requireAbsolute(path) {
|
|
206
|
+
if (!path.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(path)) throw new SidebarError("fs-error", `"${path}" is not an absolute path`, 400);
|
|
207
|
+
return resolve(path);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Whether `target` lies under `base` (or equals it), tolerant of separator
|
|
211
|
+
* style and — on Windows, where the filesystem is case-insensitive — of
|
|
212
|
+
* letter case. The media route uses this instead of a raw `startsWith` so a
|
|
213
|
+
* case-mismatched or mixed-separator path can never be misclassified
|
|
214
|
+
* (e.g. `C:\Users\Me` vs `c:/users/me/file.png`).
|
|
215
|
+
* @param platform - filesystem semantics; injectable so both branches are
|
|
216
|
+
* unit-testable on any host.
|
|
217
|
+
*/
|
|
218
|
+
function isWithin(base, target, platform = process.platform) {
|
|
219
|
+
const norm = (value) => value.replace(/[\\/]+/g, "/").replace(/\/$/, "");
|
|
220
|
+
const b = norm(base);
|
|
221
|
+
const t = norm(target);
|
|
222
|
+
if (platform === "win32") {
|
|
223
|
+
const lb = b.toLowerCase();
|
|
224
|
+
const lt = t.toLowerCase();
|
|
225
|
+
return lt === lb || lt.startsWith(`${lb}/`);
|
|
226
|
+
}
|
|
227
|
+
return t === b || t.startsWith(`${b}/`);
|
|
228
|
+
}
|
|
229
|
+
/** Message text of an unknown thrown value. */
|
|
230
|
+
function messageOf(error) {
|
|
231
|
+
return error instanceof Error ? error.message : String(error);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Decode a route pathname into the session + absolute file path. Rejects
|
|
235
|
+
* a wrong prefix (404), an empty or double-slash path, malformed percent
|
|
236
|
+
* encoding, and a missing sessionId or file path (400). The caller still
|
|
237
|
+
* must bound the decoded path with requireAbsolute + isWithin(cwd) — a
|
|
238
|
+
* decoded `..` segment resolves outside the cwd and is refused there.
|
|
239
|
+
*/
|
|
240
|
+
function decodeHtmlUrl(pathname) {
|
|
241
|
+
if (!pathname.startsWith("/sidebar/html/")) return {
|
|
242
|
+
ok: false,
|
|
243
|
+
status: 404,
|
|
244
|
+
message: "not an html route"
|
|
245
|
+
};
|
|
246
|
+
const rest = pathname.slice(14);
|
|
247
|
+
if (rest === "" || rest.includes("//")) return {
|
|
248
|
+
ok: false,
|
|
249
|
+
status: 400,
|
|
250
|
+
message: "invalid html route path"
|
|
251
|
+
};
|
|
252
|
+
let segments;
|
|
253
|
+
try {
|
|
254
|
+
segments = rest.split("/").map((segment) => decodeURIComponent(segment));
|
|
255
|
+
} catch {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
status: 400,
|
|
259
|
+
message: "malformed URL encoding"
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const [sessionId, ...pathSegments] = segments;
|
|
263
|
+
if (sessionId === void 0 || sessionId === "" || pathSegments.length === 0 || pathSegments.some((segment) => segment === "")) return {
|
|
264
|
+
ok: false,
|
|
265
|
+
status: 400,
|
|
266
|
+
message: "sessionId and file path are required"
|
|
267
|
+
};
|
|
268
|
+
return {
|
|
269
|
+
ok: true,
|
|
270
|
+
ref: {
|
|
271
|
+
sessionId,
|
|
272
|
+
path: `/${pathSegments.join("/")}`
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region src/browser-probe.ts
|
|
278
|
+
/**
|
|
279
|
+
* Pure helpers for the `browser.probe` route (sidebar browser): the host
|
|
280
|
+
* fetches the response HEADERS of a URL the user is browsing and the client
|
|
281
|
+
* decides whether the target site forbids being embedded (X-Frame-Options /
|
|
282
|
+
* CSP frame-ancestors are exactly the signals the browser enforces when it
|
|
283
|
+
* refuses an iframe load). Kept dependency-free so the parser is
|
|
284
|
+
* unit-testable.
|
|
285
|
+
*/
|
|
286
|
+
/**
|
|
287
|
+
* Extract the `frame-ancestors` source list of a Content-Security-Policy
|
|
288
|
+
* header, or undefined when the directive is absent (or empty). The
|
|
289
|
+
* directive is the only one with a source list; sources are space-separated
|
|
290
|
+
* tokens (`'none'`, `'self'`, `*`, or origins).
|
|
291
|
+
*/
|
|
292
|
+
function extractFrameAncestors(csp) {
|
|
293
|
+
if (csp === null) return void 0;
|
|
294
|
+
for (const directive of csp.split(";")) {
|
|
295
|
+
const parts = directive.trim().split(/\s+/);
|
|
296
|
+
if (parts[0] === "frame-ancestors") {
|
|
297
|
+
const sources = parts.slice(1).filter((source) => source !== "");
|
|
298
|
+
return sources.length === 0 ? void 0 : sources;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/trust-fence.ts
|
|
304
|
+
function header(headers, name) {
|
|
305
|
+
const value = headers[name];
|
|
306
|
+
return typeof value === "string" ? value : void 0;
|
|
307
|
+
}
|
|
308
|
+
/** Normalized URL of a Host-header authority, or undefined when unparsable. */
|
|
309
|
+
function parseAuthority(authority) {
|
|
310
|
+
try {
|
|
311
|
+
return new URL(`http://${authority}`);
|
|
312
|
+
} catch {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** Whether a normalized URL hostname names the local loopback authority. */
|
|
317
|
+
function isLoopbackHostname(hostname) {
|
|
318
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
319
|
+
const parts = hostname.split(".");
|
|
320
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
321
|
+
}
|
|
322
|
+
/** Canonical authority form: hostname, or hostname:port when a port was written. */
|
|
323
|
+
function canonicalAuthority(entry, entryUrl) {
|
|
324
|
+
const port = entryUrl.port !== "" ? entryUrl.port : new URL(`https://${entry}`).port;
|
|
325
|
+
return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
|
|
326
|
+
}
|
|
327
|
+
/** Whether the request authority matches a trustedHosts entry (exact or port-less). */
|
|
328
|
+
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
329
|
+
return trustedHosts.some((entry) => {
|
|
330
|
+
const entryUrl = parseAuthority(entry);
|
|
331
|
+
if (entryUrl === void 0) return false;
|
|
332
|
+
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host;
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Decide whether one sidebar request may reach the plugin routes.
|
|
337
|
+
* @param request - node HTTP request facts (headers).
|
|
338
|
+
* @param trustedHosts - non-loopback authorities this deployment serves.
|
|
339
|
+
* @returns true when the Host is ours (loopback or trusted) and browser markers are same-origin.
|
|
340
|
+
*/
|
|
341
|
+
function isTrustedApiRequest(request, trustedHosts) {
|
|
342
|
+
const host = header(request.headers, "host");
|
|
343
|
+
if (host === void 0) return false;
|
|
344
|
+
const hostUrl = parseAuthority(host);
|
|
345
|
+
if (hostUrl === void 0) return false;
|
|
346
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
|
|
347
|
+
if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
|
|
348
|
+
const origin = header(request.headers, "origin");
|
|
349
|
+
if (origin === void 0) return true;
|
|
350
|
+
try {
|
|
351
|
+
return new URL(origin).host === hostUrl.host;
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
//#endregion
|
|
357
|
+
//#region src/bundle-route.ts
|
|
358
|
+
/**
|
|
359
|
+
* Lazy chunk route: serves the client bundle's chunk scripts
|
|
360
|
+
* (/sidebar/bundle/<name>.js). The official /plugins/<id>/client.js route
|
|
361
|
+
* cannot serve arbitrary file names, so the plugin serves its own split
|
|
362
|
+
* bundles (lib/client-<name>.js) here; the client injects the script on
|
|
363
|
+
* first use of the feature that needs it (see src/client/chunk-loader.ts).
|
|
364
|
+
*
|
|
365
|
+
* Caching contract: every response carries `cache-control: no-cache` plus an
|
|
366
|
+
* ETag (content hash, memoized per file by mtime/size) and honors
|
|
367
|
+
* If-None-Match — the browser revalidates each fetch, but a 304 avoids
|
|
368
|
+
* re-downloading multi-MB chunks that did not change (page refresh, HMR
|
|
369
|
+
* re-activation). Same browser-trust fence as every other /sidebar route;
|
|
370
|
+
* only allowlisted chunk names are servable (no path traversal).
|
|
371
|
+
*/
|
|
372
|
+
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
|
|
373
|
+
const CHUNK_NAMES = [
|
|
374
|
+
"docx",
|
|
375
|
+
"xlsx",
|
|
376
|
+
"pptx",
|
|
377
|
+
"terminal",
|
|
378
|
+
"editor"
|
|
379
|
+
];
|
|
380
|
+
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
|
|
381
|
+
const LIB_DIR = dirname(fileURLToPath(import.meta.url));
|
|
382
|
+
/** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
|
|
383
|
+
function shortHash(input) {
|
|
384
|
+
return createHash("sha1").update(input).digest("hex").slice(0, 12);
|
|
385
|
+
}
|
|
386
|
+
/** ETag memo: recompute the content hash only when the file's stat changed. */
|
|
387
|
+
const etags = /* @__PURE__ */ new Map();
|
|
388
|
+
/**
|
|
389
|
+
* The chunk file's ETag (quoted hash), or undefined when the file is
|
|
390
|
+
* missing. Hash is recomputed only when mtime/size changed (hashing a
|
|
391
|
+
* multi-MB chunk per request is wasteful).
|
|
392
|
+
*/
|
|
393
|
+
async function etagOf(name, chunkDir) {
|
|
394
|
+
const path = join(chunkDir, `client-${name}.js`);
|
|
395
|
+
const key = `${chunkDir}:${name}`;
|
|
396
|
+
try {
|
|
397
|
+
const info = await stat(path);
|
|
398
|
+
const memo = etags.get(key);
|
|
399
|
+
if (memo !== void 0 && memo.mtimeMs === info.mtimeMs && memo.size === info.size) return memo.etag;
|
|
400
|
+
const etag = `"${shortHash(await readFile(path))}"`;
|
|
401
|
+
etags.set(key, {
|
|
402
|
+
mtimeMs: info.mtimeMs,
|
|
403
|
+
size: info.size,
|
|
404
|
+
etag
|
|
405
|
+
});
|
|
406
|
+
return etag;
|
|
407
|
+
} catch {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Build the /sidebar/bundle route handler. `fence` is the shared browser-
|
|
413
|
+
* trust check every /sidebar route applies; `chunkDir` is the directory the
|
|
414
|
+
* chunk scripts live in (overridable for tests).
|
|
415
|
+
*/
|
|
416
|
+
function createBundleRouteHandler(fence, chunkDir = LIB_DIR) {
|
|
417
|
+
return async (req, res) => {
|
|
418
|
+
if (!fence(req)) {
|
|
419
|
+
res.writeHead(403);
|
|
420
|
+
res.end("forbidden");
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
424
|
+
res.writeHead(405);
|
|
425
|
+
res.end();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
|
|
429
|
+
const name = /^\/sidebar\/bundle\/([a-z0-9-]+)\.js$/.exec(pathname)?.[1];
|
|
430
|
+
if (name === void 0 || !CHUNK_NAMES.includes(name)) {
|
|
431
|
+
res.writeHead(404);
|
|
432
|
+
res.end("not found");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const etag = await etagOf(name, chunkDir);
|
|
436
|
+
if (etag === void 0) {
|
|
437
|
+
res.writeHead(404);
|
|
438
|
+
res.end("not found");
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (req.headers["if-none-match"] === etag) {
|
|
442
|
+
res.writeHead(304, {
|
|
443
|
+
"cache-control": "no-cache",
|
|
444
|
+
etag
|
|
445
|
+
});
|
|
446
|
+
res.end();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
const body = await readFile(join(chunkDir, `client-${name}.js`));
|
|
451
|
+
res.writeHead(200, {
|
|
452
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
453
|
+
"cache-control": "no-cache",
|
|
454
|
+
etag
|
|
455
|
+
});
|
|
456
|
+
res.end(body);
|
|
457
|
+
} catch {
|
|
458
|
+
res.writeHead(404);
|
|
459
|
+
res.end("not found");
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/** Register the /sidebar/bundle route (disposed with the fiber). */
|
|
464
|
+
function registerBundleRoute(ctx, fence) {
|
|
465
|
+
return ctx.webServer.register({
|
|
466
|
+
kind: "prefix",
|
|
467
|
+
path: "/sidebar/bundle",
|
|
468
|
+
handler: createBundleRouteHandler(fence)
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
//#endregion
|
|
472
|
+
//#region src/git.ts
|
|
473
|
+
/**
|
|
474
|
+
* Git operations for the sidebar source-control panel. Everything goes
|
|
475
|
+
* through the system `git` binary spawned per request (no library, no state),
|
|
476
|
+
* with porcelain-parseable output formats (`-z` NUL framing, unit separators)
|
|
477
|
+
* so parsing never depends on locale or color config. All commands run with
|
|
478
|
+
* `-C <cwd>` on the session's working directory and `--no-pager` /
|
|
479
|
+
* `-c color.ui=false` so output stays machine-readable.
|
|
480
|
+
*
|
|
481
|
+
* Commits use the user's git global identity untouched (never sets
|
|
482
|
+
* user.name/user.email).
|
|
483
|
+
*/
|
|
484
|
+
/** One git failure (stderr text as the message). */
|
|
485
|
+
var GitCommandError = class extends Error {
|
|
486
|
+
code;
|
|
487
|
+
command;
|
|
488
|
+
constructor(message, code = "git-error", command) {
|
|
489
|
+
super(message);
|
|
490
|
+
this.code = code;
|
|
491
|
+
this.command = command;
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
/** Parse porcelain v1 -z output into entries (rename/copy pairs collapse to one row). */
|
|
495
|
+
function parsePorcelainZ(output) {
|
|
496
|
+
const tokens = output.split("\0");
|
|
497
|
+
const entries = [];
|
|
498
|
+
let index = 0;
|
|
499
|
+
while (index < tokens.length) {
|
|
500
|
+
const token = tokens[index];
|
|
501
|
+
index += 1;
|
|
502
|
+
if (token === "") continue;
|
|
503
|
+
const xy = token.slice(0, 2);
|
|
504
|
+
const rest = token.slice(3);
|
|
505
|
+
entries.push({
|
|
506
|
+
path: rest,
|
|
507
|
+
xy
|
|
508
|
+
});
|
|
509
|
+
if ((xy[0] === "R" || xy[0] === "C") && tokens[index] !== void 0 && tokens[index] !== "") index += 1;
|
|
510
|
+
}
|
|
511
|
+
return entries;
|
|
512
|
+
}
|
|
513
|
+
/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
|
|
514
|
+
function parseLogLines(output) {
|
|
515
|
+
const rows = [];
|
|
516
|
+
for (const line of output.split("\n")) {
|
|
517
|
+
if (line === "") continue;
|
|
518
|
+
const [hash, subject, author, date, hashFull, refs] = line.split("");
|
|
519
|
+
if (hash === void 0 || subject === void 0) continue;
|
|
520
|
+
rows.push({
|
|
521
|
+
hash,
|
|
522
|
+
subject,
|
|
523
|
+
author: author ?? "",
|
|
524
|
+
date: date ?? "",
|
|
525
|
+
hashFull: hashFull ?? hash,
|
|
526
|
+
refs: refs ?? ""
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
return rows;
|
|
530
|
+
}
|
|
531
|
+
/** Run one git command; resolves with stdout, rejects with GitCommandError. */
|
|
532
|
+
function runGit(cwd, args, timeoutMs = 3e4) {
|
|
533
|
+
const full = [
|
|
534
|
+
"-C",
|
|
535
|
+
cwd,
|
|
536
|
+
"--no-pager",
|
|
537
|
+
"-c",
|
|
538
|
+
"color.ui=false",
|
|
539
|
+
...args
|
|
540
|
+
];
|
|
541
|
+
return new Promise((resolvePromise, reject) => {
|
|
542
|
+
const child = spawn("git", full, {
|
|
543
|
+
stdio: [
|
|
544
|
+
"ignore",
|
|
545
|
+
"pipe",
|
|
546
|
+
"pipe"
|
|
547
|
+
],
|
|
548
|
+
env: {
|
|
549
|
+
...process.env,
|
|
550
|
+
GIT_OPTIONAL_LOCKS: "0"
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
let stdout = "";
|
|
554
|
+
let stderr = "";
|
|
555
|
+
const timer = setTimeout(() => {
|
|
556
|
+
child.kill("SIGKILL");
|
|
557
|
+
reject(new GitCommandError(`git ${args[0] ?? ""} timed out after ${timeoutMs}ms`, "git-error", args.join(" ")));
|
|
558
|
+
}, timeoutMs);
|
|
559
|
+
child.stdout.on("data", (chunk) => {
|
|
560
|
+
stdout += chunk.toString("utf8");
|
|
561
|
+
});
|
|
562
|
+
child.stderr.on("data", (chunk) => {
|
|
563
|
+
stderr += chunk.toString("utf8");
|
|
564
|
+
});
|
|
565
|
+
child.on("error", (error) => {
|
|
566
|
+
clearTimeout(timer);
|
|
567
|
+
reject(new GitCommandError(`cannot run git: ${error.message}`, "git-error", args.join(" ")));
|
|
568
|
+
});
|
|
569
|
+
child.on("close", (code) => {
|
|
570
|
+
clearTimeout(timer);
|
|
571
|
+
if (code === 0) resolvePromise(stdout);
|
|
572
|
+
else reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, "git-error", args.join(" ")));
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
/** Whether the directory is inside a git work tree (exit-0 `git rev-parse`). */
|
|
577
|
+
async function isGitRepo(cwd) {
|
|
578
|
+
try {
|
|
579
|
+
return (await runGit(cwd, ["rev-parse", "--is-inside-work-tree"])).trim() === "true";
|
|
580
|
+
} catch {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
|
|
585
|
+
async function repoRoot(cwd) {
|
|
586
|
+
return (await runGit(cwd, ["rev-parse", "--show-toplevel"])).trim();
|
|
587
|
+
}
|
|
588
|
+
/** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
|
|
589
|
+
async function currentBranch(cwd) {
|
|
590
|
+
return (await runGit(cwd, [
|
|
591
|
+
"rev-parse",
|
|
592
|
+
"--abbrev-ref",
|
|
593
|
+
"HEAD"
|
|
594
|
+
])).trim();
|
|
595
|
+
}
|
|
596
|
+
/** Working-tree status (untracked included). */
|
|
597
|
+
async function status(cwd) {
|
|
598
|
+
if (!await isGitRepo(cwd)) return {
|
|
599
|
+
isRepo: false,
|
|
600
|
+
entries: []
|
|
601
|
+
};
|
|
602
|
+
const [branch, raw] = await Promise.all([currentBranch(cwd).catch(() => "HEAD"), runGit(cwd, [
|
|
603
|
+
"status",
|
|
604
|
+
"--porcelain=v1",
|
|
605
|
+
"-z",
|
|
606
|
+
"--untracked-files=normal"
|
|
607
|
+
])]);
|
|
608
|
+
return {
|
|
609
|
+
isRepo: true,
|
|
610
|
+
branch,
|
|
611
|
+
entries: parsePorcelainZ(raw)
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/** Diff text of the worktree (unstaged) or the index (staged). */
|
|
615
|
+
async function diff(cwd, path, staged) {
|
|
616
|
+
const args = [
|
|
617
|
+
"diff",
|
|
618
|
+
"--no-ext-diff",
|
|
619
|
+
"--no-color",
|
|
620
|
+
"-U3"
|
|
621
|
+
];
|
|
622
|
+
if (staged) args.push("--cached");
|
|
623
|
+
if (path !== void 0) args.push("--", path);
|
|
624
|
+
return runGit(cwd, args);
|
|
625
|
+
}
|
|
626
|
+
/** Stage paths (all when path is undefined). */
|
|
627
|
+
async function stage(cwd, path) {
|
|
628
|
+
await runGit(cwd, [
|
|
629
|
+
"add",
|
|
630
|
+
"-A",
|
|
631
|
+
...path !== void 0 ? ["--", path] : []
|
|
632
|
+
]);
|
|
633
|
+
}
|
|
634
|
+
/** Unstage paths (all when path is undefined). */
|
|
635
|
+
async function unstage(cwd, path) {
|
|
636
|
+
await runGit(cwd, [
|
|
637
|
+
"reset",
|
|
638
|
+
"-q",
|
|
639
|
+
...path !== void 0 ? ["--", path] : []
|
|
640
|
+
]);
|
|
641
|
+
}
|
|
642
|
+
/** Commit the staged changes with a message (global identity untouched). */
|
|
643
|
+
async function commit(cwd, message) {
|
|
644
|
+
await runGit(cwd, [
|
|
645
|
+
"commit",
|
|
646
|
+
"-m",
|
|
647
|
+
message
|
|
648
|
+
]);
|
|
649
|
+
}
|
|
650
|
+
/** Branch names (current first). */
|
|
651
|
+
async function branches(cwd) {
|
|
652
|
+
const [current, raw] = await Promise.all([currentBranch(cwd).catch(() => "HEAD"), runGit(cwd, [
|
|
653
|
+
"for-each-ref",
|
|
654
|
+
"--format=%(refname:short)",
|
|
655
|
+
"refs/heads"
|
|
656
|
+
])]);
|
|
657
|
+
const names = raw.split("\n").filter((line) => line !== "");
|
|
658
|
+
return {
|
|
659
|
+
current,
|
|
660
|
+
names: names.includes(current) ? names : [current, ...names]
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
/** Switch to an existing branch. */
|
|
664
|
+
async function checkout(cwd, branch) {
|
|
665
|
+
await runGit(cwd, ["checkout", branch]);
|
|
666
|
+
}
|
|
667
|
+
/** Recent commit history (newest first), lazily pageable via skip/count. */
|
|
668
|
+
async function log(cwd, count = 30, skip = 0) {
|
|
669
|
+
return parseLogLines(await runGit(cwd, [
|
|
670
|
+
"log",
|
|
671
|
+
"-n",
|
|
672
|
+
String(count),
|
|
673
|
+
"--skip",
|
|
674
|
+
String(skip),
|
|
675
|
+
"--decorate=short",
|
|
676
|
+
"--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D"
|
|
677
|
+
]));
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Content of a file at a revision (`git show <rev>:<path>`), or null when the
|
|
681
|
+
* revision has no such path (a new/untracked file has no HEAD side).
|
|
682
|
+
*/
|
|
683
|
+
async function show(cwd, rev, path) {
|
|
684
|
+
try {
|
|
685
|
+
return await runGit(cwd, ["show", `${rev}:${path}`]);
|
|
686
|
+
} catch {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/** Full patch text of one commit (`git show` with the commit header suppressed).
|
|
691
|
+
* Merge commits show their diff against the first parent (`-m --first-parent`
|
|
692
|
+
* is a no-op for regular commits), so a history click always has content. */
|
|
693
|
+
async function commitDiff(cwd, hash) {
|
|
694
|
+
return runGit(cwd, [
|
|
695
|
+
"show",
|
|
696
|
+
"--no-ext-diff",
|
|
697
|
+
"--no-color",
|
|
698
|
+
"--format=",
|
|
699
|
+
"-m",
|
|
700
|
+
"--first-parent",
|
|
701
|
+
hash
|
|
702
|
+
]);
|
|
703
|
+
}
|
|
704
|
+
/** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
|
|
705
|
+
async function discard(cwd, path) {
|
|
706
|
+
await runGit(cwd, [
|
|
707
|
+
"checkout",
|
|
708
|
+
"--",
|
|
709
|
+
path
|
|
710
|
+
]);
|
|
711
|
+
}
|
|
712
|
+
/** Revert one commit onto the current branch with an auto-generated message. */
|
|
713
|
+
async function revert(cwd, hash) {
|
|
714
|
+
await runGit(cwd, [
|
|
715
|
+
"revert",
|
|
716
|
+
"--no-edit",
|
|
717
|
+
hash
|
|
718
|
+
]);
|
|
719
|
+
}
|
|
720
|
+
/** Cherry-pick one commit onto the current branch. */
|
|
721
|
+
async function cherryPick(cwd, hash) {
|
|
722
|
+
await runGit(cwd, ["cherry-pick", hash]);
|
|
723
|
+
}
|
|
724
|
+
//#endregion
|
|
725
|
+
//#region src/pty-manager.ts
|
|
726
|
+
/**
|
|
727
|
+
* PTY session table for the sidebar terminals. One node-pty process per
|
|
728
|
+
* `${sessionId}:${tabId}` key; processes survive WebSocket disconnects
|
|
729
|
+
* (page refresh, tab switch) and reconnect to the same process by key.
|
|
730
|
+
* Output is mirrored into a bounded transcript ring (capped bytes) so a new
|
|
731
|
+
* connection replays history before live data. Sessions die only when the
|
|
732
|
+
* tab is closed or the plugin tears down.
|
|
733
|
+
*/
|
|
734
|
+
/** Per-terminal transcript bound (bytes kept for replay). */
|
|
735
|
+
const TRANSCRIPT_LIMIT$1 = 1 << 20;
|
|
736
|
+
/**
|
|
737
|
+
* Restore the executable bit pnpm strips from node-pty's prebuilt
|
|
738
|
+
* spawn-helper (the macOS helper that forks and sets up the pty). Without it
|
|
739
|
+
* every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
|
|
740
|
+
* @deepseek-ai/dsh-terminal-bash's ensure-spawn-helper postinstall, run at
|
|
741
|
+
* plugin activation so link-installed deployments get the fix too.
|
|
742
|
+
*/
|
|
743
|
+
function ensureSpawnHelper() {
|
|
744
|
+
if (process.platform === "win32") return;
|
|
745
|
+
try {
|
|
746
|
+
const entry = createRequire(import.meta.url).resolve("node-pty");
|
|
747
|
+
const packageRoot = dirname(dirname(entry));
|
|
748
|
+
const candidates = [join(packageRoot, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), join(packageRoot, "build", "Release", "spawn-helper")];
|
|
749
|
+
for (const helper of candidates) if (existsSync(helper)) chmodSync(helper, 493);
|
|
750
|
+
} catch {}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* The terminal registry. `maxPerSession` bounds concurrent processes per
|
|
754
|
+
* conversation (the client caps tabs at the same number).
|
|
755
|
+
*/
|
|
756
|
+
var PtyManager = class {
|
|
757
|
+
shell;
|
|
758
|
+
maxPerSession;
|
|
759
|
+
sessions = /* @__PURE__ */ new Map();
|
|
760
|
+
pendingCloses = /* @__PURE__ */ new Map();
|
|
761
|
+
constructor(shell, maxPerSession) {
|
|
762
|
+
this.shell = shell;
|
|
763
|
+
this.maxPerSession = maxPerSession;
|
|
764
|
+
}
|
|
765
|
+
/** All live terminal keys of one session. */
|
|
766
|
+
keysOf(sessionId) {
|
|
767
|
+
const keys = [];
|
|
768
|
+
for (const handle of this.sessions.values()) if (handle.sessionId === sessionId) keys.push(handle.key);
|
|
769
|
+
return keys;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Open (or reuse) the terminal for a session/tab key. A handle whose
|
|
773
|
+
* process already exited is replaced with a fresh spawn (reconnecting a
|
|
774
|
+
* dead terminal must yield a live shell, not an input sink), and so is a
|
|
775
|
+
* live handle whose spawn cwd differs from the now-authoritative one (the
|
|
776
|
+
* first connect of a page load can arrive before the session hydrates, so
|
|
777
|
+
* it fell back to the process cwd — reconnecting with the real cwd must
|
|
778
|
+
* restart the shell in the right directory). Reopening also cancels any
|
|
779
|
+
* pending scheduled close (a reconnect within the grace window keeps the
|
|
780
|
+
* process alive).
|
|
781
|
+
* @param sessionId - conversation id.
|
|
782
|
+
* @param tabId - client tab id.
|
|
783
|
+
* @param cwd - initial working directory (the session's cwd).
|
|
784
|
+
* @param cols - initial terminal width.
|
|
785
|
+
* @param rows - initial terminal height.
|
|
786
|
+
* @returns the live handle.
|
|
787
|
+
* @throws {SidebarError} pty-error when the per-session cap is reached.
|
|
788
|
+
*/
|
|
789
|
+
open(sessionId, tabId, cwd, cols, rows) {
|
|
790
|
+
const key = `${sessionId}:${tabId}`;
|
|
791
|
+
this.cancelClose(key);
|
|
792
|
+
const existing = this.sessions.get(key);
|
|
793
|
+
if (existing !== void 0 && !existing.exited && existing.cwd === cwd) return existing;
|
|
794
|
+
if (existing !== void 0) this.close(key);
|
|
795
|
+
for (const [candidate, handle] of [...this.sessions]) if (handle.sessionId === sessionId && handle.exited) this.close(candidate);
|
|
796
|
+
if (this.keysOf(sessionId).length >= this.maxPerSession) throw new SidebarError("pty-error", `terminal limit reached (${this.maxPerSession}) for this session`, 400);
|
|
797
|
+
const handle = {
|
|
798
|
+
key,
|
|
799
|
+
sessionId,
|
|
800
|
+
tabId,
|
|
801
|
+
cwd,
|
|
802
|
+
pty: nodePty.spawn(this.shell, [], {
|
|
803
|
+
name: "xterm-256color",
|
|
804
|
+
cols: Math.max(2, Math.floor(cols)),
|
|
805
|
+
rows: Math.max(2, Math.floor(rows)),
|
|
806
|
+
cwd,
|
|
807
|
+
env: { ...process.env }
|
|
808
|
+
}),
|
|
809
|
+
transcript: "",
|
|
810
|
+
exited: false
|
|
811
|
+
};
|
|
812
|
+
handle.pty.onData((data) => {
|
|
813
|
+
handle.transcript += data;
|
|
814
|
+
if (handle.transcript.length > TRANSCRIPT_LIMIT$1) handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT$1);
|
|
815
|
+
});
|
|
816
|
+
handle.pty.onExit(({ exitCode }) => {
|
|
817
|
+
handle.exited = true;
|
|
818
|
+
handle.exitCode = exitCode;
|
|
819
|
+
});
|
|
820
|
+
this.sessions.set(key, handle);
|
|
821
|
+
return handle;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Schedule the terminal's destruction after `delayMs`. A tab close sends
|
|
825
|
+
* delay 0 (release the quota immediately); a bare socket drop (refresh,
|
|
826
|
+
* crash) uses the grace period so a quick reconnect keeps the process.
|
|
827
|
+
* `open()` cancels any pending close.
|
|
828
|
+
*/
|
|
829
|
+
scheduleClose(key, delayMs) {
|
|
830
|
+
if (this.sessions.get(key) === void 0) return;
|
|
831
|
+
this.cancelClose(key);
|
|
832
|
+
const timer = setTimeout(() => {
|
|
833
|
+
this.close(key);
|
|
834
|
+
}, delayMs);
|
|
835
|
+
this.pendingCloses.set(key, timer);
|
|
836
|
+
}
|
|
837
|
+
/** Cancel a pending scheduled close (the terminal is being reopened). */
|
|
838
|
+
cancelClose(key) {
|
|
839
|
+
const timer = this.pendingCloses.get(key);
|
|
840
|
+
if (timer !== void 0) {
|
|
841
|
+
clearTimeout(timer);
|
|
842
|
+
this.pendingCloses.delete(key);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
/** Resolve a live handle by key, or undefined. */
|
|
846
|
+
get(key) {
|
|
847
|
+
return this.sessions.get(key);
|
|
848
|
+
}
|
|
849
|
+
/** Close a terminal and drop its state (the owning tab was closed). */
|
|
850
|
+
close(key) {
|
|
851
|
+
this.cancelClose(key);
|
|
852
|
+
const handle = this.sessions.get(key);
|
|
853
|
+
if (handle === void 0) return;
|
|
854
|
+
this.sessions.delete(key);
|
|
855
|
+
try {
|
|
856
|
+
handle.pty.kill();
|
|
857
|
+
} catch {}
|
|
858
|
+
}
|
|
859
|
+
/** Close every terminal (plugin teardown). */
|
|
860
|
+
disposeAll() {
|
|
861
|
+
for (const timer of this.pendingCloses.values()) clearTimeout(timer);
|
|
862
|
+
this.pendingCloses.clear();
|
|
863
|
+
for (const key of [...this.sessions.keys()]) this.close(key);
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
/** The interactive shell for this platform (empty SHELL falls back). */
|
|
867
|
+
function defaultShell() {
|
|
868
|
+
if (process.platform === "win32") return "powershell.exe";
|
|
869
|
+
const shell = process.env.SHELL;
|
|
870
|
+
return shell !== void 0 && shell.trim() !== "" ? shell : "/bin/bash";
|
|
871
|
+
}
|
|
872
|
+
//#endregion
|
|
873
|
+
//#region src/agent-pty.ts
|
|
874
|
+
/**
|
|
875
|
+
* Agent-owned terminal registry: a uuid-keyed table of long-lived PTY
|
|
876
|
+
* sessions created by the model through the `terminal_create` tool. Each
|
|
877
|
+
* handle survives across tool calls (and across WebSocket disconnects from
|
|
878
|
+
* the sidebar view) until the model calls `terminal_close` or the user
|
|
879
|
+
* closes the corresponding sidebar tab — tmux semantics, scoped per agent
|
|
880
|
+
* session.
|
|
881
|
+
*
|
|
882
|
+
* This is a parallel registry to {@link PtyManager}: UI tabs are keyed by
|
|
883
|
+
* `${sessionId}:${tabId}` and capped per session, while agent terminals are
|
|
884
|
+
* keyed by uuid and uncapped (the model is trusted to close unused ones).
|
|
885
|
+
* Both registries share the same shell resolver and spawn-helper fix.
|
|
886
|
+
*/
|
|
887
|
+
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
|
|
888
|
+
const TRANSCRIPT_LIMIT = 1 << 20;
|
|
889
|
+
/** POSIX signals the registry forwards to a live pty. */
|
|
890
|
+
const ALLOWED_SIGNALS = [
|
|
891
|
+
"SIGINT",
|
|
892
|
+
"SIGTERM",
|
|
893
|
+
"SIGKILL",
|
|
894
|
+
"SIGHUP",
|
|
895
|
+
"SIGTSTP"
|
|
896
|
+
];
|
|
897
|
+
/** Largest pty dimension the registry accepts (mirrors the tool contract). */
|
|
898
|
+
const TERMINAL_DIM_MAX = 1024;
|
|
899
|
+
/** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
|
|
900
|
+
function clampDims(cols, rows) {
|
|
901
|
+
const clamp = (value) => Math.min(TERMINAL_DIM_MAX, Math.max(2, Math.floor(value)));
|
|
902
|
+
return {
|
|
903
|
+
cols: clamp(cols),
|
|
904
|
+
rows: clamp(rows)
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
/** Map a POSIX signal number to its conventional name (best-effort). */
|
|
908
|
+
const SIGNAL_NAMES = {
|
|
909
|
+
1: "SIGHUP",
|
|
910
|
+
2: "SIGINT",
|
|
911
|
+
3: "SIGQUIT",
|
|
912
|
+
4: "SIGILL",
|
|
913
|
+
6: "SIGABRT",
|
|
914
|
+
9: "SIGKILL",
|
|
915
|
+
11: "SIGSEGV",
|
|
916
|
+
13: "SIGPIPE",
|
|
917
|
+
14: "SIGALRM",
|
|
918
|
+
15: "SIGTERM",
|
|
919
|
+
17: "SIGCHLD",
|
|
920
|
+
18: "SIGCONT",
|
|
921
|
+
19: "SIGSTOP",
|
|
922
|
+
20: "SIGTSTP"
|
|
923
|
+
};
|
|
924
|
+
/** Convert a raw signal number to a name (or null when absent/unknown). */
|
|
925
|
+
function signalNameOf(signal) {
|
|
926
|
+
if (signal === null || signal === void 0) return null;
|
|
927
|
+
return SIGNAL_NAMES[signal] ?? `signal ${signal}`;
|
|
928
|
+
}
|
|
929
|
+
/** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
|
|
930
|
+
function locateNeedle(transcript, needle) {
|
|
931
|
+
if (needle === "") return void 0;
|
|
932
|
+
const idx = transcript.indexOf(needle);
|
|
933
|
+
if (idx === -1) return void 0;
|
|
934
|
+
let line = 0;
|
|
935
|
+
let lineStart = 0;
|
|
936
|
+
for (let i = 0; i < idx; i += 1) if (transcript.charCodeAt(i) === 10) {
|
|
937
|
+
line += 1;
|
|
938
|
+
lineStart = i + 1;
|
|
939
|
+
}
|
|
940
|
+
return {
|
|
941
|
+
line,
|
|
942
|
+
column: idx - lineStart
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
/** Snapshot projection of a handle (drops the pty reference and transcript). */
|
|
946
|
+
function snapshotOf(handle) {
|
|
947
|
+
const out = {
|
|
948
|
+
uuid: handle.uuid,
|
|
949
|
+
title: handle.title,
|
|
950
|
+
command: handle.command,
|
|
951
|
+
exited: handle.exited
|
|
952
|
+
};
|
|
953
|
+
if (handle.exited) {
|
|
954
|
+
out.exitCode = handle.exitCode ?? null;
|
|
955
|
+
out.exitSignal = signalNameOf(handle.exitSignal);
|
|
956
|
+
}
|
|
957
|
+
return out;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* The agent terminal registry. The constructor takes the resolved shell
|
|
961
|
+
* binary (the same `defaultShell()` the UI-tab registry uses) and runs the
|
|
962
|
+
* spawn-helper chmod fix once at construction so the first agent terminal
|
|
963
|
+
* does not race a lazy fixer.
|
|
964
|
+
*/
|
|
965
|
+
var AgentPtyRegistry = class {
|
|
966
|
+
shell;
|
|
967
|
+
sessions = /* @__PURE__ */ new Map();
|
|
968
|
+
changeListeners = /* @__PURE__ */ new Set();
|
|
969
|
+
constructor(shell) {
|
|
970
|
+
this.shell = shell;
|
|
971
|
+
ensureSpawnHelper();
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Spawn one agent terminal: start the shell in `cwd`, then write
|
|
975
|
+
* `command + '\n'` to stdin so the command runs in the fresh shell. The
|
|
976
|
+
* terminal stays alive after the command exits — the model can send more
|
|
977
|
+
* input through `terminal_send` until it calls `terminal_close` or the
|
|
978
|
+
* user closes the sidebar tab. An empty `command` spawns a bare shell.
|
|
979
|
+
* @returns the new handle's uuid (the model-facing opaque id).
|
|
980
|
+
*/
|
|
981
|
+
create(sessionId, title, command, cwd, cols = 80, rows = 24) {
|
|
982
|
+
const uuid = randomUUID();
|
|
983
|
+
const dims = clampDims(cols, rows);
|
|
984
|
+
const pty = nodePty.spawn(this.shell, [], {
|
|
985
|
+
name: "xterm-256color",
|
|
986
|
+
cols: dims.cols,
|
|
987
|
+
rows: dims.rows,
|
|
988
|
+
cwd,
|
|
989
|
+
env: { ...process.env }
|
|
990
|
+
});
|
|
991
|
+
const handle = {
|
|
992
|
+
uuid,
|
|
993
|
+
sessionId,
|
|
994
|
+
title,
|
|
995
|
+
command,
|
|
996
|
+
cwd,
|
|
997
|
+
pty,
|
|
998
|
+
transcript: "",
|
|
999
|
+
exited: false
|
|
1000
|
+
};
|
|
1001
|
+
pty.onData((data) => {
|
|
1002
|
+
handle.transcript += data;
|
|
1003
|
+
if (handle.transcript.length > TRANSCRIPT_LIMIT) handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT);
|
|
1004
|
+
});
|
|
1005
|
+
pty.onExit(({ exitCode, signal }) => {
|
|
1006
|
+
handle.exited = true;
|
|
1007
|
+
handle.exitCode = exitCode;
|
|
1008
|
+
handle.exitSignal = signal;
|
|
1009
|
+
this.notify();
|
|
1010
|
+
});
|
|
1011
|
+
if (command !== "") try {
|
|
1012
|
+
pty.write(`${command}\r`);
|
|
1013
|
+
} catch {}
|
|
1014
|
+
this.sessions.set(uuid, handle);
|
|
1015
|
+
this.notify();
|
|
1016
|
+
return uuid;
|
|
1017
|
+
}
|
|
1018
|
+
/** All live agent terminals belonging to one conversation. */
|
|
1019
|
+
list(sessionId) {
|
|
1020
|
+
const out = [];
|
|
1021
|
+
for (const handle of this.sessions.values()) if (handle.sessionId === sessionId) out.push(snapshotOf(handle));
|
|
1022
|
+
return out;
|
|
1023
|
+
}
|
|
1024
|
+
/** Resolve a live handle by uuid, or throw `not-found`. */
|
|
1025
|
+
expect(uuid) {
|
|
1026
|
+
const handle = this.sessions.get(uuid);
|
|
1027
|
+
if (handle === void 0) throw new SidebarError("not-found", `agent terminal "${uuid}" not found`, 404);
|
|
1028
|
+
return handle;
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Resolve a live handle that belongs to `sessionId`, or throw `not-found`.
|
|
1032
|
+
* The model-facing tools call this before every uuid-keyed operation: a
|
|
1033
|
+
* uuid from another session is indistinguishable from an unknown one, so a
|
|
1034
|
+
* model can never reach (or probe) a terminal it does not own.
|
|
1035
|
+
*/
|
|
1036
|
+
assertOwned(uuid, sessionId) {
|
|
1037
|
+
const handle = this.expect(uuid);
|
|
1038
|
+
if (handle.sessionId !== sessionId) throw new SidebarError("not-found", `agent terminal "${uuid}" not found`, 404);
|
|
1039
|
+
return handle;
|
|
1040
|
+
}
|
|
1041
|
+
/** Resolve a handle's snapshot, or undefined if it does not exist. */
|
|
1042
|
+
snapshot(uuid) {
|
|
1043
|
+
const handle = this.sessions.get(uuid);
|
|
1044
|
+
return handle === void 0 ? void 0 : snapshotOf(handle);
|
|
1045
|
+
}
|
|
1046
|
+
/** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */
|
|
1047
|
+
send(uuid, text) {
|
|
1048
|
+
const handle = this.expect(uuid);
|
|
1049
|
+
if (handle.exited) throw new SidebarError("bad-request", `agent terminal "${uuid}" has exited`, 400);
|
|
1050
|
+
handle.pty.write(text);
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Read one bounded page of the retained transcript. `offset` is a 0-based
|
|
1054
|
+
* line index from the start of the retained transcript (default 0);
|
|
1055
|
+
* `count` caps the page size (default 500). A negative `offset` reads
|
|
1056
|
+
* from the end (e.g. -50 reads the last 50 lines). Returns `totalLines`
|
|
1057
|
+
* so the model can paginate.
|
|
1058
|
+
*/
|
|
1059
|
+
read(uuid, offset, count) {
|
|
1060
|
+
const lines = this.expect(uuid).transcript.split("\n");
|
|
1061
|
+
const totalLines = lines.length;
|
|
1062
|
+
const pageSize = Math.max(1, Math.min(count ?? 500, 500));
|
|
1063
|
+
let start;
|
|
1064
|
+
if (offset === void 0 || offset === 0) start = 0;
|
|
1065
|
+
else if (offset < 0) start = Math.max(0, totalLines + offset);
|
|
1066
|
+
else start = Math.min(offset, totalLines);
|
|
1067
|
+
const end = Math.min(start + pageSize, totalLines);
|
|
1068
|
+
return {
|
|
1069
|
+
text: lines.slice(start, end).join("\n"),
|
|
1070
|
+
totalLines,
|
|
1071
|
+
lineBegin: start,
|
|
1072
|
+
lineEnd: end
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Resize a terminal's pty, clamped to the 2..1024 sane range.
|
|
1077
|
+
* @returns the dimensions actually applied (the caller echoes these, so the
|
|
1078
|
+
* reported value always matches the pty).
|
|
1079
|
+
*/
|
|
1080
|
+
resize(uuid, cols, rows) {
|
|
1081
|
+
const handle = this.expect(uuid);
|
|
1082
|
+
const dims = clampDims(cols, rows);
|
|
1083
|
+
if (!handle.exited) handle.pty.resize(dims.cols, dims.rows);
|
|
1084
|
+
return dims;
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Wait for `needle` to appear in a terminal's transcript, or for the
|
|
1088
|
+
* terminal to exit, or for the timeout to elapse — whichever happens
|
|
1089
|
+
* first. The wait polls the live transcript every ~50ms and short-circuits
|
|
1090
|
+
* on `signal` abort (re-thrown as the abort reason so the tool layer
|
|
1091
|
+
* surfaces cancellation).
|
|
1092
|
+
*
|
|
1093
|
+
* The match scans the FULL retained transcript on each poll, not just the
|
|
1094
|
+
* delta since the last poll — a needle that scrolled past the most recent
|
|
1095
|
+
* chunk but is still within the ~1 MiB bound is still a match. The
|
|
1096
|
+
* returned line/column locate the FIRST occurrence (oldest), which is what
|
|
1097
|
+
* a user watching the terminal would have seen first.
|
|
1098
|
+
*
|
|
1099
|
+
* The implementation uses polling (not pty onData subscription) because
|
|
1100
|
+
* node-pty's onData fires before the registry's own onData listener
|
|
1101
|
+
* updates the transcript (listener order is not guaranteed), and on
|
|
1102
|
+
* Windows ConPTY output can arrive in bursts with batching delays that
|
|
1103
|
+
* make event-driven wakeups unreliable. A 50ms poll is fast enough for
|
|
1104
|
+
* interactive use and simple enough to be obviously correct.
|
|
1105
|
+
* @param uuid - terminal to watch.
|
|
1106
|
+
* @param needle - substring to search for (case-sensitive, verbatim).
|
|
1107
|
+
* @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
|
|
1108
|
+
* @param signal - caller-owned cancellation; aborts the wait re-throwing.
|
|
1109
|
+
* @returns one of `found` / `timeout` / `exited`.
|
|
1110
|
+
*/
|
|
1111
|
+
async waitFor(uuid, needle, timeoutMs = 1e4, signal) {
|
|
1112
|
+
if (needle === "") throw new SidebarError("bad-request", "needle must be a non-empty string", 400);
|
|
1113
|
+
const handle = this.expect(uuid);
|
|
1114
|
+
const timeout = Math.max(100, Math.floor(timeoutMs));
|
|
1115
|
+
const start = Date.now();
|
|
1116
|
+
const deadline = start + timeout;
|
|
1117
|
+
if (handle.exited) return {
|
|
1118
|
+
kind: "exited",
|
|
1119
|
+
needle,
|
|
1120
|
+
exitCode: handle.exitCode ?? null,
|
|
1121
|
+
exitSignal: signalNameOf(handle.exitSignal)
|
|
1122
|
+
};
|
|
1123
|
+
const firstHit = locateNeedle(handle.transcript, needle);
|
|
1124
|
+
if (firstHit !== void 0) return {
|
|
1125
|
+
kind: "found",
|
|
1126
|
+
needle,
|
|
1127
|
+
line: firstHit.line,
|
|
1128
|
+
column: firstHit.column,
|
|
1129
|
+
elapsedMs: Date.now() - start
|
|
1130
|
+
};
|
|
1131
|
+
while (true) {
|
|
1132
|
+
if (signal?.aborted) signal.throwIfAborted();
|
|
1133
|
+
if (handle.exited) return {
|
|
1134
|
+
kind: "exited",
|
|
1135
|
+
needle,
|
|
1136
|
+
exitCode: handle.exitCode ?? null,
|
|
1137
|
+
exitSignal: signalNameOf(handle.exitSignal)
|
|
1138
|
+
};
|
|
1139
|
+
const hit = locateNeedle(handle.transcript, needle);
|
|
1140
|
+
if (hit !== void 0) return {
|
|
1141
|
+
kind: "found",
|
|
1142
|
+
needle,
|
|
1143
|
+
line: hit.line,
|
|
1144
|
+
column: hit.column,
|
|
1145
|
+
elapsedMs: Date.now() - start
|
|
1146
|
+
};
|
|
1147
|
+
if (Date.now() >= deadline) return {
|
|
1148
|
+
kind: "timeout",
|
|
1149
|
+
needle,
|
|
1150
|
+
timeoutMs: timeout,
|
|
1151
|
+
totalLines: handle.transcript.split("\n").length
|
|
1152
|
+
};
|
|
1153
|
+
await new Promise((resolve) => {
|
|
1154
|
+
const t = setTimeout(resolve, 50);
|
|
1155
|
+
if (typeof t === "object" && "unref" in t) t.unref();
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Send a POSIX signal to a terminal's foreground process.
|
|
1161
|
+
*
|
|
1162
|
+
* Two delivery paths, by signal kind:
|
|
1163
|
+
* - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by
|
|
1164
|
+
* writing the corresponding control character to the pty stdin. This is
|
|
1165
|
+
* how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel
|
|
1166
|
+
* line discipline (POSIX ISIG mode) or the ConPTY input pipeline
|
|
1167
|
+
* (Windows), which translates it into a SIGINT/SIGTSTP for the
|
|
1168
|
+
* foreground process group. This works on every platform — calling
|
|
1169
|
+
* `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX,
|
|
1170
|
+
* but writing `\x03` is universally correct.
|
|
1171
|
+
* - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`,
|
|
1172
|
+
* which maps to the platform's process-termination path (POSIX
|
|
1173
|
+
* `kill(2)`, Windows `TerminateProcess`). These cannot be faked with
|
|
1174
|
+
* control characters.
|
|
1175
|
+
*/
|
|
1176
|
+
signal(uuid, signal) {
|
|
1177
|
+
const handle = this.expect(uuid);
|
|
1178
|
+
if (handle.exited) return;
|
|
1179
|
+
if (signal === "SIGINT" || signal === "SIGTSTP") {
|
|
1180
|
+
const ctrlByte = signal === "SIGINT" ? "" : "";
|
|
1181
|
+
try {
|
|
1182
|
+
handle.pty.write(ctrlByte);
|
|
1183
|
+
} catch {}
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
try {
|
|
1187
|
+
handle.pty.kill(signal);
|
|
1188
|
+
} catch {
|
|
1189
|
+
try {
|
|
1190
|
+
handle.pty.kill();
|
|
1191
|
+
} catch {}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Close a terminal and drop its state. Idempotent: a second close of the
|
|
1196
|
+
* same uuid is a no-op. Returns true iff a live handle was actually
|
|
1197
|
+
* dropped.
|
|
1198
|
+
*/
|
|
1199
|
+
close(uuid) {
|
|
1200
|
+
const handle = this.sessions.get(uuid);
|
|
1201
|
+
if (handle === void 0) return false;
|
|
1202
|
+
this.sessions.delete(uuid);
|
|
1203
|
+
try {
|
|
1204
|
+
handle.pty.kill();
|
|
1205
|
+
} catch {}
|
|
1206
|
+
this.notify();
|
|
1207
|
+
return true;
|
|
1208
|
+
}
|
|
1209
|
+
/** Resolve a live handle by uuid (for the WS attach path). */
|
|
1210
|
+
get(uuid) {
|
|
1211
|
+
return this.sessions.get(uuid);
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Subscribe to registry changes (create / close / exit). The sidebar push
|
|
1215
|
+
* endpoint uses this to forward snapshots to the connected view. Returns
|
|
1216
|
+
* the unsubscribe function.
|
|
1217
|
+
*/
|
|
1218
|
+
subscribe(listener) {
|
|
1219
|
+
this.changeListeners.add(listener);
|
|
1220
|
+
return () => {
|
|
1221
|
+
this.changeListeners.delete(listener);
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
/** Close every agent terminal (plugin teardown). */
|
|
1225
|
+
disposeAll() {
|
|
1226
|
+
for (const uuid of [...this.sessions.keys()]) this.close(uuid);
|
|
1227
|
+
}
|
|
1228
|
+
/** Fire every change listener (callers wrap in try/catch if needed). */
|
|
1229
|
+
notify() {
|
|
1230
|
+
for (const listener of [...this.changeListeners]) try {
|
|
1231
|
+
listener();
|
|
1232
|
+
} catch {}
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
//#endregion
|
|
1236
|
+
//#region src/tools.ts
|
|
1237
|
+
/**
|
|
1238
|
+
* Eight model-facing tools for the agent-owned sidebar terminals (tmux
|
|
1239
|
+
* semantics: spawn-and-detach, send-keys, read, wait-for, resize, signal,
|
|
1240
|
+
* close, list). Each tool binds to the calling agent's session through
|
|
1241
|
+
* `exec.agent.session.id`, so the model never passes a sessionId — the
|
|
1242
|
+
* agent identity is the scope.
|
|
1243
|
+
*
|
|
1244
|
+
* Conventions (per plugin-development-guide.md §3):
|
|
1245
|
+
* C1 — parameters schema-validated before `execute` runs.
|
|
1246
|
+
* C4 — `execute` returns one canonical JSON value; `render` is a separate
|
|
1247
|
+
* pure text projection.
|
|
1248
|
+
* C6 — `exec.signal.throwIfAborted()` before any spawn.
|
|
1249
|
+
* C10 — no UI/transport vocabulary in the canonical value.
|
|
1250
|
+
*/
|
|
1251
|
+
/** Maximum UTF-8 bytes of one `terminal_read` result text. */
|
|
1252
|
+
const READ_BYTE_LIMIT = 262144;
|
|
1253
|
+
/**
|
|
1254
|
+
* Bound a string to a byte limit, marking truncation. Truncation never
|
|
1255
|
+
* splits a multi-byte UTF-8 sequence: when the byte cap lands inside one,
|
|
1256
|
+
* the walk-back retreats to the sequence's leading byte so the retained
|
|
1257
|
+
* prefix decodes cleanly (a split would decode to U+FFFD).
|
|
1258
|
+
* @internal exported for the unit tests, like {@link snapshotOf}.
|
|
1259
|
+
*/
|
|
1260
|
+
function boundBytes(text, maxBytes) {
|
|
1261
|
+
const buf = Buffer.from(text, "utf8");
|
|
1262
|
+
if (buf.byteLength <= maxBytes) return {
|
|
1263
|
+
text,
|
|
1264
|
+
truncated: false
|
|
1265
|
+
};
|
|
1266
|
+
let end = maxBytes;
|
|
1267
|
+
while (end > 0 && ((buf[end] ?? 0) & 192) === 128) end -= 1;
|
|
1268
|
+
return {
|
|
1269
|
+
text: buf.subarray(0, end).toString("utf8"),
|
|
1270
|
+
truncated: true
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
/** Pure text projection helper (the canonical value is already structured). */
|
|
1274
|
+
function textRender(fn) {
|
|
1275
|
+
return (_args, value) => [{
|
|
1276
|
+
type: "text",
|
|
1277
|
+
text: fn(value)
|
|
1278
|
+
}];
|
|
1279
|
+
}
|
|
1280
|
+
/** Extract the calling agent or throw the canonical "no agent" error. */
|
|
1281
|
+
function requireAgent(agent) {
|
|
1282
|
+
if (agent === void 0) throw new Error("sidebar terminal tools require an initiating agent");
|
|
1283
|
+
return agent;
|
|
1284
|
+
}
|
|
1285
|
+
/** Resolve the calling agent's session id (the registry scope + ownership key). */
|
|
1286
|
+
function sessionIdOf(exec) {
|
|
1287
|
+
return requireAgent(exec.agent).session.id;
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Register the eight terminal tools against the host tool registry. The
|
|
1291
|
+
* `resolveCwd` callback threads the live session cwd (authoritative from the
|
|
1292
|
+
* session store, falling back to the process cwd) so a freshly-created
|
|
1293
|
+
* terminal lands in the right directory without the model passing it.
|
|
1294
|
+
* Every uuid-keyed tool first asserts the terminal belongs to the calling
|
|
1295
|
+
* session (`registry.assertOwned`), so one agent can never reach another
|
|
1296
|
+
* session's terminals.
|
|
1297
|
+
* @param ctx - host plugin context (carries the tools service).
|
|
1298
|
+
* @param registry - the agent-owned terminal registry.
|
|
1299
|
+
* @param resolveCwd - live cwd resolver for one session id.
|
|
1300
|
+
* @returns a disposer that unregisters all eight tools (the caller gates
|
|
1301
|
+
* registration on the side-card setting and calls this to turn them off).
|
|
1302
|
+
*/
|
|
1303
|
+
function registerTools(ctx, registry, resolveCwd) {
|
|
1304
|
+
const disposers = [];
|
|
1305
|
+
const register = (tool) => {
|
|
1306
|
+
disposers.push(ctx.tools.register(tool));
|
|
1307
|
+
};
|
|
1308
|
+
register(defineTool({
|
|
1309
|
+
name: "terminal_create",
|
|
1310
|
+
description: "Open a persistent terminal in the sidebar and run a command in it. Spawns an interactive shell, writes the command + Enter to its stdin, and returns a uuid handle. The terminal stays alive after the command exits — send more input with terminal_send (set submit=true to run a command), read output with terminal_read, send Ctrl+C with terminal_signal(signal=\"SIGINT\"), and close it with terminal_close when done. Use this for interactive shells, REPLs, long-running dev servers, or any work that needs persistent terminal state across tool calls. The terminal appears as a new tab in the right sidebar (titled with the `title` you provide) so the user can watch and interact with it.",
|
|
1311
|
+
parameters: {
|
|
1312
|
+
title: {
|
|
1313
|
+
type: "string",
|
|
1314
|
+
required: true,
|
|
1315
|
+
description: "Short human-readable label for the terminal tab (e.g. \"dev server\", \"python repl\")."
|
|
1316
|
+
},
|
|
1317
|
+
command: {
|
|
1318
|
+
type: "string",
|
|
1319
|
+
required: true,
|
|
1320
|
+
description: "Shell command to run in the freshly spawned shell. The host appends an Enter key automatically — do NOT include a trailing newline. Pass \"\" to open a bare shell with no command."
|
|
1321
|
+
}
|
|
1322
|
+
},
|
|
1323
|
+
output: {
|
|
1324
|
+
schema: {
|
|
1325
|
+
type: "object",
|
|
1326
|
+
additionalProperties: false,
|
|
1327
|
+
properties: {
|
|
1328
|
+
uuid: {
|
|
1329
|
+
type: "string",
|
|
1330
|
+
required: true,
|
|
1331
|
+
description: "Opaque handle for the new terminal. Pass to terminal_send / terminal_read / terminal_resize / terminal_signal / terminal_close."
|
|
1332
|
+
},
|
|
1333
|
+
title: {
|
|
1334
|
+
type: "string",
|
|
1335
|
+
required: true,
|
|
1336
|
+
description: "The title you provided (echoed for confirmation)."
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
},
|
|
1340
|
+
render: textRender((v) => `Opened terminal "${v.title}" (uuid: ${v.uuid}). The sidebar tab appears automatically; use terminal_read to see output and terminal_send (with submit=true) to run more commands.`)
|
|
1341
|
+
},
|
|
1342
|
+
execute: (args, exec) => {
|
|
1343
|
+
exec.signal.throwIfAborted();
|
|
1344
|
+
const sessionId = sessionIdOf(exec);
|
|
1345
|
+
const cwd = resolveCwd(sessionId);
|
|
1346
|
+
const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24);
|
|
1347
|
+
return Promise.resolve({
|
|
1348
|
+
uuid,
|
|
1349
|
+
title: args.title
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
}));
|
|
1353
|
+
register(defineTool({
|
|
1354
|
+
name: "terminal_list",
|
|
1355
|
+
description: "List every terminal the current agent has opened in this session. Returns each terminal's uuid, title, the command it was started with, and whether the top-level process has exited (with exit code/signal if so). Use this to recover state after a long sequence of tool calls or to find a terminal you forgot to close.",
|
|
1356
|
+
parameters: {},
|
|
1357
|
+
output: {
|
|
1358
|
+
schema: {
|
|
1359
|
+
type: "array",
|
|
1360
|
+
items: {
|
|
1361
|
+
type: "object",
|
|
1362
|
+
additionalProperties: false,
|
|
1363
|
+
properties: {
|
|
1364
|
+
uuid: {
|
|
1365
|
+
type: "string",
|
|
1366
|
+
required: true
|
|
1367
|
+
},
|
|
1368
|
+
title: {
|
|
1369
|
+
type: "string",
|
|
1370
|
+
required: true
|
|
1371
|
+
},
|
|
1372
|
+
command: {
|
|
1373
|
+
type: "string",
|
|
1374
|
+
required: true
|
|
1375
|
+
},
|
|
1376
|
+
exited: {
|
|
1377
|
+
type: "boolean",
|
|
1378
|
+
required: true
|
|
1379
|
+
},
|
|
1380
|
+
exitCode: { oneOf: [{ type: "integer" }, { type: "null" }] },
|
|
1381
|
+
exitSignal: { oneOf: [{ type: "string" }, { type: "null" }] }
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
},
|
|
1385
|
+
render: (_args, value) => {
|
|
1386
|
+
const list = value;
|
|
1387
|
+
if (list.length === 0) return [{
|
|
1388
|
+
type: "text",
|
|
1389
|
+
text: "No agent terminals open in this session."
|
|
1390
|
+
}];
|
|
1391
|
+
return [{
|
|
1392
|
+
type: "text",
|
|
1393
|
+
text: `Agent terminals in this session:\n${list.map((t) => {
|
|
1394
|
+
const status = t.exited ? `exited (code ${t.exitCode ?? "?"}, signal ${t.exitSignal ?? "none"})` : "running";
|
|
1395
|
+
return ` ${t.uuid} "${t.title}" [${status}] $ ${t.command}`;
|
|
1396
|
+
}).join("\n")}`
|
|
1397
|
+
}];
|
|
1398
|
+
}
|
|
1399
|
+
},
|
|
1400
|
+
execute: (_args, exec) => {
|
|
1401
|
+
const sessionId = sessionIdOf(exec);
|
|
1402
|
+
return Promise.resolve(registry.list(sessionId));
|
|
1403
|
+
}
|
|
1404
|
+
}));
|
|
1405
|
+
register(defineTool({
|
|
1406
|
+
name: "terminal_send",
|
|
1407
|
+
description: "Send raw text (keystrokes) to a terminal opened with terminal_create — tmux send-keys semantics. The text is written verbatim to the pty stdin. To submit a command, set submit=true (appends an Enter key); do NOT put \"\\n\" or \"\\r\" in the text yourself. To send Ctrl+C (interrupt the running command), use the terminal_signal tool with signal=\"SIGINT\" — do NOT try to send the control character \"\\u0003\" as text. Use terminal_signal with signal=\"SIGTSTP\" for Ctrl+Z (suspend) as well. This tool does NOT wait for the command to finish or for output to settle — pair with terminal_read to observe the result. Throws if the terminal has exited.",
|
|
1408
|
+
parameters: {
|
|
1409
|
+
uuid: {
|
|
1410
|
+
type: "string",
|
|
1411
|
+
required: true,
|
|
1412
|
+
description: "Terminal uuid from terminal_create or terminal_list."
|
|
1413
|
+
},
|
|
1414
|
+
text: {
|
|
1415
|
+
type: "string",
|
|
1416
|
+
required: true,
|
|
1417
|
+
description: "UTF-8 text to write to the terminal stdin (verbatim, no shell escaping). Do not include trailing newlines — use the submit flag instead."
|
|
1418
|
+
},
|
|
1419
|
+
submit: {
|
|
1420
|
+
type: "boolean",
|
|
1421
|
+
description: "Append an Enter key (carriage return) after the text to submit a command. Default: false. Set to true when sending a command to run; leave false for partial input or control sequences."
|
|
1422
|
+
}
|
|
1423
|
+
},
|
|
1424
|
+
output: {
|
|
1425
|
+
schema: {
|
|
1426
|
+
type: "object",
|
|
1427
|
+
additionalProperties: false,
|
|
1428
|
+
properties: {
|
|
1429
|
+
uuid: {
|
|
1430
|
+
type: "string",
|
|
1431
|
+
required: true
|
|
1432
|
+
},
|
|
1433
|
+
bytes: {
|
|
1434
|
+
type: "integer",
|
|
1435
|
+
required: true,
|
|
1436
|
+
description: "Number of UTF-8 bytes written (including the Enter key if submit was true)."
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
},
|
|
1440
|
+
render: textRender((v) => `Sent ${v.bytes} byte(s) to terminal ${v.uuid}.`)
|
|
1441
|
+
},
|
|
1442
|
+
execute: (args, exec) => {
|
|
1443
|
+
exec.signal.throwIfAborted();
|
|
1444
|
+
const sessionId = sessionIdOf(exec);
|
|
1445
|
+
registry.assertOwned(args.uuid, sessionId);
|
|
1446
|
+
const payload = args.submit === true ? `${args.text}\r` : args.text;
|
|
1447
|
+
registry.send(args.uuid, payload);
|
|
1448
|
+
return Promise.resolve({
|
|
1449
|
+
uuid: args.uuid,
|
|
1450
|
+
bytes: Buffer.byteLength(payload, "utf8")
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
}));
|
|
1454
|
+
register(defineTool({
|
|
1455
|
+
name: "terminal_read",
|
|
1456
|
+
description: "Read a bounded page of retained output from an agent terminal without sending input. The host keeps up to ~1 MiB of scrollback; this tool returns up to 500 lines per call. Use `offset` to paginate forward ( 0-based from the start of the retained transcript ) or backward ( negative reads from the end, e.g. -50 reads the last 50 lines ). Returns `totalLines` so you know how much scrollback remains. Output is bounded to 256 KiB per call; longer pages are truncated with the `truncated` flag.",
|
|
1457
|
+
parameters: {
|
|
1458
|
+
uuid: {
|
|
1459
|
+
type: "string",
|
|
1460
|
+
required: true,
|
|
1461
|
+
description: "Terminal uuid from terminal_create or terminal_list."
|
|
1462
|
+
},
|
|
1463
|
+
offset: {
|
|
1464
|
+
type: "number",
|
|
1465
|
+
description: "0-based line offset from the start of the retained transcript (default 0). Negative reads from the end (e.g. -50 = last 50 lines)."
|
|
1466
|
+
},
|
|
1467
|
+
count: {
|
|
1468
|
+
type: "number",
|
|
1469
|
+
description: "Maximum lines to return (default 500, hard cap 500)."
|
|
1470
|
+
}
|
|
1471
|
+
},
|
|
1472
|
+
output: {
|
|
1473
|
+
schema: {
|
|
1474
|
+
type: "object",
|
|
1475
|
+
additionalProperties: false,
|
|
1476
|
+
properties: {
|
|
1477
|
+
text: {
|
|
1478
|
+
type: "string",
|
|
1479
|
+
required: true,
|
|
1480
|
+
description: "The slice of transcript for the requested page."
|
|
1481
|
+
},
|
|
1482
|
+
totalLines: {
|
|
1483
|
+
type: "integer",
|
|
1484
|
+
required: true,
|
|
1485
|
+
description: "Total lines in the retained transcript."
|
|
1486
|
+
},
|
|
1487
|
+
lineBegin: {
|
|
1488
|
+
type: "integer",
|
|
1489
|
+
required: true,
|
|
1490
|
+
description: "0-based index of the first line in `text` (inclusive)."
|
|
1491
|
+
},
|
|
1492
|
+
lineEnd: {
|
|
1493
|
+
type: "integer",
|
|
1494
|
+
required: true,
|
|
1495
|
+
description: "0-based index of the last line in `text` (exclusive)."
|
|
1496
|
+
},
|
|
1497
|
+
truncated: {
|
|
1498
|
+
type: "boolean",
|
|
1499
|
+
required: true,
|
|
1500
|
+
description: "Whether `text` was truncated to fit the 256 KiB read cap."
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
},
|
|
1504
|
+
render: (_args, value) => {
|
|
1505
|
+
const v = value;
|
|
1506
|
+
return [{
|
|
1507
|
+
type: "text",
|
|
1508
|
+
text: `${`[lines ${v.lineBegin}..${v.lineEnd} of ${v.totalLines}${v.truncated ? "; truncated to 256KiB" : ""}]`}\n${v.text}`
|
|
1509
|
+
}];
|
|
1510
|
+
}
|
|
1511
|
+
},
|
|
1512
|
+
execute: (args, exec) => {
|
|
1513
|
+
exec.signal.throwIfAborted();
|
|
1514
|
+
const sessionId = sessionIdOf(exec);
|
|
1515
|
+
registry.assertOwned(args.uuid, sessionId);
|
|
1516
|
+
const result = registry.read(args.uuid, args.offset, args.count);
|
|
1517
|
+
const bounded = boundBytes(result.text, READ_BYTE_LIMIT);
|
|
1518
|
+
return Promise.resolve({
|
|
1519
|
+
text: bounded.text,
|
|
1520
|
+
totalLines: result.totalLines,
|
|
1521
|
+
lineBegin: result.lineBegin,
|
|
1522
|
+
lineEnd: result.lineEnd,
|
|
1523
|
+
truncated: bounded.truncated
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
}));
|
|
1527
|
+
register(defineTool({
|
|
1528
|
+
name: "terminal_wait_for",
|
|
1529
|
+
description: "Block until a substring appears in a terminal's retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. Use this to synchronize on command completion cues ( e.g. a shell prompt, \"done\", \"Listening on\", \"Build successful\" ) without busy-polling terminal_read. The wait scans the FULL retained transcript (up to ~1 MiB) on every poll, so a needle that scrolled past the most recent chunk is still a match. Returns `found` with the line/column of the first occurrence, `timeout` if the needle did not appear in time, or `exited` if the terminal process died before the needle appeared. Default timeout is 10 seconds; raise it for long-running commands ( dev servers, test suites ). The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately.",
|
|
1530
|
+
parameters: {
|
|
1531
|
+
uuid: {
|
|
1532
|
+
type: "string",
|
|
1533
|
+
required: true,
|
|
1534
|
+
description: "Terminal uuid from terminal_create or terminal_list."
|
|
1535
|
+
},
|
|
1536
|
+
needle: {
|
|
1537
|
+
type: "string",
|
|
1538
|
+
required: true,
|
|
1539
|
+
description: "Substring to wait for (case-sensitive, verbatim). Must be non-empty."
|
|
1540
|
+
},
|
|
1541
|
+
timeout_ms: {
|
|
1542
|
+
type: "number",
|
|
1543
|
+
description: "Maximum wait in milliseconds (default 10000, i.e. 10s). Clamped to a minimum of 100ms."
|
|
1544
|
+
}
|
|
1545
|
+
},
|
|
1546
|
+
output: {
|
|
1547
|
+
schema: { oneOf: [
|
|
1548
|
+
{
|
|
1549
|
+
type: "object",
|
|
1550
|
+
additionalProperties: false,
|
|
1551
|
+
properties: {
|
|
1552
|
+
kind: {
|
|
1553
|
+
type: "string",
|
|
1554
|
+
required: true,
|
|
1555
|
+
const: "found"
|
|
1556
|
+
},
|
|
1557
|
+
needle: {
|
|
1558
|
+
type: "string",
|
|
1559
|
+
required: true
|
|
1560
|
+
},
|
|
1561
|
+
line: {
|
|
1562
|
+
type: "integer",
|
|
1563
|
+
required: true,
|
|
1564
|
+
description: "0-based line index in the retained transcript where the needle first appeared."
|
|
1565
|
+
},
|
|
1566
|
+
column: {
|
|
1567
|
+
type: "integer",
|
|
1568
|
+
required: true,
|
|
1569
|
+
description: "0-based column index within that line where the match starts."
|
|
1570
|
+
},
|
|
1571
|
+
elapsedMs: {
|
|
1572
|
+
type: "integer",
|
|
1573
|
+
required: true,
|
|
1574
|
+
description: "Wall-clock milliseconds from wait start to match."
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
},
|
|
1578
|
+
{
|
|
1579
|
+
type: "object",
|
|
1580
|
+
additionalProperties: false,
|
|
1581
|
+
properties: {
|
|
1582
|
+
kind: {
|
|
1583
|
+
type: "string",
|
|
1584
|
+
required: true,
|
|
1585
|
+
const: "timeout"
|
|
1586
|
+
},
|
|
1587
|
+
needle: {
|
|
1588
|
+
type: "string",
|
|
1589
|
+
required: true
|
|
1590
|
+
},
|
|
1591
|
+
timeoutMs: {
|
|
1592
|
+
type: "integer",
|
|
1593
|
+
required: true,
|
|
1594
|
+
description: "The configured timeout that elapsed."
|
|
1595
|
+
},
|
|
1596
|
+
totalLines: {
|
|
1597
|
+
type: "integer",
|
|
1598
|
+
required: true,
|
|
1599
|
+
description: "Total lines retained when the timeout fired. Call terminal_read to inspect the tail."
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
},
|
|
1603
|
+
{
|
|
1604
|
+
type: "object",
|
|
1605
|
+
additionalProperties: false,
|
|
1606
|
+
properties: {
|
|
1607
|
+
kind: {
|
|
1608
|
+
type: "string",
|
|
1609
|
+
required: true,
|
|
1610
|
+
const: "exited"
|
|
1611
|
+
},
|
|
1612
|
+
needle: {
|
|
1613
|
+
type: "string",
|
|
1614
|
+
required: true
|
|
1615
|
+
},
|
|
1616
|
+
exitCode: {
|
|
1617
|
+
oneOf: [{ type: "integer" }, { type: "null" }],
|
|
1618
|
+
description: "Exit code, if known."
|
|
1619
|
+
},
|
|
1620
|
+
exitSignal: {
|
|
1621
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
1622
|
+
description: "Exit signal name, if killed by a signal."
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
] },
|
|
1627
|
+
render: (_args, value) => {
|
|
1628
|
+
const v = value;
|
|
1629
|
+
if (v.kind === "found") return [{
|
|
1630
|
+
type: "text",
|
|
1631
|
+
text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsedMs}ms).`
|
|
1632
|
+
}];
|
|
1633
|
+
if (v.kind === "timeout") return [{
|
|
1634
|
+
type: "text",
|
|
1635
|
+
text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.`
|
|
1636
|
+
}];
|
|
1637
|
+
const exitInfo = v.exitCode !== void 0 && v.exitCode !== null ? ` (exit code ${v.exitCode})` : "";
|
|
1638
|
+
return [{
|
|
1639
|
+
type: "text",
|
|
1640
|
+
text: `Terminal exited before "${v.needle}" appeared${exitInfo}.`
|
|
1641
|
+
}];
|
|
1642
|
+
}
|
|
1643
|
+
},
|
|
1644
|
+
async execute(args, exec) {
|
|
1645
|
+
exec.signal.throwIfAborted();
|
|
1646
|
+
const sessionId = sessionIdOf(exec);
|
|
1647
|
+
registry.assertOwned(args.uuid, sessionId);
|
|
1648
|
+
const timeoutMs = args.timeout_ms ?? 1e4;
|
|
1649
|
+
return await registry.waitFor(args.uuid, args.needle, timeoutMs, exec.signal);
|
|
1650
|
+
}
|
|
1651
|
+
}));
|
|
1652
|
+
register(defineTool({
|
|
1653
|
+
name: "terminal_resize",
|
|
1654
|
+
description: "Resize an agent terminal's pty ( cols × rows ). The host clamps both to a 2..1024 sane range. Most shells redraw their prompt and any full-screen TUI on the next output frame. No-op if the terminal has exited. Returns the dimensions actually applied.",
|
|
1655
|
+
parameters: {
|
|
1656
|
+
uuid: {
|
|
1657
|
+
type: "string",
|
|
1658
|
+
required: true,
|
|
1659
|
+
description: "Terminal uuid from terminal_create or terminal_list."
|
|
1660
|
+
},
|
|
1661
|
+
cols: {
|
|
1662
|
+
type: "integer",
|
|
1663
|
+
required: true,
|
|
1664
|
+
description: "New column count ( clamped to 2..1024 )."
|
|
1665
|
+
},
|
|
1666
|
+
rows: {
|
|
1667
|
+
type: "integer",
|
|
1668
|
+
required: true,
|
|
1669
|
+
description: "New row count ( clamped to 2..1024 )."
|
|
1670
|
+
}
|
|
1671
|
+
},
|
|
1672
|
+
output: {
|
|
1673
|
+
schema: {
|
|
1674
|
+
type: "object",
|
|
1675
|
+
additionalProperties: false,
|
|
1676
|
+
properties: {
|
|
1677
|
+
uuid: {
|
|
1678
|
+
type: "string",
|
|
1679
|
+
required: true
|
|
1680
|
+
},
|
|
1681
|
+
cols: {
|
|
1682
|
+
type: "integer",
|
|
1683
|
+
required: true
|
|
1684
|
+
},
|
|
1685
|
+
rows: {
|
|
1686
|
+
type: "integer",
|
|
1687
|
+
required: true
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
},
|
|
1691
|
+
render: textRender((v) => `Resized terminal ${v.uuid} to ${v.cols}×${v.rows}.`)
|
|
1692
|
+
},
|
|
1693
|
+
execute: (args, exec) => {
|
|
1694
|
+
exec.signal.throwIfAborted();
|
|
1695
|
+
const sessionId = sessionIdOf(exec);
|
|
1696
|
+
registry.assertOwned(args.uuid, sessionId);
|
|
1697
|
+
const dims = registry.resize(args.uuid, args.cols, args.rows);
|
|
1698
|
+
return Promise.resolve({
|
|
1699
|
+
uuid: args.uuid,
|
|
1700
|
+
...dims
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
}));
|
|
1704
|
+
register(defineTool({
|
|
1705
|
+
name: "terminal_signal",
|
|
1706
|
+
description: "Send a POSIX signal to an agent terminal's foreground process — this is how you send Ctrl+C, Ctrl+Z, etc. Use signal=\"SIGINT\" for Ctrl+C (interrupt the running command), signal=\"SIGTERM\" to request termination, signal=\"SIGKILL\" to force-kill the pty, signal=\"SIGHUP\" to hang up (many shells exit), signal=\"SIGTSTP\" for Ctrl+Z (suspend). Do NOT try to send control characters (like \"\\u0003\") through terminal_send — use this tool instead. On Windows, only SIGKILL and SIGTERM are effective — others are accepted but may no-op. No-op if the terminal has already exited. Use terminal_close to dispose of the terminal entirely.",
|
|
1707
|
+
parameters: {
|
|
1708
|
+
uuid: {
|
|
1709
|
+
type: "string",
|
|
1710
|
+
required: true,
|
|
1711
|
+
description: "Terminal uuid from terminal_create or terminal_list."
|
|
1712
|
+
},
|
|
1713
|
+
signal: {
|
|
1714
|
+
type: "string",
|
|
1715
|
+
required: true,
|
|
1716
|
+
enum: ALLOWED_SIGNALS,
|
|
1717
|
+
description: "Signal to deliver: SIGINT (Ctrl+C) | SIGTERM | SIGKILL | SIGHUP | SIGTSTP (Ctrl+Z)."
|
|
1718
|
+
}
|
|
1719
|
+
},
|
|
1720
|
+
output: {
|
|
1721
|
+
schema: {
|
|
1722
|
+
type: "object",
|
|
1723
|
+
additionalProperties: false,
|
|
1724
|
+
properties: {
|
|
1725
|
+
uuid: {
|
|
1726
|
+
type: "string",
|
|
1727
|
+
required: true
|
|
1728
|
+
},
|
|
1729
|
+
signal: {
|
|
1730
|
+
type: "string",
|
|
1731
|
+
required: true
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
},
|
|
1735
|
+
render: textRender((v) => `Sent ${v.signal} to terminal ${v.uuid}.`)
|
|
1736
|
+
},
|
|
1737
|
+
execute: (args, exec) => {
|
|
1738
|
+
exec.signal.throwIfAborted();
|
|
1739
|
+
const sessionId = sessionIdOf(exec);
|
|
1740
|
+
registry.assertOwned(args.uuid, sessionId);
|
|
1741
|
+
registry.signal(args.uuid, args.signal);
|
|
1742
|
+
return Promise.resolve({
|
|
1743
|
+
uuid: args.uuid,
|
|
1744
|
+
signal: args.signal
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
}));
|
|
1748
|
+
register(defineTool({
|
|
1749
|
+
name: "terminal_close",
|
|
1750
|
+
description: "Close an agent terminal and release its process. The uuid becomes invalid for all subsequent tool calls. Idempotent: closing an already-closed uuid is a no-op. The corresponding sidebar tab is removed automatically when the host pushes the updated terminal list. Always close terminals you no longer need — the host keeps the pty alive until you do.",
|
|
1751
|
+
parameters: { uuid: {
|
|
1752
|
+
type: "string",
|
|
1753
|
+
required: true,
|
|
1754
|
+
description: "Terminal uuid from terminal_create or terminal_list."
|
|
1755
|
+
} },
|
|
1756
|
+
output: {
|
|
1757
|
+
schema: {
|
|
1758
|
+
type: "object",
|
|
1759
|
+
additionalProperties: false,
|
|
1760
|
+
properties: {
|
|
1761
|
+
uuid: {
|
|
1762
|
+
type: "string",
|
|
1763
|
+
required: true
|
|
1764
|
+
},
|
|
1765
|
+
closed: {
|
|
1766
|
+
type: "boolean",
|
|
1767
|
+
required: true,
|
|
1768
|
+
description: "Whether a live terminal was actually dropped (false if the uuid was already gone)."
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
},
|
|
1772
|
+
render: textRender((v) => v.closed ? `Closed terminal ${v.uuid}.` : `Terminal ${v.uuid} was already closed.`)
|
|
1773
|
+
},
|
|
1774
|
+
execute: (args, exec) => {
|
|
1775
|
+
exec.signal.throwIfAborted();
|
|
1776
|
+
const sessionId = sessionIdOf(exec);
|
|
1777
|
+
registry.assertOwned(args.uuid, sessionId);
|
|
1778
|
+
const closed = registry.close(args.uuid);
|
|
1779
|
+
return Promise.resolve({
|
|
1780
|
+
uuid: args.uuid,
|
|
1781
|
+
closed
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
}));
|
|
1785
|
+
return () => {
|
|
1786
|
+
for (const dispose of disposers) dispose();
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
//#endregion
|
|
1790
|
+
//#region src/jobs-routes.ts
|
|
1791
|
+
/**
|
|
1792
|
+
* Extract the plain text of a finalized tool result: the text blocks inside
|
|
1793
|
+
* the 'tool-result' block, joined with newlines. Error results and
|
|
1794
|
+
* non-text blocks contribute nothing.
|
|
1795
|
+
*/
|
|
1796
|
+
function resultText(message) {
|
|
1797
|
+
if (!Array.isArray(message.content)) return void 0;
|
|
1798
|
+
const parts = [];
|
|
1799
|
+
for (const block of message.content) {
|
|
1800
|
+
if (block === null || typeof block !== "object") continue;
|
|
1801
|
+
const candidate = block;
|
|
1802
|
+
if (candidate.type !== "tool-result") continue;
|
|
1803
|
+
const inner = candidate.content;
|
|
1804
|
+
if (!Array.isArray(inner)) continue;
|
|
1805
|
+
for (const item of inner) {
|
|
1806
|
+
if (item === null || typeof item !== "object") continue;
|
|
1807
|
+
const textItem = item;
|
|
1808
|
+
if (textItem.type === "text" && typeof textItem.text === "string") parts.push(textItem.text);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
return parts.length > 0 ? parts.join("\n") : void 0;
|
|
1812
|
+
}
|
|
1813
|
+
/** Whether a tool/result is an error result (the inner block's isError flag). */
|
|
1814
|
+
function resultIsError(message) {
|
|
1815
|
+
if (!Array.isArray(message.content)) return false;
|
|
1816
|
+
return message.content.some((block) => {
|
|
1817
|
+
if (block === null || typeof block !== "object") return false;
|
|
1818
|
+
return block.type === "tool-result" && block.isError === true;
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
/** Whether a job_output result carries no new output — the controller's
|
|
1822
|
+
* model-facing "(no new output)" body, noise for the human pane. */
|
|
1823
|
+
function isNoNewOutput(text) {
|
|
1824
|
+
return text.startsWith("(no new output)");
|
|
1825
|
+
}
|
|
1826
|
+
/** Extract the job_output trace of one raw session event (undefined = unrelated). */
|
|
1827
|
+
function traceOf(event) {
|
|
1828
|
+
if (event.type === "tool/call") {
|
|
1829
|
+
const data = event.data;
|
|
1830
|
+
if (data.name !== "job_output" || typeof data.callId !== "string") return void 0;
|
|
1831
|
+
let jobId;
|
|
1832
|
+
try {
|
|
1833
|
+
const args = JSON.parse(typeof data.arguments === "string" ? data.arguments : "");
|
|
1834
|
+
if (typeof args.job_id === "string") jobId = args.job_id;
|
|
1835
|
+
} catch {}
|
|
1836
|
+
if (jobId === void 0) return void 0;
|
|
1837
|
+
return {
|
|
1838
|
+
seq: event.seq,
|
|
1839
|
+
kind: "call",
|
|
1840
|
+
callId: data.callId,
|
|
1841
|
+
jobId
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
if (event.type === "tool/result") {
|
|
1845
|
+
const message = event.data.message;
|
|
1846
|
+
if (message === void 0) return void 0;
|
|
1847
|
+
const callId = message.source?.callId;
|
|
1848
|
+
if (typeof callId !== "string") return void 0;
|
|
1849
|
+
return {
|
|
1850
|
+
seq: event.seq,
|
|
1851
|
+
kind: "result",
|
|
1852
|
+
callId,
|
|
1853
|
+
text: resultText(message),
|
|
1854
|
+
isError: resultIsError(message)
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
/** Per-session cap of mirrored live traces (a bounded, lossy ring). */
|
|
1859
|
+
const MIRROR_MAX_ENTRIES = 200;
|
|
1860
|
+
/**
|
|
1861
|
+
* The live job_output mirror: subscribes to the session append feed and
|
|
1862
|
+
* caches the job_output traces the session store's own log can lag behind
|
|
1863
|
+
* (after a host restart the store session stays frozen at its rehydration
|
|
1864
|
+
* boundary, so `session.events` misses everything appended since — the very
|
|
1865
|
+
* reads the pane exists to show). Zero DSH writes: the api-proxy pushes the
|
|
1866
|
+
* same feed to browsers.
|
|
1867
|
+
*/
|
|
1868
|
+
function createJobOutputMirror(ctx) {
|
|
1869
|
+
const perSession = /* @__PURE__ */ new Map();
|
|
1870
|
+
const callIds = /* @__PURE__ */ new Map();
|
|
1871
|
+
if (typeof ctx.on !== "function") return { entries: () => [] };
|
|
1872
|
+
const dispose = ctx.on("session/event", (session, event) => {
|
|
1873
|
+
const sessionId = session?.id;
|
|
1874
|
+
if (typeof sessionId !== "string") return;
|
|
1875
|
+
if (event.type === "tool/call") {
|
|
1876
|
+
const trace = traceOf(event);
|
|
1877
|
+
if (trace?.kind !== "call") return;
|
|
1878
|
+
let ids = callIds.get(sessionId);
|
|
1879
|
+
if (ids === void 0) callIds.set(sessionId, ids = /* @__PURE__ */ new Set());
|
|
1880
|
+
ids.add(trace.callId);
|
|
1881
|
+
push(sessionId, trace);
|
|
1882
|
+
} else if (event.type === "tool/result") {
|
|
1883
|
+
const trace = traceOf(event);
|
|
1884
|
+
if (trace?.kind !== "result") return;
|
|
1885
|
+
if (!callIds.get(sessionId)?.has(trace.callId)) return;
|
|
1886
|
+
push(sessionId, trace);
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
ctx.effect(() => dispose, "dsh-better-sidebar: job-output event mirror");
|
|
1890
|
+
const push = (sessionId, trace) => {
|
|
1891
|
+
let list = perSession.get(sessionId);
|
|
1892
|
+
if (list === void 0) perSession.set(sessionId, list = []);
|
|
1893
|
+
list.push(trace);
|
|
1894
|
+
if (list.length > MIRROR_MAX_ENTRIES) {
|
|
1895
|
+
const removed = list.splice(0, list.length - MIRROR_MAX_ENTRIES);
|
|
1896
|
+
const ids = callIds.get(sessionId);
|
|
1897
|
+
if (ids !== void 0) {
|
|
1898
|
+
for (const entry of removed) if (entry.kind === "call") ids.delete(entry.callId);
|
|
1899
|
+
if (ids.size === 0) callIds.delete(sessionId);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
return { entries: (sessionId) => perSession.get(sessionId) ?? [] };
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Build the jobs routes bound to the plugin context. `output` merges the
|
|
1907
|
+
* owner session's own event log with the live job_output mirror; `kill`
|
|
1908
|
+
* reads the jobs/agents services lazily and degrades to a 503 when the
|
|
1909
|
+
* deployment lacks the registry.
|
|
1910
|
+
* @param ctx - host plugin context.
|
|
1911
|
+
* @param outputLimit - response cap for one output replay in bytes; longer
|
|
1912
|
+
* texts are sliced and flagged `truncated` (mirrors the fs.read cap).
|
|
1913
|
+
*/
|
|
1914
|
+
function buildJobsApi(ctx, outputLimit) {
|
|
1915
|
+
const jobs = ctx.get("jobs");
|
|
1916
|
+
const agents = ctx.get("agents");
|
|
1917
|
+
const mirror = createJobOutputMirror(ctx);
|
|
1918
|
+
/** The live caller whose session id the registry fence compares against. */
|
|
1919
|
+
const callerOf = (sessionId) => agents?.get(sessionId);
|
|
1920
|
+
/** Registry refusals become a 404 job-error; unknown and foreign ids are indistinguishable. */
|
|
1921
|
+
const registryError = (error) => new SidebarError("job-error", error instanceof Error ? error.message : String(error), 404);
|
|
1922
|
+
return {
|
|
1923
|
+
output(payload) {
|
|
1924
|
+
const sessionId = requireString(payload, "sessionId");
|
|
1925
|
+
const id = requireString(payload, "id");
|
|
1926
|
+
const bySeq = /* @__PURE__ */ new Map();
|
|
1927
|
+
for (const event of ctx.sessions.get(sessionId)?.events ?? []) {
|
|
1928
|
+
const trace = traceOf(event);
|
|
1929
|
+
if (trace !== void 0) bySeq.set(trace.seq, trace);
|
|
1930
|
+
}
|
|
1931
|
+
for (const trace of mirror.entries(sessionId)) bySeq.set(trace.seq, trace);
|
|
1932
|
+
const jobOf = /* @__PURE__ */ new Map();
|
|
1933
|
+
const parts = [];
|
|
1934
|
+
let read = false;
|
|
1935
|
+
for (const trace of [...bySeq.values()].sort((left, right) => left.seq - right.seq)) if (trace.kind === "call") {
|
|
1936
|
+
if (trace.jobId !== void 0) jobOf.set(trace.callId, trace.jobId);
|
|
1937
|
+
} else if (jobOf.get(trace.callId) === id) {
|
|
1938
|
+
read = true;
|
|
1939
|
+
if (trace.isError !== true && trace.text !== void 0 && !isNoNewOutput(trace.text)) parts.push(trace.text);
|
|
1940
|
+
}
|
|
1941
|
+
const text = parts.join("\n");
|
|
1942
|
+
return {
|
|
1943
|
+
text: text.length > outputLimit ? text.slice(0, outputLimit) : text,
|
|
1944
|
+
truncated: text.length > outputLimit,
|
|
1945
|
+
read
|
|
1946
|
+
};
|
|
1947
|
+
},
|
|
1948
|
+
kill(payload) {
|
|
1949
|
+
if (jobs === void 0) throw new SidebarError("job-error", "the background-job registry is not mounted in this deployment", 503);
|
|
1950
|
+
const sessionId = requireString(payload, "sessionId");
|
|
1951
|
+
const id = requireString(payload, "id");
|
|
1952
|
+
const record = payload;
|
|
1953
|
+
const reason = typeof record?.reason === "string" && record.reason !== "" ? record.reason : "user requested via sidebar";
|
|
1954
|
+
try {
|
|
1955
|
+
return {
|
|
1956
|
+
ok: true,
|
|
1957
|
+
outcome: jobs.kill(id, callerOf(sessionId), reason)
|
|
1958
|
+
};
|
|
1959
|
+
} catch (error) {
|
|
1960
|
+
throw registryError(error);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
//#endregion
|
|
1966
|
+
//#region src/index.ts
|
|
1967
|
+
/**
|
|
1968
|
+
* dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
|
|
1969
|
+
* read/write, git), the /sidebar/file media route (images), the /sidebar/html
|
|
1970
|
+
* preview route, the /sidebar/bundle lazy-chunk route (client code splits),
|
|
1971
|
+
* and the terminal WebSocket upgrade. Every route passes the same
|
|
1972
|
+
* browser-trust fence as the /api gateway — Host-header loopback or the
|
|
1973
|
+
* connection row's `trustedHosts` (the `dsh web` launcher derives LAN IP
|
|
1974
|
+
* literals per boot) — with the trustedHosts read live from the connection
|
|
1975
|
+
* loader row so the fence never drifts from the deployment's.
|
|
1976
|
+
*
|
|
1977
|
+
* All operations are conversation-scoped: requests carry a sessionId, the
|
|
1978
|
+
* session's authoritative cwd comes from the session store, and terminal
|
|
1979
|
+
* processes are keyed by session.
|
|
1980
|
+
*/
|
|
1981
|
+
/** Plugin identity for cordis.yml rows. */
|
|
1982
|
+
const name = "dsh-better-sidebar";
|
|
1983
|
+
/** Services required before mounting: the webserver routes, the session store, the loader's connection row, and the tool registry. */
|
|
1984
|
+
const inject = [
|
|
1985
|
+
"webServer",
|
|
1986
|
+
"sessions",
|
|
1987
|
+
"loader",
|
|
1988
|
+
"tools"
|
|
1989
|
+
];
|
|
1990
|
+
/** Content types for the media route, by extension. */
|
|
1991
|
+
const MEDIA_TYPES = {
|
|
1992
|
+
".png": "image/png",
|
|
1993
|
+
".jpg": "image/jpeg",
|
|
1994
|
+
".jpeg": "image/jpeg",
|
|
1995
|
+
".gif": "image/gif",
|
|
1996
|
+
".webp": "image/webp",
|
|
1997
|
+
".svg": "image/svg+xml",
|
|
1998
|
+
".bmp": "image/bmp",
|
|
1999
|
+
".ico": "image/x-icon",
|
|
2000
|
+
".avif": "image/avif",
|
|
2001
|
+
".pdf": "application/pdf",
|
|
2002
|
+
".html": "text/html",
|
|
2003
|
+
".htm": "text/html"
|
|
2004
|
+
};
|
|
2005
|
+
/** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
|
|
2006
|
+
function mediaTypeForPath(path) {
|
|
2007
|
+
return MEDIA_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
2008
|
+
}
|
|
2009
|
+
/** The connection row's resolved trustedHosts (live read; the /api fence's own list). */
|
|
2010
|
+
function trustedHostsOf(ctx) {
|
|
2011
|
+
for (const entry of ctx.loader.entries()) if (entry.options.name === "connection") return entry.options.config?.trustedHosts ?? [];
|
|
2012
|
+
return [];
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* Resolve a session's authoritative working directory. The attached session
|
|
2016
|
+
* header wins; while the session is still hydrating from persistence (the
|
|
2017
|
+
* web client attaches the current conversation a moment after page load, so
|
|
2018
|
+
* the very first sidebar requests can arrive detached) the caller's own
|
|
2019
|
+
* list-summary cwd is used; the process cwd is the last resort (blank
|
|
2020
|
+
* sessions have no cwd anywhere yet). Never throws for a missing cwd, so
|
|
2021
|
+
* explorer/git/terminal work from first paint instead of surfacing
|
|
2022
|
+
* "session ... has no working directory".
|
|
2023
|
+
*/
|
|
2024
|
+
function sessionCwdOf(ctx, sessionId, clientCwd) {
|
|
2025
|
+
const headerCwd = ctx.sessions.get(sessionId)?.header.cwd;
|
|
2026
|
+
if (headerCwd !== void 0 && headerCwd !== "") return headerCwd;
|
|
2027
|
+
if (clientCwd !== void 0 && clientCwd !== "") try {
|
|
2028
|
+
return requireAbsolute(clientCwd);
|
|
2029
|
+
} catch {
|
|
2030
|
+
throw new SidebarError("bad-request", `invalid working directory "${clientCwd}"`);
|
|
2031
|
+
}
|
|
2032
|
+
return process.cwd();
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Resolve a path that a git command reported — `git status`/`git diff`
|
|
2036
|
+
* print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the
|
|
2037
|
+
* session cwd (a session inside a subdirectory of a repository). Absolute
|
|
2038
|
+
* paths pass through; relative ones join the repo root (falling back to the
|
|
2039
|
+
* cwd when the root cannot be resolved, e.g. a bare directory).
|
|
2040
|
+
*/
|
|
2041
|
+
async function resolveGitPath(cwd, raw) {
|
|
2042
|
+
if (isAbsolute(raw)) return requireAbsolute(raw);
|
|
2043
|
+
const root = await repoRoot(cwd).catch(() => cwd);
|
|
2044
|
+
return requireAbsolute(join(root, raw));
|
|
2045
|
+
}
|
|
2046
|
+
/** How many leading bytes a binary read returns for client-side detect sniffing. */
|
|
2047
|
+
const READ_HEAD_LIMIT = 4096;
|
|
2048
|
+
/** Text read of a file with the size cap; binary detection via NUL probe.
|
|
2049
|
+
* Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64)
|
|
2050
|
+
* so the client can re-match viewers by content (`detect`). */
|
|
2051
|
+
async function readText(path, readLimit) {
|
|
2052
|
+
const info = await stat(path).catch((error) => {
|
|
2053
|
+
throw new SidebarError("fs-error", `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
2054
|
+
});
|
|
2055
|
+
if (info.isDirectory()) throw new SidebarError("fs-error", `"${path}" is a directory`, 400);
|
|
2056
|
+
const size = info.size;
|
|
2057
|
+
const truncated = size > readLimit;
|
|
2058
|
+
const handle = await open(path, "r").catch((error) => {
|
|
2059
|
+
throw new SidebarError("fs-error", `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
2060
|
+
});
|
|
2061
|
+
try {
|
|
2062
|
+
const buffer = Buffer.alloc(Math.min(size, readLimit));
|
|
2063
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
2064
|
+
const slice = buffer.subarray(0, bytesRead);
|
|
2065
|
+
const binary = slice.includes(0);
|
|
2066
|
+
const head = binary ? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString("base64") : void 0;
|
|
2067
|
+
return {
|
|
2068
|
+
content: binary ? "" : slice.toString("utf8"),
|
|
2069
|
+
truncated,
|
|
2070
|
+
binary,
|
|
2071
|
+
size,
|
|
2072
|
+
head
|
|
2073
|
+
};
|
|
2074
|
+
} finally {
|
|
2075
|
+
await handle.close();
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */
|
|
2079
|
+
function buildApi(ctx, ptyManager, agentPtyRegistry, resolved, getSettings) {
|
|
2080
|
+
const cwdOf = (payload) => {
|
|
2081
|
+
const sessionId = requireString(payload, "sessionId");
|
|
2082
|
+
const record = payload;
|
|
2083
|
+
return {
|
|
2084
|
+
sessionId,
|
|
2085
|
+
cwd: sessionCwdOf(ctx, sessionId, typeof record?.cwd === "string" && record.cwd !== "" ? record.cwd : void 0)
|
|
2086
|
+
};
|
|
2087
|
+
};
|
|
2088
|
+
const jobsApi = buildJobsApi(ctx, resolved.readLimit);
|
|
2089
|
+
return {
|
|
2090
|
+
"session.cwd": (payload) => {
|
|
2091
|
+
const { sessionId, cwd } = cwdOf(payload);
|
|
2092
|
+
return {
|
|
2093
|
+
sessionId,
|
|
2094
|
+
cwd,
|
|
2095
|
+
root: rootLabel(cwd),
|
|
2096
|
+
parent: parentOf(cwd) ?? null
|
|
2097
|
+
};
|
|
2098
|
+
},
|
|
2099
|
+
"fs.tree": async (payload) => {
|
|
2100
|
+
const { cwd } = cwdOf(payload);
|
|
2101
|
+
return listDirectory(payload.path === void 0 ? cwd : requireAbsolute(requireString(payload, "path")), resolved.listLimit);
|
|
2102
|
+
},
|
|
2103
|
+
"fs.read": async (payload) => {
|
|
2104
|
+
const { cwd } = cwdOf(payload);
|
|
2105
|
+
const { content, truncated, binary, size, head } = await readText(await resolveGitPath(cwd, requireString(payload, "path")), resolved.readLimit);
|
|
2106
|
+
if (binary) return {
|
|
2107
|
+
kind: "binary",
|
|
2108
|
+
size,
|
|
2109
|
+
truncated,
|
|
2110
|
+
head
|
|
2111
|
+
};
|
|
2112
|
+
return {
|
|
2113
|
+
kind: "text",
|
|
2114
|
+
content,
|
|
2115
|
+
truncated
|
|
2116
|
+
};
|
|
2117
|
+
},
|
|
2118
|
+
"fs.write": async (payload) => {
|
|
2119
|
+
const { cwd } = cwdOf(payload);
|
|
2120
|
+
const path = requireAbsolute(requireString(payload, "path"));
|
|
2121
|
+
const content = requireString(payload, "content");
|
|
2122
|
+
const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`;
|
|
2123
|
+
try {
|
|
2124
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2125
|
+
await writeFile(tmp, content, "utf8");
|
|
2126
|
+
await rename(tmp, path);
|
|
2127
|
+
} catch (error) {
|
|
2128
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
2129
|
+
throw new SidebarError("fs-error", `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
2130
|
+
}
|
|
2131
|
+
return { ok: true };
|
|
2132
|
+
},
|
|
2133
|
+
"git.status": async (payload) => {
|
|
2134
|
+
const { cwd } = cwdOf(payload);
|
|
2135
|
+
return status(cwd);
|
|
2136
|
+
},
|
|
2137
|
+
"git.diff": async (payload) => {
|
|
2138
|
+
const { cwd } = cwdOf(payload);
|
|
2139
|
+
const record = payload;
|
|
2140
|
+
return { diff: await diff(cwd, record.path === void 0 ? void 0 : await resolveGitPath(cwd, requireString(payload, "path")), record.staged === true) };
|
|
2141
|
+
},
|
|
2142
|
+
"git.stage": async (payload) => {
|
|
2143
|
+
const { cwd } = cwdOf(payload);
|
|
2144
|
+
await stage(cwd, payload.path === void 0 ? void 0 : requireString(payload, "path"));
|
|
2145
|
+
return { ok: true };
|
|
2146
|
+
},
|
|
2147
|
+
"git.unstage": async (payload) => {
|
|
2148
|
+
const { cwd } = cwdOf(payload);
|
|
2149
|
+
await unstage(cwd, payload.path === void 0 ? void 0 : requireString(payload, "path"));
|
|
2150
|
+
return { ok: true };
|
|
2151
|
+
},
|
|
2152
|
+
"git.commit": async (payload) => {
|
|
2153
|
+
const { cwd } = cwdOf(payload);
|
|
2154
|
+
await commit(cwd, requireString(payload, "message"));
|
|
2155
|
+
return { ok: true };
|
|
2156
|
+
},
|
|
2157
|
+
"git.branch": async (payload) => {
|
|
2158
|
+
const { cwd } = cwdOf(payload);
|
|
2159
|
+
return branches(cwd);
|
|
2160
|
+
},
|
|
2161
|
+
"git.checkout": async (payload) => {
|
|
2162
|
+
const { cwd } = cwdOf(payload);
|
|
2163
|
+
await checkout(cwd, requireString(payload, "branch"));
|
|
2164
|
+
return { ok: true };
|
|
2165
|
+
},
|
|
2166
|
+
"git.log": async (payload) => {
|
|
2167
|
+
const { cwd } = cwdOf(payload);
|
|
2168
|
+
const record = payload;
|
|
2169
|
+
return log(cwd, typeof record.count === "number" && Number.isInteger(record.count) && record.count > 0 ? record.count : void 0, typeof record.skip === "number" && Number.isInteger(record.skip) && record.skip >= 0 ? record.skip : void 0);
|
|
2170
|
+
},
|
|
2171
|
+
"git.commit-diff": async (payload) => {
|
|
2172
|
+
const { cwd } = cwdOf(payload);
|
|
2173
|
+
return { diff: await commitDiff(cwd, requireString(payload, "hash")) };
|
|
2174
|
+
},
|
|
2175
|
+
"git.discard": async (payload) => {
|
|
2176
|
+
const { cwd } = cwdOf(payload);
|
|
2177
|
+
await discard(cwd, await resolveGitPath(cwd, requireString(payload, "path")));
|
|
2178
|
+
return { ok: true };
|
|
2179
|
+
},
|
|
2180
|
+
"git.revert": async (payload) => {
|
|
2181
|
+
const { cwd } = cwdOf(payload);
|
|
2182
|
+
await revert(cwd, requireString(payload, "hash"));
|
|
2183
|
+
return { ok: true };
|
|
2184
|
+
},
|
|
2185
|
+
"git.cherry-pick": async (payload) => {
|
|
2186
|
+
const { cwd } = cwdOf(payload);
|
|
2187
|
+
await cherryPick(cwd, requireString(payload, "hash"));
|
|
2188
|
+
return { ok: true };
|
|
2189
|
+
},
|
|
2190
|
+
"git.show": async (payload) => {
|
|
2191
|
+
const { cwd } = cwdOf(payload);
|
|
2192
|
+
const path = await resolveGitPath(cwd, requireString(payload, "path"));
|
|
2193
|
+
return { content: await show(cwd, requireString(payload, "rev"), path) };
|
|
2194
|
+
},
|
|
2195
|
+
"pty.close": (payload) => {
|
|
2196
|
+
const sessionId = requireString(payload, "sessionId");
|
|
2197
|
+
const tab = requireString(payload, "tab");
|
|
2198
|
+
ptyManager.close(`${sessionId}:${tab}`);
|
|
2199
|
+
return { ok: true };
|
|
2200
|
+
},
|
|
2201
|
+
"agent-pty.close": (payload) => {
|
|
2202
|
+
const uuid = requireString(payload, "uuid");
|
|
2203
|
+
agentPtyRegistry.close(uuid);
|
|
2204
|
+
return { ok: true };
|
|
2205
|
+
},
|
|
2206
|
+
"jobs.output": (payload) => jobsApi.output(payload),
|
|
2207
|
+
"jobs.kill": (payload) => jobsApi.kill(payload),
|
|
2208
|
+
"settings.get": () => {
|
|
2209
|
+
return getSettings()?.get() ?? {
|
|
2210
|
+
value: void 0,
|
|
2211
|
+
revision: void 0
|
|
2212
|
+
};
|
|
2213
|
+
},
|
|
2214
|
+
"settings.update": async (payload) => {
|
|
2215
|
+
const settings = getSettings();
|
|
2216
|
+
if (settings === void 0) throw new SidebarError("settings-rejected", "the settings service is not mounted in this deployment", 503);
|
|
2217
|
+
const record = payload;
|
|
2218
|
+
const patch = record?.patch;
|
|
2219
|
+
if (patch === null || typeof patch !== "object" || Array.isArray(patch)) throw new SidebarError("bad-request", "patch must be a plain object");
|
|
2220
|
+
const expectedRevision = typeof record?.expectedRevision === "number" ? record.expectedRevision : void 0;
|
|
2221
|
+
try {
|
|
2222
|
+
return await settings.update(patch, expectedRevision);
|
|
2223
|
+
} catch (error) {
|
|
2224
|
+
if (error instanceof SettingsConflictError) throw new SidebarError("settings-conflict", error.message, 409);
|
|
2225
|
+
throw new SidebarError("settings-rejected", error instanceof Error ? error.message : String(error), 400);
|
|
2226
|
+
}
|
|
2227
|
+
},
|
|
2228
|
+
"browser.probe": async (payload) => {
|
|
2229
|
+
const raw = requireString(payload, "url");
|
|
2230
|
+
let parsed;
|
|
2231
|
+
try {
|
|
2232
|
+
parsed = new URL(raw);
|
|
2233
|
+
} catch {
|
|
2234
|
+
throw new SidebarError("bad-request", "invalid url", 400);
|
|
2235
|
+
}
|
|
2236
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new SidebarError("bad-request", "only http/https urls can be probed", 400);
|
|
2237
|
+
if (isLoopbackHostname(parsed.hostname)) throw new SidebarError("bad-request", "local addresses are not probed", 400);
|
|
2238
|
+
const controller = new AbortController();
|
|
2239
|
+
const timer = setTimeout(() => controller.abort(), 8e3);
|
|
2240
|
+
try {
|
|
2241
|
+
let response = await fetch(parsed, {
|
|
2242
|
+
method: "HEAD",
|
|
2243
|
+
redirect: "follow",
|
|
2244
|
+
signal: controller.signal
|
|
2245
|
+
});
|
|
2246
|
+
if (response.status === 405 || response.status === 501) response = await fetch(parsed, {
|
|
2247
|
+
method: "GET",
|
|
2248
|
+
redirect: "follow",
|
|
2249
|
+
signal: controller.signal
|
|
2250
|
+
});
|
|
2251
|
+
const frameAncestors = extractFrameAncestors(response.headers.get("content-security-policy"));
|
|
2252
|
+
const xFrameOptions = response.headers.get("x-frame-options");
|
|
2253
|
+
return {
|
|
2254
|
+
reachable: true,
|
|
2255
|
+
url: response.url,
|
|
2256
|
+
status: response.status,
|
|
2257
|
+
...xFrameOptions !== null ? { xFrameOptions } : {},
|
|
2258
|
+
...frameAncestors !== void 0 ? { frameAncestors } : {}
|
|
2259
|
+
};
|
|
2260
|
+
} catch {
|
|
2261
|
+
return { reachable: false };
|
|
2262
|
+
} finally {
|
|
2263
|
+
clearTimeout(timer);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2268
|
+
/**
|
|
2269
|
+
* Plugin body: mount the fenced routes and the pty lifecycle.
|
|
2270
|
+
* @param ctx - host plugin context (webServer, sessions, loader).
|
|
2271
|
+
* @param config - deployment-provided limits; the Loader validates against
|
|
2272
|
+
* {@link Config} and fills defaults, direct callers get them from
|
|
2273
|
+
* {@link resolveSidebarConfig}.
|
|
2274
|
+
*/
|
|
2275
|
+
function apply(ctx, config) {
|
|
2276
|
+
ensureSpawnHelper();
|
|
2277
|
+
const resolved = resolveSidebarConfig(config);
|
|
2278
|
+
const trustedHosts = trustedHostsOf(ctx);
|
|
2279
|
+
const fence = (req) => isTrustedApiRequest(req, trustedHosts);
|
|
2280
|
+
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession);
|
|
2281
|
+
const agentPtyRegistry = new AgentPtyRegistry(defaultShell());
|
|
2282
|
+
let settingsFace;
|
|
2283
|
+
let toolsDisposers = null;
|
|
2284
|
+
const syncToolsGate = (scope) => {
|
|
2285
|
+
if (scope.get().agentTerminalTools) {
|
|
2286
|
+
if (toolsDisposers === null) toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId));
|
|
2287
|
+
} else if (toolsDisposers !== null) {
|
|
2288
|
+
toolsDisposers();
|
|
2289
|
+
toolsDisposers = null;
|
|
2290
|
+
agentPtyRegistry.disposeAll();
|
|
2291
|
+
}
|
|
2292
|
+
};
|
|
2293
|
+
ctx.inject(["settings"], (sctx) => {
|
|
2294
|
+
const ns = settingsNamespace(SIDEBAR_PREFS_NS);
|
|
2295
|
+
const scope = sctx.settings.register(ns, PrefsSchema);
|
|
2296
|
+
const viewOf = () => {
|
|
2297
|
+
const descriptor = sctx.settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === ns);
|
|
2298
|
+
return descriptor === void 0 ? {
|
|
2299
|
+
value: void 0,
|
|
2300
|
+
revision: void 0
|
|
2301
|
+
} : {
|
|
2302
|
+
value: descriptor.value,
|
|
2303
|
+
revision: descriptor.revision
|
|
2304
|
+
};
|
|
2305
|
+
};
|
|
2306
|
+
settingsFace = {
|
|
2307
|
+
get: viewOf,
|
|
2308
|
+
update: async (patch, expectedRevision) => {
|
|
2309
|
+
await sctx.settings.update(ns, patch, expectedRevision);
|
|
2310
|
+
return viewOf();
|
|
2311
|
+
}
|
|
2312
|
+
};
|
|
2313
|
+
syncToolsGate(scope);
|
|
2314
|
+
scope.watch(() => {
|
|
2315
|
+
syncToolsGate(scope);
|
|
2316
|
+
});
|
|
2317
|
+
});
|
|
2318
|
+
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace);
|
|
2319
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2320
|
+
kind: "prefix",
|
|
2321
|
+
path: "/sidebar/api",
|
|
2322
|
+
handler: async (req, res) => {
|
|
2323
|
+
if (!fence(req)) {
|
|
2324
|
+
writeJson(res, 403, {
|
|
2325
|
+
ok: false,
|
|
2326
|
+
error: {
|
|
2327
|
+
code: "forbidden",
|
|
2328
|
+
message: "forbidden"
|
|
2329
|
+
}
|
|
2330
|
+
});
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
if (req.method !== "POST") {
|
|
2334
|
+
writeJson(res, 405, {
|
|
2335
|
+
ok: false,
|
|
2336
|
+
error: {
|
|
2337
|
+
code: "method-error",
|
|
2338
|
+
message: "method not allowed"
|
|
2339
|
+
}
|
|
2340
|
+
});
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
|
|
2344
|
+
const method = pathname.startsWith("/sidebar/api/") ? pathname.slice(13) : void 0;
|
|
2345
|
+
if (method === void 0 || method.includes("/")) {
|
|
2346
|
+
writeError(res, new SidebarError("not-found", "unknown sidebar API method", 404));
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
try {
|
|
2350
|
+
const payload = await readJsonBody(req);
|
|
2351
|
+
const handler = api[method];
|
|
2352
|
+
if (handler === void 0) throw new SidebarError("not-found", `unknown sidebar API method "${method}"`, 404);
|
|
2353
|
+
writeOk(res, await handler(payload));
|
|
2354
|
+
} catch (error) {
|
|
2355
|
+
writeError(res, error);
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
}), "dsh-better-sidebar: /sidebar/api routes");
|
|
2359
|
+
ctx.effect(() => registerBundleRoute(ctx, fence), "dsh-better-sidebar: /sidebar/bundle chunk route");
|
|
2360
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2361
|
+
kind: "prefix",
|
|
2362
|
+
path: "/sidebar/file",
|
|
2363
|
+
handler: async (req, res) => {
|
|
2364
|
+
if (!fence(req)) {
|
|
2365
|
+
res.writeHead(403);
|
|
2366
|
+
res.end("forbidden");
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
2369
|
+
if (req.method !== "GET") {
|
|
2370
|
+
res.writeHead(405);
|
|
2371
|
+
res.end();
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
try {
|
|
2375
|
+
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
2376
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
2377
|
+
const raw = url.searchParams.get("path");
|
|
2378
|
+
if (sessionId === null || raw === null) throw new SidebarError("bad-request", "sessionId and path are required");
|
|
2379
|
+
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get("cwd") ?? void 0);
|
|
2380
|
+
const path = requireAbsolute(raw);
|
|
2381
|
+
if (!isWithin(cwd, path)) throw new SidebarError("fs-error", "media path outside the session working directory", 403);
|
|
2382
|
+
const info = await stat(path);
|
|
2383
|
+
if (!info.isFile() || info.size > resolved.mediaLimit) throw new SidebarError("fs-error", "not a file or too large", 400);
|
|
2384
|
+
const type = mediaTypeForPath(path);
|
|
2385
|
+
const body = await readFile(path);
|
|
2386
|
+
const headers = {
|
|
2387
|
+
"content-type": type,
|
|
2388
|
+
"cache-control": "no-cache"
|
|
2389
|
+
};
|
|
2390
|
+
if (url.searchParams.get("download") === "1") headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}`;
|
|
2391
|
+
res.writeHead(200, headers);
|
|
2392
|
+
res.end(body);
|
|
2393
|
+
} catch (error) {
|
|
2394
|
+
writeError(res, error);
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
}), "dsh-better-sidebar: /sidebar/file media route");
|
|
2398
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2399
|
+
kind: "prefix",
|
|
2400
|
+
path: "/sidebar/html",
|
|
2401
|
+
handler: async (req, res) => {
|
|
2402
|
+
if (!fence(req)) {
|
|
2403
|
+
res.writeHead(403);
|
|
2404
|
+
res.end("forbidden");
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
if (req.method !== "GET") {
|
|
2408
|
+
res.writeHead(405);
|
|
2409
|
+
res.end();
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
try {
|
|
2413
|
+
const decoded = decodeHtmlUrl(new URL(req.url ?? "/", "http://dsh.internal").pathname);
|
|
2414
|
+
if (!decoded.ok) {
|
|
2415
|
+
writeError(res, new SidebarError("bad-request", decoded.message, decoded.status));
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
const { sessionId, path } = decoded.ref;
|
|
2419
|
+
const cwd = sessionCwdOf(ctx, sessionId);
|
|
2420
|
+
const absolute = requireAbsolute(path);
|
|
2421
|
+
if (!isWithin(cwd, absolute)) throw new SidebarError("fs-error", "html path outside the session working directory", 403);
|
|
2422
|
+
const info = await stat(absolute);
|
|
2423
|
+
if (!info.isFile() || info.size > resolved.mediaLimit) throw new SidebarError("fs-error", "not a file or too large", 400);
|
|
2424
|
+
const type = mediaTypeForPath(absolute);
|
|
2425
|
+
const body = await readFile(absolute);
|
|
2426
|
+
res.writeHead(200, {
|
|
2427
|
+
"content-type": type,
|
|
2428
|
+
"cache-control": "no-cache",
|
|
2429
|
+
"x-content-type-options": "nosniff",
|
|
2430
|
+
"referrer-policy": "no-referrer",
|
|
2431
|
+
"content-security-policy": "sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'"
|
|
2432
|
+
});
|
|
2433
|
+
res.end(body);
|
|
2434
|
+
} catch (error) {
|
|
2435
|
+
writeError(res, error);
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
}), "dsh-better-sidebar: /sidebar/html preview route");
|
|
2439
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
2440
|
+
ctx.effect(() => ctx.webServer.registerUpgrade({
|
|
2441
|
+
path: "/sidebar/ws/terminal",
|
|
2442
|
+
handler: (req, socket, head) => {
|
|
2443
|
+
if (!fence(req)) {
|
|
2444
|
+
socket.destroy();
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2447
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
2448
|
+
attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved);
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
}), "dsh-better-sidebar: terminal WebSocket");
|
|
2452
|
+
const agentListWss = new WebSocketServer({ noServer: true });
|
|
2453
|
+
ctx.effect(() => ctx.webServer.registerUpgrade({
|
|
2454
|
+
path: "/sidebar/ws/agent-terminals",
|
|
2455
|
+
handler: (req, socket, head) => {
|
|
2456
|
+
if (!fence(req)) {
|
|
2457
|
+
socket.destroy();
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
agentListWss.handleUpgrade(req, socket, head, (ws) => {
|
|
2461
|
+
attachAgentList(agentPtyRegistry, ws, req);
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
}), "dsh-better-sidebar: agent-terminals push WebSocket");
|
|
2465
|
+
ctx.effect(() => () => {
|
|
2466
|
+
toolsDisposers?.();
|
|
2467
|
+
ptyManager.disposeAll();
|
|
2468
|
+
agentPtyRegistry.disposeAll();
|
|
2469
|
+
wss.close();
|
|
2470
|
+
agentListWss.close();
|
|
2471
|
+
}, "dsh-better-sidebar: teardown");
|
|
2472
|
+
}
|
|
2473
|
+
/** Push the live agent-terminal list for one session to a connected sidebar view. */
|
|
2474
|
+
async function attachAgentList(registry, ws, req) {
|
|
2475
|
+
try {
|
|
2476
|
+
const sessionId = new URL(req.url ?? "/", "http://dsh.internal").searchParams.get("sessionId");
|
|
2477
|
+
if (sessionId === null) {
|
|
2478
|
+
ws.close(1008, "sessionId is required");
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
const send = () => {
|
|
2482
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(registry.list(sessionId)));
|
|
2483
|
+
};
|
|
2484
|
+
send();
|
|
2485
|
+
const unsubscribe = registry.subscribe(send);
|
|
2486
|
+
ws.on("close", () => {
|
|
2487
|
+
unsubscribe();
|
|
2488
|
+
});
|
|
2489
|
+
ws.on("error", () => {
|
|
2490
|
+
unsubscribe();
|
|
2491
|
+
});
|
|
2492
|
+
} catch (error) {
|
|
2493
|
+
ws.close(1011, error instanceof Error ? error.message : String(error));
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Wire one terminal socket to its pty: replay transcript, pump both ways.
|
|
2498
|
+
* Two attach modes share the wire protocol:
|
|
2499
|
+
* - `?uuid=...` attaches to an agent-owned terminal (created by the
|
|
2500
|
+
* `terminal_create` tool). The close frame kills the pty immediately
|
|
2501
|
+
* (the agent's terminal closes when the user closes the sidebar tab); a
|
|
2502
|
+
* bare socket drop (refresh, tab switch) leaves the pty alive for the
|
|
2503
|
+
* reconnect grace, exactly like UI-tab terminals.
|
|
2504
|
+
* - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user
|
|
2505
|
+
* created it from the + menu). The close frame schedules a 0-ms close
|
|
2506
|
+
* (the host's reconnect grace keeps the shell alive across a refresh).
|
|
2507
|
+
*/
|
|
2508
|
+
async function attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved) {
|
|
2509
|
+
try {
|
|
2510
|
+
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
2511
|
+
const uuid = url.searchParams.get("uuid");
|
|
2512
|
+
if (uuid !== null) {
|
|
2513
|
+
const handle = agentPtyRegistry.get(uuid);
|
|
2514
|
+
if (handle === void 0) {
|
|
2515
|
+
ws.close(1011, `agent terminal "${uuid}" not found`);
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
pumpAgentTerminal(agentPtyRegistry, handle, ws);
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
2522
|
+
const tabId = url.searchParams.get("tab");
|
|
2523
|
+
if (sessionId === null || tabId === null) {
|
|
2524
|
+
ws.close(1008, "either ?uuid or ?sessionId+?tab are required");
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get("cwd") ?? void 0);
|
|
2528
|
+
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24);
|
|
2529
|
+
if (handle.transcript !== "") ws.send(handle.transcript);
|
|
2530
|
+
const onData = (data) => {
|
|
2531
|
+
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4194304) ws.send(data);
|
|
2532
|
+
};
|
|
2533
|
+
const onExit = ({ exitCode }) => {
|
|
2534
|
+
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`);
|
|
2535
|
+
};
|
|
2536
|
+
const dataSub = handle.pty.onData(onData);
|
|
2537
|
+
const exitSub = handle.pty.onExit(onExit);
|
|
2538
|
+
ws.on("message", (data) => {
|
|
2539
|
+
const text = data.toString("utf8");
|
|
2540
|
+
let control = null;
|
|
2541
|
+
try {
|
|
2542
|
+
const parsed = JSON.parse(text);
|
|
2543
|
+
if (parsed !== null && typeof parsed === "object") control = parsed;
|
|
2544
|
+
} catch {}
|
|
2545
|
+
if (control !== null && control.type === "close") {
|
|
2546
|
+
ptyManager.scheduleClose(handle.key, 0);
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
if (handle.exited) return;
|
|
2550
|
+
if (control !== null && control.type === "resize" && typeof control.cols === "number" && typeof control.rows === "number") {
|
|
2551
|
+
const dims = clampDims(control.cols, control.rows);
|
|
2552
|
+
handle.pty.resize(dims.cols, dims.rows);
|
|
2553
|
+
} else handle.pty.write(text);
|
|
2554
|
+
});
|
|
2555
|
+
ws.on("close", () => {
|
|
2556
|
+
dataSub.dispose();
|
|
2557
|
+
exitSub.dispose();
|
|
2558
|
+
ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs);
|
|
2559
|
+
});
|
|
2560
|
+
} catch (error) {
|
|
2561
|
+
ws.close(1011, error instanceof Error ? error.message : String(error));
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
/**
|
|
2565
|
+
* Pump one agent terminal's pty to a connected view. The close frame kills
|
|
2566
|
+
* the pty immediately (the agent's terminal closes when the user closes the
|
|
2567
|
+
* sidebar tab); a bare socket drop leaves the pty alive — the agent owns
|
|
2568
|
+
* the lifetime, and only `terminal_close`, a `{type:'close'}` frame, or
|
|
2569
|
+
* plugin teardown kills it.
|
|
2570
|
+
*/
|
|
2571
|
+
function pumpAgentTerminal(registry, handle, ws) {
|
|
2572
|
+
if (handle.transcript !== "") ws.send(handle.transcript);
|
|
2573
|
+
const onData = (data) => {
|
|
2574
|
+
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4194304) ws.send(data);
|
|
2575
|
+
};
|
|
2576
|
+
const onExit = ({ exitCode }) => {
|
|
2577
|
+
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`);
|
|
2578
|
+
};
|
|
2579
|
+
const dataSub = handle.pty.onData(onData);
|
|
2580
|
+
const exitSub = handle.pty.onExit(onExit);
|
|
2581
|
+
ws.on("message", (data) => {
|
|
2582
|
+
if (handle.exited) return;
|
|
2583
|
+
const text = data.toString("utf8");
|
|
2584
|
+
let control = null;
|
|
2585
|
+
try {
|
|
2586
|
+
const parsed = JSON.parse(text);
|
|
2587
|
+
if (parsed !== null && typeof parsed === "object") control = parsed;
|
|
2588
|
+
} catch {}
|
|
2589
|
+
if (control !== null && control.type === "close") {
|
|
2590
|
+
registry.close(handle.uuid);
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
if (control !== null && control.type === "resize" && typeof control.cols === "number" && typeof control.rows === "number") {
|
|
2594
|
+
const dims = clampDims(control.cols, control.rows);
|
|
2595
|
+
handle.pty.resize(dims.cols, dims.rows);
|
|
2596
|
+
} else if (control === null) handle.pty.write(text);
|
|
2597
|
+
});
|
|
2598
|
+
ws.on("close", () => {
|
|
2599
|
+
dataSub.dispose();
|
|
2600
|
+
exitSub.dispose();
|
|
2601
|
+
});
|
|
2602
|
+
}
|
|
2603
|
+
//#endregion
|
|
2604
|
+
export { Config, apply, inject, mediaTypeForPath, name };
|