@keith9681/dsh-sidebar 0.15.2
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 +0 -0
- package/README_EN.md +0 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +34651 -0
- package/lib/client-mermaid.js +200629 -0
- package/lib/client-registry.js +13388 -0
- package/lib/client-terminal.js +8904 -0
- package/lib/client.js +13388 -0
- package/lib/index.js +3959 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -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 +12 -0
- package/lib/types/client/FileTree.d.ts +36 -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/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -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 +43 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +23 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +268 -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 +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.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/desktop-env.d.ts +35 -0
- package/lib/types/client/editable-state.d.ts +27 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +57 -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 +19 -0
- package/lib/types/client/intercept.d.ts +33 -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 +42 -0
- package/lib/types/client/locales.d.ts +366 -0
- package/lib/types/client/mermaid-blocks.d.ts +41 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +35 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +23 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +440 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +30 -0
- package/lib/types/client/state.d.ts +359 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +33 -0
- package/lib/types/client/terminal-font.d.ts +20 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +603 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +82 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/prefs-shared.d.ts +225 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +31 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +173 -0
- package/scripts/install.ps1 +289 -0
- package/scripts/install.sh +287 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +525 -0
- package/src/client/FileTree.tsx +653 -0
- package/src/client/GitView.tsx +558 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1152 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +644 -0
- package/src/client/Sidebar.tsx +1148 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +305 -0
- package/src/client/TerminalView.tsx +383 -0
- package/src/client/TextEditor.tsx +500 -0
- package/src/client/TreePanel.tsx +273 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +335 -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 +37 -0
- package/src/client/builtins/tabs.tsx +331 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +351 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.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/desktop-env.ts +72 -0
- package/src/client/editable-state.ts +70 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +369 -0
- package/src/client/intercept.tsx +110 -0
- package/src/client/lang.ts +103 -0
- package/src/client/layout.css +94 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales.ts +734 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +401 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +47 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +87 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +207 -0
- package/src/client/produced-files.ts +91 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +838 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2577 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +322 -0
- package/src/client/state.ts +1175 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +46 -0
- package/src/client/terminal-font.ts +28 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +155 -0
- package/src/context-types.ts +610 -0
- package/src/fs-operations.ts +99 -0
- package/src/fs-search.ts +86 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +244 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1132 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/prefs-shared.ts +268 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +479 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +100 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,3959 @@
|
|
|
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, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
5
|
+
import z from "schemastery";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { once } from "node:events";
|
|
8
|
+
import { chmodSync, createWriteStream, existsSync, readFileSync, realpathSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { SettingsConflictError, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
12
|
+
import { homedir, userInfo } from "node:os";
|
|
13
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
14
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
15
|
+
import { snapshotSubagentDescriptor } from "@deepseek-ai/dsh-subagent";
|
|
16
|
+
//#region src/prefs-shared.ts
|
|
17
|
+
/**
|
|
18
|
+
* Shared "Side card" preference vocabulary (types + constants), consumed by
|
|
19
|
+
* BOTH halves: the host registers the schemastery schema over these values
|
|
20
|
+
* (config.ts) and the client reads/writes them through the settings RPC
|
|
21
|
+
* (client/prefs.ts, client/SideCardSection.tsx). Kept free of schemastery so
|
|
22
|
+
* the browser bundle never pulls the schema runtime in.
|
|
23
|
+
*/
|
|
24
|
+
/** The user-settings namespace holding the side card preferences. */
|
|
25
|
+
const SIDEBAR_PREFS_NS = "dsh-better-sidebar";
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/config.ts
|
|
28
|
+
/**
|
|
29
|
+
* Serializable configuration and defaults for the sidebar host half. Loader
|
|
30
|
+
* schema validation normally fills defaults; {@link resolveSidebarConfig}
|
|
31
|
+
* applies the same defaults for direct callers that bypass the Loader.
|
|
32
|
+
* @module dsh-better-sidebar/config
|
|
33
|
+
*/
|
|
34
|
+
/** Schemastery schema for the plugin configuration. */
|
|
35
|
+
const Config = z.object({
|
|
36
|
+
readLimit: z.number().step(1).min(1).default(524288),
|
|
37
|
+
mediaLimit: z.number().step(1).min(1).default(20971520),
|
|
38
|
+
uploadLimit: z.number().step(1).min(1).default(134217728),
|
|
39
|
+
listLimit: z.number().step(1).min(1).default(1e3),
|
|
40
|
+
terminalsPerSession: z.number().step(1).min(1).default(3),
|
|
41
|
+
reconnectGraceMs: z.number().step(1).min(0).default(3e4),
|
|
42
|
+
shell: z.string().default(""),
|
|
43
|
+
shellArgs: z.array(z.string()).default([])
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* Apply direct-call defaults after Loader schema validation has normally run.
|
|
47
|
+
*
|
|
48
|
+
* @param config - Deployment-provided sidebar host settings.
|
|
49
|
+
* @returns Complete settings consumed by the host half.
|
|
50
|
+
*/
|
|
51
|
+
function resolveSidebarConfig(config) {
|
|
52
|
+
return {
|
|
53
|
+
readLimit: config?.readLimit ?? 524288,
|
|
54
|
+
mediaLimit: config?.mediaLimit ?? 20971520,
|
|
55
|
+
uploadLimit: config?.uploadLimit ?? 134217728,
|
|
56
|
+
listLimit: config?.listLimit ?? 1e3,
|
|
57
|
+
terminalsPerSession: config?.terminalsPerSession ?? 3,
|
|
58
|
+
reconnectGraceMs: config?.reconnectGraceMs ?? 3e4,
|
|
59
|
+
shell: config?.shell?.trim() ?? "",
|
|
60
|
+
shellArgs: config?.shellArgs ?? []
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Schemastery schema for the user-facing preferences (validated by the settings service). */
|
|
64
|
+
const PrefsSchema = z.object({
|
|
65
|
+
openByDefault: z.boolean().default(false),
|
|
66
|
+
defaultWidthPercent: z.number().step(1).min(20).max(60).default(35),
|
|
67
|
+
autoOpenSubagent: z.boolean().default(true),
|
|
68
|
+
autoOpenJobs: z.boolean().default(true),
|
|
69
|
+
agentTerminalTools: z.boolean().default(false),
|
|
70
|
+
bottomPanelAutoTerminal: z.boolean().default(false),
|
|
71
|
+
terminalFontFamily: z.string().default(""),
|
|
72
|
+
terminalFontSize: z.number().step(1).min(9).max(32).default(13),
|
|
73
|
+
interceptOpenPath: z.boolean().default(true),
|
|
74
|
+
editorExplorer: z.boolean().default(false),
|
|
75
|
+
terminalShell: z.string().default(""),
|
|
76
|
+
terminalShellArgs: z.string().default(""),
|
|
77
|
+
titleBarScheme: z.union([
|
|
78
|
+
z.const("auto"),
|
|
79
|
+
z.const("web"),
|
|
80
|
+
z.const("preset"),
|
|
81
|
+
z.const("custom")
|
|
82
|
+
]),
|
|
83
|
+
titleBarPresetId: z.string(),
|
|
84
|
+
customCss: z.string(),
|
|
85
|
+
titleBarCompat: z.boolean().default(false),
|
|
86
|
+
titleBarStripPx: z.number().step(1).min(0).max(120).default(40),
|
|
87
|
+
htmlViewerNoSandbox: z.boolean().default(false),
|
|
88
|
+
htmlViewerDefaultUnsafe: z.boolean().default(false),
|
|
89
|
+
browserNoSandbox: z.boolean().default(false),
|
|
90
|
+
browserInterceptLinks: z.boolean().default(true),
|
|
91
|
+
browserInterceptHttp: z.boolean().default(true),
|
|
92
|
+
browserInterceptHttps: z.boolean().default(false),
|
|
93
|
+
tabsEnabled: z.dict(z.boolean()).default({}),
|
|
94
|
+
viewersEnabled: z.dict(z.boolean()).default({}),
|
|
95
|
+
pluginSettings: z.dict(z.dict(z.any())).default({})
|
|
96
|
+
});
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/wire.ts
|
|
99
|
+
/** One API failure with its wire code and HTTP status. */
|
|
100
|
+
var SidebarError = class extends Error {
|
|
101
|
+
code;
|
|
102
|
+
status;
|
|
103
|
+
constructor(code, message, status = 400) {
|
|
104
|
+
super(message);
|
|
105
|
+
this.code = code;
|
|
106
|
+
this.status = status;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
/** Body size bound of one JSON request (defense against unbounded reads). */
|
|
110
|
+
const MAX_BODY_BYTES = 1 << 20;
|
|
111
|
+
/** Read and parse the JSON request body (bounded; malformed → bad-request). */
|
|
112
|
+
async function readJsonBody(req) {
|
|
113
|
+
const chunks = [];
|
|
114
|
+
let total = 0;
|
|
115
|
+
for await (const chunk of req) {
|
|
116
|
+
const buffer = Buffer.from(chunk);
|
|
117
|
+
total += buffer.length;
|
|
118
|
+
if (total > MAX_BODY_BYTES) throw new SidebarError("bad-request", "request body too large");
|
|
119
|
+
chunks.push(buffer);
|
|
120
|
+
}
|
|
121
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
122
|
+
if (text.trim() === "") return {};
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(text);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new SidebarError("bad-request", "request body is not valid JSON");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Write a JSON response with the given status. */
|
|
130
|
+
function writeJson(res, status, body) {
|
|
131
|
+
const payload = JSON.stringify(body);
|
|
132
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
133
|
+
res.end(payload);
|
|
134
|
+
}
|
|
135
|
+
/** Write the success envelope. */
|
|
136
|
+
function writeOk(res, value) {
|
|
137
|
+
writeJson(res, 200, {
|
|
138
|
+
ok: true,
|
|
139
|
+
value
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** Write the failure envelope for any thrown value (unknown → internal 500). */
|
|
143
|
+
function writeError(res, error) {
|
|
144
|
+
if (error instanceof SidebarError) {
|
|
145
|
+
writeJson(res, error.status, {
|
|
146
|
+
ok: false,
|
|
147
|
+
error: {
|
|
148
|
+
code: error.code,
|
|
149
|
+
message: error.message
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
writeJson(res, 500, {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: {
|
|
157
|
+
code: "internal",
|
|
158
|
+
message: error instanceof Error ? error.message : String(error)
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/** Narrow an unknown payload value to a string, else throw bad-request. */
|
|
163
|
+
function requireString(payload, key) {
|
|
164
|
+
const value = payload?.[key];
|
|
165
|
+
if (typeof value !== "string" || value === "") throw new SidebarError("bad-request", `missing or invalid "${key}"`);
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/fs-tree.ts
|
|
170
|
+
/**
|
|
171
|
+
* Single-level directory listing for the sidebar explorer. Streams the level
|
|
172
|
+
* with opendir, sorts directories first then names (case-insensitive), and
|
|
173
|
+
* marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
|
|
174
|
+
* stat'ed once to expose their target kind — a symlink to a directory
|
|
175
|
+
* expands like a directory — and dangling links are flagged broken. The
|
|
176
|
+
* probe runs only for entries that are actually symlinks, so levels without
|
|
177
|
+
* links stay as cheap as before.
|
|
178
|
+
*/
|
|
179
|
+
/** Directory-first, case-insensitive name ordering (VSCode explorer order). */
|
|
180
|
+
function compareEntries(a, b) {
|
|
181
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
182
|
+
return a.name.localeCompare(b.name, void 0, { sensitivity: "base" });
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* List one directory level.
|
|
186
|
+
* @param path - absolute directory path.
|
|
187
|
+
* @param maxEntries - row bound of one level (extra rows flag `truncated`).
|
|
188
|
+
* @returns the sorted listing.
|
|
189
|
+
* @throws {SidebarError} fs-error when the level is unreadable or not a directory.
|
|
190
|
+
*/
|
|
191
|
+
async function listDirectory(path, maxEntries = 1e3) {
|
|
192
|
+
let level;
|
|
193
|
+
try {
|
|
194
|
+
level = await opendir(path);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
throw new SidebarError("fs-error", `cannot list "${path}": ${messageOf(error)}`, 400);
|
|
197
|
+
}
|
|
198
|
+
const rows = [];
|
|
199
|
+
let overflow = 0;
|
|
200
|
+
try {
|
|
201
|
+
for await (const dirent of level) {
|
|
202
|
+
if (rows.length >= maxEntries) {
|
|
203
|
+
overflow += 1;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
rows.push({
|
|
207
|
+
name: dirent.name,
|
|
208
|
+
path: join(path, dirent.name),
|
|
209
|
+
isDir: dirent.isDirectory(),
|
|
210
|
+
isSymlink: dirent.isSymbolicLink(),
|
|
211
|
+
broken: false,
|
|
212
|
+
hidden: dirent.name.startsWith(".")
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
throw new SidebarError("fs-error", `cannot list "${path}": ${messageOf(error)}`, 400);
|
|
217
|
+
}
|
|
218
|
+
await probeSymlinkTargets(rows);
|
|
219
|
+
rows.sort(compareEntries);
|
|
220
|
+
return {
|
|
221
|
+
path,
|
|
222
|
+
entries: rows,
|
|
223
|
+
truncated: overflow > 0
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/** How many symlink target stats run in flight during one level listing. */
|
|
227
|
+
const SYMLINK_PROBE_CONCURRENCY = 32;
|
|
228
|
+
/** Probe each symlink row's target once (bounded concurrency, order-preserving). */
|
|
229
|
+
async function probeSymlinkTargets(rows, concurrency = SYMLINK_PROBE_CONCURRENCY) {
|
|
230
|
+
let next = 0;
|
|
231
|
+
const workers = Array.from({ length: Math.min(concurrency, rows.length) }, async () => {
|
|
232
|
+
for (;;) {
|
|
233
|
+
const index = next;
|
|
234
|
+
next += 1;
|
|
235
|
+
if (index >= rows.length) return;
|
|
236
|
+
const row = rows[index];
|
|
237
|
+
if (!row.isSymlink) continue;
|
|
238
|
+
const info = await stat(row.path).catch(() => void 0);
|
|
239
|
+
row.isDir = info !== void 0 ? info.isDirectory() : row.isDir;
|
|
240
|
+
row.broken = info === void 0;
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
await Promise.all(workers);
|
|
244
|
+
}
|
|
245
|
+
/** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
|
|
246
|
+
function rootLabel(path) {
|
|
247
|
+
const base = basename(path);
|
|
248
|
+
return base !== "" ? base : path;
|
|
249
|
+
}
|
|
250
|
+
/** Parent of a path, or undefined at the filesystem root (the explorer's "up" target). */
|
|
251
|
+
function parentOf(path) {
|
|
252
|
+
const parent = dirname(path);
|
|
253
|
+
return parent === path ? void 0 : parent;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Normalize a caller-supplied path to an absolute, resolved path or throw
|
|
257
|
+
* fs-error. `path.isAbsolute()` is the OS's own notion of absolute: POSIX
|
|
258
|
+
* roots (`/...`), Windows drive letters (`C:\...`) and — on win32 — UNC
|
|
259
|
+
* network shares (`\\server\share\...`); drive-relative forms (`C:foo`)
|
|
260
|
+
* stay rejected.
|
|
261
|
+
*/
|
|
262
|
+
function requireAbsolute(path) {
|
|
263
|
+
if (!isAbsolute(path)) throw new SidebarError("fs-error", `"${path}" is not an absolute path`, 400);
|
|
264
|
+
return resolve(path);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Whether `target` lies under `base` (or equals it), tolerant of separator
|
|
268
|
+
* style and — on Windows, where the filesystem is case-insensitive — of
|
|
269
|
+
* letter case. The media route uses this instead of a raw `startsWith` so a
|
|
270
|
+
* case-mismatched or mixed-separator path can never be misclassified
|
|
271
|
+
* (e.g. `C:\Users\Me` vs `c:/users/me/file.png`).
|
|
272
|
+
* @param platform - filesystem semantics; injectable so both branches are
|
|
273
|
+
* unit-testable on any host.
|
|
274
|
+
*/
|
|
275
|
+
function isWithin(base, target, platform = process.platform) {
|
|
276
|
+
const norm = (value) => value.replace(/[\\/]+/g, "/").replace(/\/$/, "");
|
|
277
|
+
const b = norm(base);
|
|
278
|
+
const t = norm(target);
|
|
279
|
+
if (platform === "win32") {
|
|
280
|
+
const lb = b.toLowerCase();
|
|
281
|
+
const lt = t.toLowerCase();
|
|
282
|
+
return lt === lb || lt.startsWith(`${lb}/`);
|
|
283
|
+
}
|
|
284
|
+
return t === b || t.startsWith(`${b}/`);
|
|
285
|
+
}
|
|
286
|
+
/** Message text of an unknown thrown value. */
|
|
287
|
+
function messageOf(error) {
|
|
288
|
+
return error instanceof Error ? error.message : String(error);
|
|
289
|
+
}
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/fs-operations.ts
|
|
292
|
+
/**
|
|
293
|
+
* Workspace-safe file mutations for the sidebar (the upload route today).
|
|
294
|
+
*
|
|
295
|
+
* Every write is lexically confined to the session workspace: the upload
|
|
296
|
+
* directory is resolved absolute and must sit inside the session cwd, the
|
|
297
|
+
* relative path is sanitized (absolute paths, '.', '..' and empty segments
|
|
298
|
+
* are refused), and the final target must stay inside both. Containment is
|
|
299
|
+
* lexical (no symlink resolution) — a symlinked directory inside the cwd can
|
|
300
|
+
* redirect writes outside, matching the trust model of the other /sidebar/*
|
|
301
|
+
* routes. Bytes stream from the request body to a uniquely named temp sibling
|
|
302
|
+
* and are renamed into place, so a failed, aborted, or oversized upload never
|
|
303
|
+
* leaves a partial file at the target path.
|
|
304
|
+
*/
|
|
305
|
+
/**
|
|
306
|
+
* Stream `chunks` into `dir/relativePath` atomically: a uniquely named temp
|
|
307
|
+
* sibling receives the bytes, then is renamed over the target. The parent
|
|
308
|
+
* directory is created on demand (recursive), so folder uploads work before
|
|
309
|
+
* any level exists. The unique temp name keeps concurrent uploads to the same
|
|
310
|
+
* target independent (each writes and renames its own file; the last rename
|
|
311
|
+
* wins) and never blocks later uploads after a crashed process.
|
|
312
|
+
*
|
|
313
|
+
* @throws SidebarError with a wire code for containment, shape, and size
|
|
314
|
+
* failures; the temp file is always removed on failure.
|
|
315
|
+
*/
|
|
316
|
+
async function writeWorkspaceUpload(input) {
|
|
317
|
+
const { cwd, dir, relativePath, chunks, limit } = input;
|
|
318
|
+
const base = requireAbsolute(dir);
|
|
319
|
+
if (!isWithin(cwd, base)) throw new SidebarError("forbidden", "upload directory escapes the session workspace", 403);
|
|
320
|
+
if (relativePath === "" || relativePath.startsWith("/") || relativePath.startsWith("\\")) throw new SidebarError("bad-request", "relativePath must stay below the upload directory", 400);
|
|
321
|
+
const segments = relativePath.split(/[\\/]/);
|
|
322
|
+
if (segments.some((part) => part === "" || part === "." || part === "..")) throw new SidebarError("bad-request", "relativePath must stay below the upload directory", 400);
|
|
323
|
+
const target = join(base, ...segments);
|
|
324
|
+
if (!isWithin(cwd, target) || !isWithin(base, target)) throw new SidebarError("forbidden", "target escapes the session workspace", 403);
|
|
325
|
+
const tmp = join(dirname(target), `.${basename(target)}.dsh-upload-${randomUUID()}.tmp`);
|
|
326
|
+
await mkdir(dirname(target), { recursive: true });
|
|
327
|
+
const stream = createWriteStream(tmp, { flags: "wx" });
|
|
328
|
+
const closed = new Promise((resolve) => {
|
|
329
|
+
stream.once("close", () => resolve());
|
|
330
|
+
});
|
|
331
|
+
let size = 0;
|
|
332
|
+
let streamError;
|
|
333
|
+
stream.on("error", (error) => {
|
|
334
|
+
streamError = error;
|
|
335
|
+
});
|
|
336
|
+
try {
|
|
337
|
+
for await (const chunk of chunks) {
|
|
338
|
+
const buffer = Buffer.from(chunk);
|
|
339
|
+
size += buffer.length;
|
|
340
|
+
if (size > limit) throw new SidebarError("too-large", `upload exceeds the ${limit} byte limit`, 413);
|
|
341
|
+
if (!stream.write(buffer)) await once(stream, "drain");
|
|
342
|
+
if (streamError !== void 0) throw streamError;
|
|
343
|
+
}
|
|
344
|
+
await new Promise((resolve, reject) => {
|
|
345
|
+
stream.end((error) => error === void 0 || error === null ? resolve() : reject(error));
|
|
346
|
+
});
|
|
347
|
+
if (streamError !== void 0) throw streamError;
|
|
348
|
+
await rename(tmp, target);
|
|
349
|
+
return {
|
|
350
|
+
path: target,
|
|
351
|
+
size: (await stat(target)).size
|
|
352
|
+
};
|
|
353
|
+
} catch (error) {
|
|
354
|
+
stream.destroy();
|
|
355
|
+
await closed.catch(() => {});
|
|
356
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/fs-search.ts
|
|
362
|
+
/**
|
|
363
|
+
* Recursive file-name search for the editor's merged-mode side panel.
|
|
364
|
+
* Streams the tree with opendir and matches the query as a case-insensitive
|
|
365
|
+
* substring of each entry's NAME (paths stay relative to the search root —
|
|
366
|
+
* the client resolves them against the session cwd). No .gitignore semantics
|
|
367
|
+
* (this is a name lookup, not a code search), but `.git` directories are
|
|
368
|
+
* skipped outright (VCS internals are never useful results) and symlink
|
|
369
|
+
* directories are NOT descended (cycle safety).
|
|
370
|
+
*
|
|
371
|
+
* Two performance budgets bound the walk: `maxMatches` (the client renders
|
|
372
|
+
* the flat list) and `maxVisited` (a runaway tree — a home directory root,
|
|
373
|
+
* a node_modules forest — must not stall the host). Exceeding either stops
|
|
374
|
+
* early with `truncated: true`.
|
|
375
|
+
*/
|
|
376
|
+
const DEFAULT_MAX_MATCHES = 200;
|
|
377
|
+
const DEFAULT_MAX_VISITED = 1e5;
|
|
378
|
+
/**
|
|
379
|
+
* Search `root` recursively for entries whose name contains `query`
|
|
380
|
+
* (case-insensitive).
|
|
381
|
+
* @param root - absolute search root.
|
|
382
|
+
* @param query - the name substring; empty matches nothing.
|
|
383
|
+
* @param opts - budget overrides (tests).
|
|
384
|
+
* @returns the matching paths RELATIVE to `root` ('/'-separated), sorted,
|
|
385
|
+
* plus whether a budget cut the walk short. An unreadable level is skipped
|
|
386
|
+
* (permission errors never fail the whole search).
|
|
387
|
+
*/
|
|
388
|
+
async function searchFiles(root, query, opts = {}) {
|
|
389
|
+
const needle = query.trim().toLowerCase();
|
|
390
|
+
if (needle === "") return {
|
|
391
|
+
matches: [],
|
|
392
|
+
truncated: false
|
|
393
|
+
};
|
|
394
|
+
const maxMatches = opts.maxMatches ?? DEFAULT_MAX_MATCHES;
|
|
395
|
+
const maxVisited = opts.maxVisited ?? DEFAULT_MAX_VISITED;
|
|
396
|
+
const matches = [];
|
|
397
|
+
let visited = 0;
|
|
398
|
+
let truncated = false;
|
|
399
|
+
const walk = async (dir) => {
|
|
400
|
+
if (truncated) return;
|
|
401
|
+
const level = await opendir(dir).catch(() => void 0);
|
|
402
|
+
if (level === void 0) return;
|
|
403
|
+
for await (const dirent of level) {
|
|
404
|
+
visited += 1;
|
|
405
|
+
if (visited > maxVisited) {
|
|
406
|
+
truncated = true;
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (dirent.isDirectory() && dirent.name === ".git") continue;
|
|
410
|
+
if (dirent.name.toLowerCase().includes(needle)) {
|
|
411
|
+
matches.push(join(relative(root, dir), dirent.name));
|
|
412
|
+
if (matches.length >= maxMatches) {
|
|
413
|
+
truncated = true;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (dirent.isDirectory() && !dirent.isSymbolicLink()) {
|
|
418
|
+
await walk(join(dir, dirent.name));
|
|
419
|
+
if (truncated) return;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
await walk(root);
|
|
424
|
+
return {
|
|
425
|
+
matches: matches.sort().map((path) => path.split(sep).join("/")),
|
|
426
|
+
truncated
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Decode a route pathname into the session + absolute file path. Rejects
|
|
431
|
+
* a wrong prefix (404), an empty path, malformed percent encoding, and a
|
|
432
|
+
* missing sessionId or file path (400). The caller still must bound the
|
|
433
|
+
* decoded path with requireAbsolute + isWithin(cwd) — a decoded `..`
|
|
434
|
+
* segment resolves outside the cwd and is refused there.
|
|
435
|
+
*/
|
|
436
|
+
function decodeHtmlUrl(pathname) {
|
|
437
|
+
if (!pathname.startsWith("/sidebar/html/")) return {
|
|
438
|
+
ok: false,
|
|
439
|
+
status: 404,
|
|
440
|
+
message: "not an html route"
|
|
441
|
+
};
|
|
442
|
+
const rest = pathname.slice(14);
|
|
443
|
+
if (rest === "") return {
|
|
444
|
+
ok: false,
|
|
445
|
+
status: 400,
|
|
446
|
+
message: "invalid html route path"
|
|
447
|
+
};
|
|
448
|
+
let segments;
|
|
449
|
+
try {
|
|
450
|
+
segments = rest.split("/").map((segment) => decodeURIComponent(segment));
|
|
451
|
+
} catch {
|
|
452
|
+
return {
|
|
453
|
+
ok: false,
|
|
454
|
+
status: 400,
|
|
455
|
+
message: "malformed URL encoding"
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const [sessionId, ...pathSegments] = segments;
|
|
459
|
+
if (sessionId === void 0 || sessionId === "") return {
|
|
460
|
+
ok: false,
|
|
461
|
+
status: 400,
|
|
462
|
+
message: "sessionId and file path are required"
|
|
463
|
+
};
|
|
464
|
+
const unc = pathSegments[0] === "";
|
|
465
|
+
const tail = unc ? pathSegments.slice(1) : pathSegments;
|
|
466
|
+
if (tail.length === 0 || tail.some((segment) => segment === "")) return {
|
|
467
|
+
ok: false,
|
|
468
|
+
status: 400,
|
|
469
|
+
message: "sessionId and file path are required"
|
|
470
|
+
};
|
|
471
|
+
let path;
|
|
472
|
+
if (unc) path = `//${tail.join("/")}`;
|
|
473
|
+
else if (/^[A-Za-z]:$/.test(tail[0] ?? "")) path = tail.join("/");
|
|
474
|
+
else path = `/${tail.join("/")}`;
|
|
475
|
+
return {
|
|
476
|
+
ok: true,
|
|
477
|
+
ref: {
|
|
478
|
+
sessionId,
|
|
479
|
+
path
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
//#endregion
|
|
484
|
+
//#region src/browser-probe.ts
|
|
485
|
+
/**
|
|
486
|
+
* Pure helpers for the `browser.probe` route (sidebar browser): the host
|
|
487
|
+
* fetches the response HEADERS of a URL the user is browsing and the client
|
|
488
|
+
* decides whether the target site forbids being embedded (X-Frame-Options /
|
|
489
|
+
* CSP frame-ancestors are exactly the signals the browser enforces when it
|
|
490
|
+
* refuses an iframe load). Kept dependency-free so the parser is
|
|
491
|
+
* unit-testable.
|
|
492
|
+
*/
|
|
493
|
+
/**
|
|
494
|
+
* Extract the `frame-ancestors` source list of a Content-Security-Policy
|
|
495
|
+
* header, or undefined when the directive is absent (or empty). The
|
|
496
|
+
* directive is the only one with a source list; sources are space-separated
|
|
497
|
+
* tokens (`'none'`, `'self'`, `*`, or origins).
|
|
498
|
+
*/
|
|
499
|
+
function extractFrameAncestors(csp) {
|
|
500
|
+
if (csp === null) return void 0;
|
|
501
|
+
for (const directive of csp.split(";")) {
|
|
502
|
+
const parts = directive.trim().split(/\s+/);
|
|
503
|
+
if (parts[0] === "frame-ancestors") {
|
|
504
|
+
const sources = parts.slice(1).filter((source) => source !== "");
|
|
505
|
+
return sources.length === 0 ? void 0 : sources;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/trust-fence.ts
|
|
511
|
+
function header(headers, name) {
|
|
512
|
+
const value = headers[name];
|
|
513
|
+
return typeof value === "string" ? value : void 0;
|
|
514
|
+
}
|
|
515
|
+
/** Normalized URL of a Host-header authority, or undefined when unparsable. */
|
|
516
|
+
function parseAuthority(authority) {
|
|
517
|
+
try {
|
|
518
|
+
return new URL(`http://${authority}`);
|
|
519
|
+
} catch {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/** Whether a normalized URL hostname names the local loopback authority. */
|
|
524
|
+
function isLoopbackHostname(hostname) {
|
|
525
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
526
|
+
const parts = hostname.split(".");
|
|
527
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
528
|
+
}
|
|
529
|
+
/** Canonical authority form: hostname, or hostname:port when a port was written. */
|
|
530
|
+
function canonicalAuthority(entry, entryUrl) {
|
|
531
|
+
const port = entryUrl.port !== "" ? entryUrl.port : new URL(`https://${entry}`).port;
|
|
532
|
+
return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
|
|
533
|
+
}
|
|
534
|
+
/** Whether the request authority matches a trustedHosts entry (exact or port-less). */
|
|
535
|
+
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
536
|
+
return trustedHosts.some((entry) => {
|
|
537
|
+
const entryUrl = parseAuthority(entry);
|
|
538
|
+
if (entryUrl === void 0) return false;
|
|
539
|
+
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host;
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Decide whether one sidebar request may reach the plugin routes.
|
|
544
|
+
* @param request - node HTTP request facts (headers).
|
|
545
|
+
* @param trustedHosts - non-loopback authorities this deployment serves.
|
|
546
|
+
* @returns true when the Host is ours (loopback or trusted) and browser markers are same-origin.
|
|
547
|
+
*/
|
|
548
|
+
function isTrustedApiRequest(request, trustedHosts) {
|
|
549
|
+
const host = header(request.headers, "host");
|
|
550
|
+
if (host === void 0) return false;
|
|
551
|
+
const hostUrl = parseAuthority(host);
|
|
552
|
+
if (hostUrl === void 0) return false;
|
|
553
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
|
|
554
|
+
if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
|
|
555
|
+
const origin = header(request.headers, "origin");
|
|
556
|
+
if (origin === void 0) return true;
|
|
557
|
+
try {
|
|
558
|
+
return new URL(origin).host === hostUrl.host;
|
|
559
|
+
} catch {
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
//#endregion
|
|
564
|
+
//#region src/bundle-route.ts
|
|
565
|
+
/**
|
|
566
|
+
* Lazy chunk route: serves the client bundle's chunk scripts
|
|
567
|
+
* (/sidebar/bundle/<name>.js). The official /plugins/<id>/client.js route
|
|
568
|
+
* cannot serve arbitrary file names, so the plugin serves its own split
|
|
569
|
+
* bundles (lib/client-<name>.js) here; the client injects the script on
|
|
570
|
+
* first use of the feature that needs it (see src/client/chunk-loader.ts).
|
|
571
|
+
*
|
|
572
|
+
* Caching contract: every response carries `cache-control: no-cache` plus an
|
|
573
|
+
* ETag (content hash, memoized per file by mtime/size) and honors
|
|
574
|
+
* If-None-Match — the browser revalidates each fetch, but a 304 avoids
|
|
575
|
+
* re-downloading multi-MB chunks that did not change (page refresh, HMR
|
|
576
|
+
* re-activation). Same browser-trust fence as every other /sidebar route;
|
|
577
|
+
* only allowlisted chunk names are servable (no path traversal).
|
|
578
|
+
*/
|
|
579
|
+
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
|
|
580
|
+
const CHUNK_NAMES = [
|
|
581
|
+
"terminal",
|
|
582
|
+
"editor",
|
|
583
|
+
"mermaid"
|
|
584
|
+
];
|
|
585
|
+
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
|
|
586
|
+
const LIB_DIR = dirname(fileURLToPath(import.meta.url));
|
|
587
|
+
/** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
|
|
588
|
+
function shortHash(input) {
|
|
589
|
+
return createHash("sha1").update(input).digest("hex").slice(0, 12);
|
|
590
|
+
}
|
|
591
|
+
/** ETag memo: recompute the content hash only when the file's stat changed. */
|
|
592
|
+
const etags = /* @__PURE__ */ new Map();
|
|
593
|
+
/**
|
|
594
|
+
* The chunk file's ETag (quoted hash), or undefined when the file is
|
|
595
|
+
* missing. Hash is recomputed only when mtime/size changed (hashing a
|
|
596
|
+
* multi-MB chunk per request is wasteful).
|
|
597
|
+
*/
|
|
598
|
+
async function etagOf(name, chunkDir) {
|
|
599
|
+
const path = join(chunkDir, `client-${name}.js`);
|
|
600
|
+
const key = `${chunkDir}:${name}`;
|
|
601
|
+
try {
|
|
602
|
+
const info = await stat(path);
|
|
603
|
+
const memo = etags.get(key);
|
|
604
|
+
if (memo !== void 0 && memo.mtimeMs === info.mtimeMs && memo.size === info.size) return memo.etag;
|
|
605
|
+
const etag = `"${shortHash(await readFile(path))}"`;
|
|
606
|
+
etags.set(key, {
|
|
607
|
+
mtimeMs: info.mtimeMs,
|
|
608
|
+
size: info.size,
|
|
609
|
+
etag
|
|
610
|
+
});
|
|
611
|
+
return etag;
|
|
612
|
+
} catch {
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Build the /sidebar/bundle route handler. `fence` is the shared browser-
|
|
618
|
+
* trust check every /sidebar route applies; `chunkDir` is the directory the
|
|
619
|
+
* chunk scripts live in (overridable for tests).
|
|
620
|
+
*/
|
|
621
|
+
function createBundleRouteHandler(fence, chunkDir = LIB_DIR) {
|
|
622
|
+
return async (req, res) => {
|
|
623
|
+
if (!fence(req)) {
|
|
624
|
+
res.writeHead(403);
|
|
625
|
+
res.end("forbidden");
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
629
|
+
res.writeHead(405);
|
|
630
|
+
res.end();
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
|
|
634
|
+
const name = /^\/sidebar\/bundle\/([a-z0-9-]+)\.js$/.exec(pathname)?.[1];
|
|
635
|
+
if (name === void 0 || !CHUNK_NAMES.includes(name)) {
|
|
636
|
+
res.writeHead(404);
|
|
637
|
+
res.end("not found");
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const etag = await etagOf(name, chunkDir);
|
|
641
|
+
if (etag === void 0) {
|
|
642
|
+
res.writeHead(404);
|
|
643
|
+
res.end("not found");
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (req.headers["if-none-match"] === etag) {
|
|
647
|
+
res.writeHead(304, {
|
|
648
|
+
"cache-control": "no-cache",
|
|
649
|
+
etag
|
|
650
|
+
});
|
|
651
|
+
res.end();
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
try {
|
|
655
|
+
const body = await readFile(join(chunkDir, `client-${name}.js`));
|
|
656
|
+
res.writeHead(200, {
|
|
657
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
658
|
+
"cache-control": "no-cache",
|
|
659
|
+
etag
|
|
660
|
+
});
|
|
661
|
+
res.end(body);
|
|
662
|
+
} catch {
|
|
663
|
+
res.writeHead(404);
|
|
664
|
+
res.end("not found");
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
/** Register the /sidebar/bundle route (disposed with the fiber). */
|
|
669
|
+
function registerBundleRoute(ctx, fence) {
|
|
670
|
+
return ctx.webServer.register({
|
|
671
|
+
kind: "prefix",
|
|
672
|
+
path: "/sidebar/bundle",
|
|
673
|
+
handler: createBundleRouteHandler(fence)
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/open-external.ts
|
|
678
|
+
/**
|
|
679
|
+
* External open actions for the file tree's "open with" menu: hand a path to
|
|
680
|
+
* the OS file manager (reveal/select) or launch a URL scheme's registered
|
|
681
|
+
* handler (vscode://, cursor://, zed://, custom schemes).
|
|
682
|
+
*
|
|
683
|
+
* The client runs in a browser / DSH Desktop renderer where a raw `vscode://`
|
|
684
|
+
* navigation is unreliable, so both actions fan out through this host route
|
|
685
|
+
* and spawn the platform opener with an argv array (no shell interpolation).
|
|
686
|
+
* The command builders are pure — the platform is injectable — so every
|
|
687
|
+
* per-platform branch is unit-testable without spawning anything.
|
|
688
|
+
*/
|
|
689
|
+
/** Reveal/select a path in the OS file manager. On Linux there is no common
|
|
690
|
+
* select protocol — the containing directory is opened instead (KISS). */
|
|
691
|
+
function revealCommand(path, platform = process.platform) {
|
|
692
|
+
switch (platform) {
|
|
693
|
+
case "darwin": return {
|
|
694
|
+
command: "open",
|
|
695
|
+
args: ["-R", path]
|
|
696
|
+
};
|
|
697
|
+
case "win32": return {
|
|
698
|
+
command: "explorer.exe",
|
|
699
|
+
args: ["/select,", path]
|
|
700
|
+
};
|
|
701
|
+
default: return {
|
|
702
|
+
command: "xdg-open",
|
|
703
|
+
args: [parentOf(path) ?? path]
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/** Hand a custom-scheme URL to the OS protocol handler. */
|
|
708
|
+
function urlCommand(url, platform = process.platform) {
|
|
709
|
+
switch (platform) {
|
|
710
|
+
case "darwin": return {
|
|
711
|
+
command: "open",
|
|
712
|
+
args: [url]
|
|
713
|
+
};
|
|
714
|
+
case "win32": return {
|
|
715
|
+
command: "rundll32.exe",
|
|
716
|
+
args: ["url.dll,FileProtocolHandler", url]
|
|
717
|
+
};
|
|
718
|
+
default: return {
|
|
719
|
+
command: "xdg-open",
|
|
720
|
+
args: [url]
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
/** Validate a URL-scheme open target: a parseable custom-scheme URL (never
|
|
725
|
+
* http/https — those would only dump the URL into a browser tab). */
|
|
726
|
+
function validateExternalUrl(raw) {
|
|
727
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) throw new SidebarError("bad-request", "url must be a custom-scheme URL");
|
|
728
|
+
let url;
|
|
729
|
+
try {
|
|
730
|
+
url = new URL(raw);
|
|
731
|
+
} catch {
|
|
732
|
+
throw new SidebarError("bad-request", "invalid url");
|
|
733
|
+
}
|
|
734
|
+
if (url.protocol === "http:" || url.protocol === "https:") throw new SidebarError("bad-request", "only custom-scheme urls can be opened externally");
|
|
735
|
+
return raw;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Launch one external open action and return immediately (detached, no
|
|
739
|
+
* stdio). Spawn failures are reported through the child's 'error' event —
|
|
740
|
+
* by then the route already returned, so the event is swallowed (the OS
|
|
741
|
+
* dialog about a missing handler is the user-visible outcome either way).
|
|
742
|
+
*/
|
|
743
|
+
function launchExternal(action, value) {
|
|
744
|
+
const platform = process.platform;
|
|
745
|
+
const spec = action === "reveal" ? revealCommand(requireAbsolute(value), platform) : urlCommand(validateExternalUrl(value), platform);
|
|
746
|
+
const child = spawn(spec.command, spec.args, {
|
|
747
|
+
detached: true,
|
|
748
|
+
stdio: "ignore"
|
|
749
|
+
});
|
|
750
|
+
child.on("error", () => {});
|
|
751
|
+
child.unref();
|
|
752
|
+
return { started: true };
|
|
753
|
+
}
|
|
754
|
+
//#endregion
|
|
755
|
+
//#region src/git.ts
|
|
756
|
+
/**
|
|
757
|
+
* Git operations for the sidebar source-control panel. Everything goes
|
|
758
|
+
* through the system `git` binary spawned per request (no library, no state),
|
|
759
|
+
* with porcelain-parseable output formats (`-z` NUL framing, unit separators)
|
|
760
|
+
* so parsing never depends on locale or color config. All commands run with
|
|
761
|
+
* `-C <cwd>` on the session's working directory and `--no-pager` /
|
|
762
|
+
* `-c color.ui=false` so output stays machine-readable.
|
|
763
|
+
*
|
|
764
|
+
* Commits use the user's git global identity untouched (never sets
|
|
765
|
+
* user.name/user.email).
|
|
766
|
+
*/
|
|
767
|
+
/** One git failure (stderr text as the message). */
|
|
768
|
+
var GitCommandError = class extends Error {
|
|
769
|
+
code;
|
|
770
|
+
command;
|
|
771
|
+
constructor(message, code = "git-error", command) {
|
|
772
|
+
super(message);
|
|
773
|
+
this.code = code;
|
|
774
|
+
this.command = command;
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
/** Parse porcelain v1 -z output into entries (rename/copy pairs collapse to one row). */
|
|
778
|
+
function parsePorcelainZ(output) {
|
|
779
|
+
const tokens = output.split("\0");
|
|
780
|
+
const entries = [];
|
|
781
|
+
let index = 0;
|
|
782
|
+
while (index < tokens.length) {
|
|
783
|
+
const token = tokens[index];
|
|
784
|
+
index += 1;
|
|
785
|
+
if (token === "") continue;
|
|
786
|
+
const xy = token.slice(0, 2);
|
|
787
|
+
const rest = token.slice(3);
|
|
788
|
+
entries.push({
|
|
789
|
+
path: rest,
|
|
790
|
+
xy
|
|
791
|
+
});
|
|
792
|
+
if ((xy[0] === "R" || xy[0] === "C") && tokens[index] !== void 0 && tokens[index] !== "") index += 1;
|
|
793
|
+
}
|
|
794
|
+
return entries;
|
|
795
|
+
}
|
|
796
|
+
/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
|
|
797
|
+
function parseLogLines(output) {
|
|
798
|
+
const rows = [];
|
|
799
|
+
for (const line of output.split("\n")) {
|
|
800
|
+
if (line === "") continue;
|
|
801
|
+
const [hash, subject, author, date, hashFull, refs] = line.split("");
|
|
802
|
+
if (hash === void 0 || subject === void 0) continue;
|
|
803
|
+
rows.push({
|
|
804
|
+
hash,
|
|
805
|
+
subject,
|
|
806
|
+
author: author ?? "",
|
|
807
|
+
date: date ?? "",
|
|
808
|
+
hashFull: hashFull ?? hash,
|
|
809
|
+
refs: refs ?? ""
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
return rows;
|
|
813
|
+
}
|
|
814
|
+
/** Run one git command; resolves with stdout, rejects with GitCommandError. */
|
|
815
|
+
function runGit(cwd, args, timeoutMs = 3e4) {
|
|
816
|
+
const full = [
|
|
817
|
+
"-C",
|
|
818
|
+
cwd,
|
|
819
|
+
"--no-pager",
|
|
820
|
+
"-c",
|
|
821
|
+
"color.ui=false",
|
|
822
|
+
...args
|
|
823
|
+
];
|
|
824
|
+
return new Promise((resolvePromise, reject) => {
|
|
825
|
+
const child = spawn("git", full, {
|
|
826
|
+
stdio: [
|
|
827
|
+
"ignore",
|
|
828
|
+
"pipe",
|
|
829
|
+
"pipe"
|
|
830
|
+
],
|
|
831
|
+
windowsHide: true,
|
|
832
|
+
env: {
|
|
833
|
+
...process.env,
|
|
834
|
+
GIT_OPTIONAL_LOCKS: "0"
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
let stdout = "";
|
|
838
|
+
let stderr = "";
|
|
839
|
+
const timer = setTimeout(() => {
|
|
840
|
+
child.kill("SIGKILL");
|
|
841
|
+
reject(new GitCommandError(`git ${args[0] ?? ""} timed out after ${timeoutMs}ms`, "git-error", args.join(" ")));
|
|
842
|
+
}, timeoutMs);
|
|
843
|
+
child.stdout.on("data", (chunk) => {
|
|
844
|
+
stdout += chunk.toString("utf8");
|
|
845
|
+
});
|
|
846
|
+
child.stderr.on("data", (chunk) => {
|
|
847
|
+
stderr += chunk.toString("utf8");
|
|
848
|
+
});
|
|
849
|
+
child.on("error", (error) => {
|
|
850
|
+
clearTimeout(timer);
|
|
851
|
+
reject(new GitCommandError(`cannot run git: ${error.message}`, "git-error", args.join(" ")));
|
|
852
|
+
});
|
|
853
|
+
child.on("close", (code) => {
|
|
854
|
+
clearTimeout(timer);
|
|
855
|
+
if (code === 0) resolvePromise(stdout);
|
|
856
|
+
else reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, "git-error", args.join(" ")));
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
/** Whether the directory is inside a git work tree (exit-0 `git rev-parse`). */
|
|
861
|
+
async function isGitRepo(cwd) {
|
|
862
|
+
try {
|
|
863
|
+
return (await runGit(cwd, ["rev-parse", "--is-inside-work-tree"])).trim() === "true";
|
|
864
|
+
} catch {
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
|
|
869
|
+
async function repoRoot(cwd) {
|
|
870
|
+
return (await runGit(cwd, ["rev-parse", "--show-toplevel"])).trim();
|
|
871
|
+
}
|
|
872
|
+
/** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
|
|
873
|
+
async function currentBranch(cwd) {
|
|
874
|
+
return (await runGit(cwd, [
|
|
875
|
+
"rev-parse",
|
|
876
|
+
"--abbrev-ref",
|
|
877
|
+
"HEAD"
|
|
878
|
+
])).trim();
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Working-tree status (untracked included). `--untracked-files=all` lists
|
|
882
|
+
* the CONTENTS of new directories as individual entries (`?? newdir/a.ts`
|
|
883
|
+
* rather than a collapsed `?? newdir/`), so every row in the source-control
|
|
884
|
+
* panel is a real file whose diff tab can load. With `=normal`, git folds a
|
|
885
|
+
* new folder into one trailing-slash entry that has no diff output and
|
|
886
|
+
* cannot be read as a file.
|
|
887
|
+
*/
|
|
888
|
+
async function status(cwd) {
|
|
889
|
+
if (!await isGitRepo(cwd)) return {
|
|
890
|
+
isRepo: false,
|
|
891
|
+
entries: []
|
|
892
|
+
};
|
|
893
|
+
const [branch, raw] = await Promise.all([currentBranch(cwd).catch(() => "HEAD"), runGit(cwd, [
|
|
894
|
+
"status",
|
|
895
|
+
"--porcelain=v1",
|
|
896
|
+
"-z",
|
|
897
|
+
"--untracked-files=all"
|
|
898
|
+
])]);
|
|
899
|
+
return {
|
|
900
|
+
isRepo: true,
|
|
901
|
+
branch,
|
|
902
|
+
entries: parsePorcelainZ(raw)
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
/** Diff text of the worktree (unstaged) or the index (staged). */
|
|
906
|
+
async function diff(cwd, path, staged) {
|
|
907
|
+
const args = [
|
|
908
|
+
"diff",
|
|
909
|
+
"--no-ext-diff",
|
|
910
|
+
"--no-color",
|
|
911
|
+
"-U3"
|
|
912
|
+
];
|
|
913
|
+
if (staged) args.push("--cached");
|
|
914
|
+
if (path !== void 0) args.push("--", path);
|
|
915
|
+
return runGit(cwd, args);
|
|
916
|
+
}
|
|
917
|
+
/** Stage paths (all when path is undefined). */
|
|
918
|
+
async function stage(cwd, path) {
|
|
919
|
+
await runGit(cwd, [
|
|
920
|
+
"add",
|
|
921
|
+
"-A",
|
|
922
|
+
...path !== void 0 ? ["--", path] : []
|
|
923
|
+
]);
|
|
924
|
+
}
|
|
925
|
+
/** Unstage paths (all when path is undefined). */
|
|
926
|
+
async function unstage(cwd, path) {
|
|
927
|
+
await runGit(cwd, [
|
|
928
|
+
"reset",
|
|
929
|
+
"-q",
|
|
930
|
+
...path !== void 0 ? ["--", path] : []
|
|
931
|
+
]);
|
|
932
|
+
}
|
|
933
|
+
/** Commit the staged changes with a message (global identity untouched). */
|
|
934
|
+
async function commit(cwd, message) {
|
|
935
|
+
await runGit(cwd, [
|
|
936
|
+
"commit",
|
|
937
|
+
"-m",
|
|
938
|
+
message
|
|
939
|
+
]);
|
|
940
|
+
}
|
|
941
|
+
/** Branch names (current first). */
|
|
942
|
+
async function branches(cwd) {
|
|
943
|
+
const [current, raw] = await Promise.all([currentBranch(cwd).catch(() => "HEAD"), runGit(cwd, [
|
|
944
|
+
"for-each-ref",
|
|
945
|
+
"--format=%(refname:short)",
|
|
946
|
+
"refs/heads"
|
|
947
|
+
])]);
|
|
948
|
+
const names = raw.split("\n").filter((line) => line !== "");
|
|
949
|
+
return {
|
|
950
|
+
current,
|
|
951
|
+
names: names.includes(current) ? names : [current, ...names]
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
/** Switch to an existing branch. */
|
|
955
|
+
async function checkout(cwd, branch) {
|
|
956
|
+
await runGit(cwd, ["checkout", branch]);
|
|
957
|
+
}
|
|
958
|
+
/** Recent commit history (newest first), lazily pageable via skip/count. */
|
|
959
|
+
async function log(cwd, count = 30, skip = 0) {
|
|
960
|
+
return parseLogLines(await runGit(cwd, [
|
|
961
|
+
"log",
|
|
962
|
+
"-n",
|
|
963
|
+
String(count),
|
|
964
|
+
"--skip",
|
|
965
|
+
String(skip),
|
|
966
|
+
"--decorate=short",
|
|
967
|
+
"--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D"
|
|
968
|
+
]));
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Content of a file at a revision (`git show <rev>:<path>`), or null when the
|
|
972
|
+
* revision has no such path (a new/untracked file has no HEAD side).
|
|
973
|
+
*/
|
|
974
|
+
async function show(cwd, rev, path) {
|
|
975
|
+
try {
|
|
976
|
+
return await runGit(cwd, ["show", `${rev}:${path}`]);
|
|
977
|
+
} catch {
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
/** Full patch text of one commit (`git show` with the commit header suppressed).
|
|
982
|
+
* Merge commits show their diff against the first parent (`-m --first-parent`
|
|
983
|
+
* is a no-op for regular commits), so a history click always has content. */
|
|
984
|
+
async function commitDiff(cwd, hash) {
|
|
985
|
+
return runGit(cwd, [
|
|
986
|
+
"show",
|
|
987
|
+
"--no-ext-diff",
|
|
988
|
+
"--no-color",
|
|
989
|
+
"--format=",
|
|
990
|
+
"-m",
|
|
991
|
+
"--first-parent",
|
|
992
|
+
hash
|
|
993
|
+
]);
|
|
994
|
+
}
|
|
995
|
+
/** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
|
|
996
|
+
async function discard(cwd, path) {
|
|
997
|
+
await runGit(cwd, [
|
|
998
|
+
"checkout",
|
|
999
|
+
"--",
|
|
1000
|
+
path
|
|
1001
|
+
]);
|
|
1002
|
+
}
|
|
1003
|
+
/** Revert one commit onto the current branch with an auto-generated message. */
|
|
1004
|
+
async function revert(cwd, hash) {
|
|
1005
|
+
await runGit(cwd, [
|
|
1006
|
+
"revert",
|
|
1007
|
+
"--no-edit",
|
|
1008
|
+
hash
|
|
1009
|
+
]);
|
|
1010
|
+
}
|
|
1011
|
+
/** Cherry-pick one commit onto the current branch. */
|
|
1012
|
+
async function cherryPick(cwd, hash) {
|
|
1013
|
+
await runGit(cwd, ["cherry-pick", hash]);
|
|
1014
|
+
}
|
|
1015
|
+
//#endregion
|
|
1016
|
+
//#region src/pty-deps.ts
|
|
1017
|
+
/**
|
|
1018
|
+
* node-pty dependency loading for the host half (issue #140, plugin side).
|
|
1019
|
+
*
|
|
1020
|
+
* The terminal surfaces (UI tabs + model-facing terminal_* tools) need
|
|
1021
|
+
* node-pty, but the package must NEVER be imported statically at module
|
|
1022
|
+
* top level: a missing or broken install (pnpm 11's strict-dep-builds
|
|
1023
|
+
* skipping node-pty's install script, a pruned store entry, a failed
|
|
1024
|
+
* prebuilt-binary download…) would then fail the plugin module load and —
|
|
1025
|
+
* because a loader entry apply failure aborts the boot — take the whole
|
|
1026
|
+
* `dsh web` server down with it.
|
|
1027
|
+
*
|
|
1028
|
+
* Instead the host half loads node-pty lazily (synchronously, via
|
|
1029
|
+
* createRequire — the same resolution `ensureSpawnHelper` already uses in
|
|
1030
|
+
* production). When the load fails the plugin stays mounted in a degraded
|
|
1031
|
+
* state: the terminal tab shows a friendly error carrying a pasteable
|
|
1032
|
+
* repair command (see scripts/install.sh / install.ps1 `--repair`), and the
|
|
1033
|
+
* agent terminal tools are simply not registered.
|
|
1034
|
+
*
|
|
1035
|
+
* Version contract: the plugin must stay in sync with DSH core —
|
|
1036
|
+
* `@deepseek-ai/dsh-subprocess-local` declares `"node-pty": "^1.1.0"` in
|
|
1037
|
+
* its `dependencies`. Both sides then resolve the SAME pnpm store entry
|
|
1038
|
+
* (same range, same integrity → one native binding, no drift). Do NOT
|
|
1039
|
+
* switch to a fork (e.g. @lydell/node-pty) or a different range without
|
|
1040
|
+
* re-checking the core declaration.
|
|
1041
|
+
*/
|
|
1042
|
+
/**
|
|
1043
|
+
* The node-pty version range this plugin ships. MUST stay identical to the
|
|
1044
|
+
* range DSH core declares (`@deepseek-ai/dsh-subprocess-local`): the same
|
|
1045
|
+
* range keeps pnpm resolving both to one physical package.
|
|
1046
|
+
*/
|
|
1047
|
+
const DSH_NODE_PTY_RANGE = "^1.1.0";
|
|
1048
|
+
/**
|
|
1049
|
+
* The WebSocket close-code-1011 reason the host sends when node-pty is
|
|
1050
|
+
* unavailable. The client recognizes this exact marker and fetches the full
|
|
1051
|
+
* repair details from `/sidebar/api/terminal.deps` (a WS close reason is
|
|
1052
|
+
* capped at 123 bytes, so the command itself cannot ride the close frame).
|
|
1053
|
+
*/
|
|
1054
|
+
const PTY_DEPS_MISSING = "pty-deps-missing";
|
|
1055
|
+
const defaultRequire = createRequire(import.meta.url);
|
|
1056
|
+
let cached;
|
|
1057
|
+
/**
|
|
1058
|
+
* Load node-pty once (synchronously) and cache the outcome. Returns null
|
|
1059
|
+
* when the package or its native binding cannot be loaded; the cause stays
|
|
1060
|
+
* queryable through {@link nodePtyLoadCause}. Never throws.
|
|
1061
|
+
*/
|
|
1062
|
+
function loadNodePty(requireImpl = defaultRequire) {
|
|
1063
|
+
if (cached === void 0) try {
|
|
1064
|
+
cached = {
|
|
1065
|
+
ok: true,
|
|
1066
|
+
module: requireImpl("node-pty")
|
|
1067
|
+
};
|
|
1068
|
+
} catch (cause) {
|
|
1069
|
+
cached = {
|
|
1070
|
+
ok: false,
|
|
1071
|
+
cause
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
return cached.ok ? cached.module : null;
|
|
1075
|
+
}
|
|
1076
|
+
/** The recorded load failure (undefined when the load succeeded or never ran). */
|
|
1077
|
+
function nodePtyLoadCause() {
|
|
1078
|
+
return cached !== void 0 && !cached.ok ? cached.cause : void 0;
|
|
1079
|
+
}
|
|
1080
|
+
/** Load node-pty or throw the canonical degraded-mode error (class-constructor default). */
|
|
1081
|
+
function loadRequiredNodePty() {
|
|
1082
|
+
const module = loadNodePty();
|
|
1083
|
+
if (module === null) {
|
|
1084
|
+
const cause = describeCause(nodePtyLoadCause());
|
|
1085
|
+
throw new SidebarError("pty-deps-missing", `node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${cause} — run the repair command shown in the terminal tab`, 503);
|
|
1086
|
+
}
|
|
1087
|
+
return module;
|
|
1088
|
+
}
|
|
1089
|
+
/** Resolve a directory to its physical location (symlinked/link: installs). */
|
|
1090
|
+
function realDir(file) {
|
|
1091
|
+
try {
|
|
1092
|
+
return dirname(realpathSync(file));
|
|
1093
|
+
} catch {
|
|
1094
|
+
return dirname(file);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
/** Walk up from `dir` looking for a DSH profile root (package.json + pnpm-workspace.yaml). */
|
|
1098
|
+
function walkUp(dir, isRoot) {
|
|
1099
|
+
let current = dir;
|
|
1100
|
+
for (let depth = 0; depth < 16; depth += 1) {
|
|
1101
|
+
if (isRoot(current)) return current;
|
|
1102
|
+
const parent = dirname(current);
|
|
1103
|
+
if (parent === current) break;
|
|
1104
|
+
current = parent;
|
|
1105
|
+
}
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
/** Whether `dir` looks like a DSH profile root (the plugin lives under its node_modules). */
|
|
1109
|
+
function isProfileRoot(dir) {
|
|
1110
|
+
return existsSync(join(dir, "package.json")) && existsSync(join(dir, "pnpm-workspace.yaml"));
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Detect the DSH profile directory this plugin is installed into: the
|
|
1114
|
+
* nearest ancestor of the plugin module that carries both `package.json`
|
|
1115
|
+
* and `pnpm-workspace.yaml` (the profile root; the plugin resolves from the
|
|
1116
|
+
* profile's node_modules). Falls back to `$DSH_HOME/profiles/web` (the
|
|
1117
|
+
* standard web profile), then null.
|
|
1118
|
+
*/
|
|
1119
|
+
function findProfileDir(fromFile = fileURLToPath(import.meta.url)) {
|
|
1120
|
+
const detected = walkUp(realDir(fromFile), isProfileRoot);
|
|
1121
|
+
if (detected !== null) return detected;
|
|
1122
|
+
const home = process.env.DSH_HOME !== void 0 && process.env.DSH_HOME.trim() !== "" ? process.env.DSH_HOME : join(homedir(), ".dsh");
|
|
1123
|
+
const web = join(home, "profiles", "web");
|
|
1124
|
+
return isProfileRoot(web) ? realpathSync(web) : null;
|
|
1125
|
+
}
|
|
1126
|
+
/** Whether `dir`'s package.json declares this plugin's name. */
|
|
1127
|
+
function isPluginRoot(dir) {
|
|
1128
|
+
const file = join(dir, "package.json");
|
|
1129
|
+
if (!existsSync(file)) return false;
|
|
1130
|
+
try {
|
|
1131
|
+
return JSON.parse(readFileSync(file, "utf8")).name === "dsh-better-sidebar";
|
|
1132
|
+
} catch {
|
|
1133
|
+
return false;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
/** The plugin package root (walk-up from the module; works for lib/ and src/ layouts). */
|
|
1137
|
+
function findPluginRoot(fromFile = fileURLToPath(import.meta.url)) {
|
|
1138
|
+
return walkUp(realDir(fromFile), isPluginRoot);
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* The pasteable repair command for a broken node-pty install: rerun the
|
|
1142
|
+
* plugin's own installer in `--repair` mode (idempotent: it re-writes the
|
|
1143
|
+
* profile's `allowBuilds: node-pty: true` and re-installs/rebuilds the
|
|
1144
|
+
* dependency). Falls back to DSH's plugin command when the scripts are not
|
|
1145
|
+
* shipped (exotic layouts).
|
|
1146
|
+
*/
|
|
1147
|
+
function buildRepairCommand(options) {
|
|
1148
|
+
const { pluginRoot, profileDir } = options;
|
|
1149
|
+
const platform = options.platform ?? process.platform;
|
|
1150
|
+
const profileName = profileDir !== null ? basename(profileDir) : null;
|
|
1151
|
+
const profileArg = profileName !== null ? platform === "win32" ? ` -Profile "${profileName}"` : ` --profile "${profileName}"` : "";
|
|
1152
|
+
if (pluginRoot !== null) {
|
|
1153
|
+
if (platform === "win32") {
|
|
1154
|
+
const script = join(pluginRoot, "scripts", "install.ps1");
|
|
1155
|
+
if (existsSync(script)) return { command: `powershell -ExecutionPolicy Bypass -File "${script}" -Repair${profileArg}` };
|
|
1156
|
+
} else {
|
|
1157
|
+
const script = join(pluginRoot, "scripts", "install.sh");
|
|
1158
|
+
if (existsSync(script)) return { command: `bash "${script}" --repair${profileArg}` };
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
return {
|
|
1162
|
+
command: `dsh plugin --profile "${profileName ?? "web"}" install`,
|
|
1163
|
+
note: "If pnpm 11 blocked node-pty's build script, ensure `allowBuilds: node-pty: true` in the profile's pnpm-workspace.yaml (the plugin's scripts/install.sh / install.ps1 --repair does this automatically)."
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
/** One-line human description of the recorded load cause. */
|
|
1167
|
+
function describeCause(cause) {
|
|
1168
|
+
if (cause instanceof Error) return cause.message;
|
|
1169
|
+
return String(cause);
|
|
1170
|
+
}
|
|
1171
|
+
/** Current node-pty dependency status (loaded vs degraded + repair info). */
|
|
1172
|
+
function depsStatus(options = {}) {
|
|
1173
|
+
if (loadNodePty() !== null) return { ok: true };
|
|
1174
|
+
const pluginRoot = findPluginRoot(options.fromFile);
|
|
1175
|
+
const profileDir = findProfileDir(options.fromFile);
|
|
1176
|
+
const { command, note } = buildRepairCommand({
|
|
1177
|
+
pluginRoot,
|
|
1178
|
+
profileDir
|
|
1179
|
+
});
|
|
1180
|
+
return {
|
|
1181
|
+
ok: false,
|
|
1182
|
+
cause: describeCause(nodePtyLoadCause()),
|
|
1183
|
+
command,
|
|
1184
|
+
profile: profileDir !== null ? basename(profileDir) : null,
|
|
1185
|
+
...note !== void 0 ? { note } : {}
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
//#endregion
|
|
1189
|
+
//#region src/pty-manager.ts
|
|
1190
|
+
/**
|
|
1191
|
+
* PTY session table for the sidebar terminals. One node-pty process per
|
|
1192
|
+
* `${sessionId}:${tabId}` key; processes survive WebSocket disconnects
|
|
1193
|
+
* (page refresh, tab switch) and reconnect to the same process by key.
|
|
1194
|
+
* Output is mirrored into a bounded transcript ring (capped bytes) so a new
|
|
1195
|
+
* connection replays history before live data. Sessions die only when the
|
|
1196
|
+
* tab is closed or the plugin tears down.
|
|
1197
|
+
*/
|
|
1198
|
+
/** Per-terminal transcript bound (bytes kept for replay). */
|
|
1199
|
+
const TRANSCRIPT_LIMIT$1 = 1 << 20;
|
|
1200
|
+
/**
|
|
1201
|
+
* Restore the executable bit pnpm strips from node-pty's prebuilt
|
|
1202
|
+
* spawn-helper (the macOS helper that forks and sets up the pty). Without it
|
|
1203
|
+
* every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
|
|
1204
|
+
* @deepseek-ai/dsh-terminal-bash's ensure-spawn-helper postinstall, run at
|
|
1205
|
+
* plugin activation so link-installed deployments get the fix too.
|
|
1206
|
+
*/
|
|
1207
|
+
function ensureSpawnHelper() {
|
|
1208
|
+
if (process.platform === "win32") return;
|
|
1209
|
+
try {
|
|
1210
|
+
const entry = createRequire(import.meta.url).resolve("node-pty");
|
|
1211
|
+
const packageRoot = dirname(dirname(entry));
|
|
1212
|
+
const candidates = [join(packageRoot, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), join(packageRoot, "build", "Release", "spawn-helper")];
|
|
1213
|
+
for (const helper of candidates) if (existsSync(helper)) chmodSync(helper, 493);
|
|
1214
|
+
} catch {}
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* The terminal registry. `maxPerSession` bounds concurrent processes per
|
|
1218
|
+
* conversation (the client caps tabs at the same number).
|
|
1219
|
+
*
|
|
1220
|
+
* Lifecycle of a UI-tab pty when its WebSocket drops:
|
|
1221
|
+
* - **Close frame** (`{type:'close'}`): the user closed the tab → schedule a
|
|
1222
|
+
* 0-ms close (quota released immediately).
|
|
1223
|
+
* - **Park frame** (`{type:'park'}`): the user switched to another
|
|
1224
|
+
* conversation; the tab is still open in its session's persisted state but
|
|
1225
|
+
* its view unmounted → mark the pty as parked (no auto-close countdown).
|
|
1226
|
+
* The pty stays alive until the user switches back (a reconnecting view
|
|
1227
|
+
* calls `open()` which clears the parked state) or the tab is later closed
|
|
1228
|
+
* (a `{type:'close'}` frame from a fresh connection). Without `park`, a
|
|
1229
|
+
* bare socket drop would start the reconnect-grace countdown and kill the
|
|
1230
|
+
* shell after `reconnectGraceMs` — wrong for a session switch, where the
|
|
1231
|
+
* user is still actively using the app, just in another conversation.
|
|
1232
|
+
* - **Bare socket drop** (no frame): page refresh, crash, plugin teardown →
|
|
1233
|
+
* schedule a close after `reconnectGraceMs` so a quick reconnect reattaches
|
|
1234
|
+
* the same shell.
|
|
1235
|
+
*/
|
|
1236
|
+
var PtyManager = class {
|
|
1237
|
+
shell;
|
|
1238
|
+
maxPerSession;
|
|
1239
|
+
shellArgs;
|
|
1240
|
+
nodePty;
|
|
1241
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1242
|
+
pendingCloses = /* @__PURE__ */ new Map();
|
|
1243
|
+
/** Tabs whose view unmounted because the user switched conversations — the
|
|
1244
|
+
* tab is still open in its session's state, so the pty must NOT enter the
|
|
1245
|
+
* reconnect-grace countdown. Cleared by `cancelClose` (a reconnecting
|
|
1246
|
+
* view's `open()` cancels it) or by `scheduleClose` (an explicit close
|
|
1247
|
+
* frame still kills a parked pty). */
|
|
1248
|
+
parked = /* @__PURE__ */ new Set();
|
|
1249
|
+
constructor(shell, maxPerSession, shellArgs = [], nodePty = loadRequiredNodePty()) {
|
|
1250
|
+
this.shell = shell;
|
|
1251
|
+
this.maxPerSession = maxPerSession;
|
|
1252
|
+
this.shellArgs = shellArgs;
|
|
1253
|
+
this.nodePty = nodePty;
|
|
1254
|
+
}
|
|
1255
|
+
/** All live terminal keys of one session. */
|
|
1256
|
+
keysOf(sessionId) {
|
|
1257
|
+
const keys = [];
|
|
1258
|
+
for (const handle of this.sessions.values()) if (handle.sessionId === sessionId) keys.push(handle.key);
|
|
1259
|
+
return keys;
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Open (or reuse) the terminal for a session/tab key. A handle whose
|
|
1263
|
+
* process already exited is replaced with a fresh spawn (reconnecting a
|
|
1264
|
+
* dead terminal must yield a live shell, not an input sink), and so is a
|
|
1265
|
+
* live handle whose spawn cwd differs from the now-authoritative one (the
|
|
1266
|
+
* first connect of a page load can arrive before the session hydrates, so
|
|
1267
|
+
* it fell back to the process cwd — reconnecting with the real cwd must
|
|
1268
|
+
* restart the shell in the right directory). Reopening also cancels any
|
|
1269
|
+
* pending scheduled close (a reconnect within the grace window keeps the
|
|
1270
|
+
* process alive).
|
|
1271
|
+
* @param sessionId - conversation id.
|
|
1272
|
+
* @param tabId - client tab id.
|
|
1273
|
+
* @param cwd - initial working directory (the session's cwd).
|
|
1274
|
+
* @param cols - initial terminal width.
|
|
1275
|
+
* @param rows - initial terminal height.
|
|
1276
|
+
* @returns the live handle.
|
|
1277
|
+
* @throws {SidebarError} pty-error when the per-session cap is reached.
|
|
1278
|
+
*/
|
|
1279
|
+
open(sessionId, tabId, cwd, cols, rows, shell, shellArgs) {
|
|
1280
|
+
const key = `${sessionId}:${tabId}`;
|
|
1281
|
+
this.cancelClose(key);
|
|
1282
|
+
const existing = this.sessions.get(key);
|
|
1283
|
+
if (existing !== void 0 && !existing.exited && existing.cwd === cwd) return existing;
|
|
1284
|
+
if (existing !== void 0) this.close(key);
|
|
1285
|
+
for (const [candidate, handle] of [...this.sessions]) if (handle.sessionId === sessionId && handle.exited) this.close(candidate);
|
|
1286
|
+
if (this.keysOf(sessionId).length >= this.maxPerSession) throw new SidebarError("pty-error", `terminal limit reached (${this.maxPerSession}) for this session`, 400);
|
|
1287
|
+
const handle = {
|
|
1288
|
+
key,
|
|
1289
|
+
sessionId,
|
|
1290
|
+
tabId,
|
|
1291
|
+
cwd,
|
|
1292
|
+
pty: this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
|
1293
|
+
name: "xterm-256color",
|
|
1294
|
+
cols: Math.max(2, Math.floor(cols)),
|
|
1295
|
+
rows: Math.max(2, Math.floor(rows)),
|
|
1296
|
+
cwd,
|
|
1297
|
+
env: { ...process.env }
|
|
1298
|
+
}),
|
|
1299
|
+
transcript: "",
|
|
1300
|
+
exited: false
|
|
1301
|
+
};
|
|
1302
|
+
handle.pty.onData((data) => {
|
|
1303
|
+
handle.transcript += data;
|
|
1304
|
+
if (handle.transcript.length > TRANSCRIPT_LIMIT$1) handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT$1);
|
|
1305
|
+
});
|
|
1306
|
+
handle.pty.onExit(({ exitCode }) => {
|
|
1307
|
+
handle.exited = true;
|
|
1308
|
+
handle.exitCode = exitCode;
|
|
1309
|
+
});
|
|
1310
|
+
this.sessions.set(key, handle);
|
|
1311
|
+
return handle;
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Schedule the terminal's destruction after `delayMs`. A tab close sends
|
|
1315
|
+
* delay 0 (release the quota immediately); a bare socket drop (refresh,
|
|
1316
|
+
* crash) uses the grace period so a quick reconnect keeps the process.
|
|
1317
|
+
* `open()` cancels any pending close. Clears the parked state — an explicit
|
|
1318
|
+
* close frame on a parked pty (the user switched back and closed the tab)
|
|
1319
|
+
* still kills it.
|
|
1320
|
+
*/
|
|
1321
|
+
scheduleClose(key, delayMs) {
|
|
1322
|
+
if (this.sessions.get(key) === void 0) return;
|
|
1323
|
+
this.cancelClose(key);
|
|
1324
|
+
const timer = setTimeout(() => {
|
|
1325
|
+
this.close(key);
|
|
1326
|
+
}, delayMs);
|
|
1327
|
+
this.pendingCloses.set(key, timer);
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Park a terminal: the owning tab's view unmounted because the user
|
|
1331
|
+
* switched to another conversation, but the tab is still open in its
|
|
1332
|
+
* session's persisted state. Cancels any pending grace close and marks
|
|
1333
|
+
* the pty so the host's `ws.on('close')` handler does NOT start the
|
|
1334
|
+
* reconnect-grace countdown — the pty stays alive until the user switches
|
|
1335
|
+
* back (a reconnecting view's `open()` clears this) or explicitly closes
|
|
1336
|
+
* the tab (a `{type:'close'}` frame's `scheduleClose` clears this).
|
|
1337
|
+
*/
|
|
1338
|
+
park(key) {
|
|
1339
|
+
if (this.sessions.get(key) === void 0) return;
|
|
1340
|
+
this.cancelClose(key);
|
|
1341
|
+
this.parked.add(key);
|
|
1342
|
+
}
|
|
1343
|
+
/** Whether this pty was parked (its view unmounted for a session switch). */
|
|
1344
|
+
isParked(key) {
|
|
1345
|
+
return this.parked.has(key);
|
|
1346
|
+
}
|
|
1347
|
+
/** Cancel a pending scheduled close (the terminal is being reopened).
|
|
1348
|
+
* Also clears the parked state — a reconnecting view reattaches a parked
|
|
1349
|
+
* pty and resumes normal lifecycle. */
|
|
1350
|
+
cancelClose(key) {
|
|
1351
|
+
const timer = this.pendingCloses.get(key);
|
|
1352
|
+
if (timer !== void 0) {
|
|
1353
|
+
clearTimeout(timer);
|
|
1354
|
+
this.pendingCloses.delete(key);
|
|
1355
|
+
}
|
|
1356
|
+
this.parked.delete(key);
|
|
1357
|
+
}
|
|
1358
|
+
/** Resolve a live handle by key, or undefined. */
|
|
1359
|
+
get(key) {
|
|
1360
|
+
return this.sessions.get(key);
|
|
1361
|
+
}
|
|
1362
|
+
/** Close a terminal and drop its state (the owning tab was closed). */
|
|
1363
|
+
close(key) {
|
|
1364
|
+
this.cancelClose(key);
|
|
1365
|
+
const handle = this.sessions.get(key);
|
|
1366
|
+
if (handle === void 0) return;
|
|
1367
|
+
this.sessions.delete(key);
|
|
1368
|
+
try {
|
|
1369
|
+
handle.pty.kill();
|
|
1370
|
+
} catch {}
|
|
1371
|
+
}
|
|
1372
|
+
/** Close every terminal (plugin teardown). */
|
|
1373
|
+
disposeAll() {
|
|
1374
|
+
for (const timer of this.pendingCloses.values()) clearTimeout(timer);
|
|
1375
|
+
this.pendingCloses.clear();
|
|
1376
|
+
for (const key of [...this.sessions.keys()]) this.close(key);
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
/**
|
|
1380
|
+
* Candidate directories that may contain a `pwsh.exe` on Windows: PATH
|
|
1381
|
+
* entries first, then the well-known machine/user install locations
|
|
1382
|
+
* (including preview channels and per-user MSI/portable layouts). The
|
|
1383
|
+
* machine-scope search reads both `ProgramW6432` and `ProgramFiles` so a
|
|
1384
|
+
* 32-bit Node process — whose `ProgramFiles` points at `(x86)` — still
|
|
1385
|
+
* finds a 64-bit PowerShell 7 install. De-duped while preserving priority
|
|
1386
|
+
* order.
|
|
1387
|
+
*/
|
|
1388
|
+
function windowsPwshCandidateDirs(env) {
|
|
1389
|
+
const dirs = [];
|
|
1390
|
+
const pathEntries = env.PATH;
|
|
1391
|
+
if (pathEntries !== void 0) for (const entry of pathEntries.split(";")) {
|
|
1392
|
+
const trimmed = entry.trim();
|
|
1393
|
+
if (trimmed !== "") dirs.push(trimmed);
|
|
1394
|
+
}
|
|
1395
|
+
for (const programFiles of [env.ProgramW6432, env.ProgramFiles]) {
|
|
1396
|
+
if (programFiles === void 0 || programFiles.trim() === "") continue;
|
|
1397
|
+
dirs.push(join(programFiles, "PowerShell", "7"));
|
|
1398
|
+
dirs.push(join(programFiles, "PowerShell", "7-preview"));
|
|
1399
|
+
}
|
|
1400
|
+
const localAppData = env.LOCALAPPDATA;
|
|
1401
|
+
if (localAppData !== void 0 && localAppData.trim() !== "") {
|
|
1402
|
+
dirs.push(join(localAppData, "Microsoft", "PowerShell", "7"));
|
|
1403
|
+
dirs.push(join(localAppData, "Microsoft", "PowerShell", "7-preview"));
|
|
1404
|
+
dirs.push(join(localAppData, "Programs", "PowerShell", "7"));
|
|
1405
|
+
dirs.push(join(localAppData, "Programs", "PowerShell", "7-preview"));
|
|
1406
|
+
}
|
|
1407
|
+
return [...new Set(dirs)];
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* The interactive shell for this platform, resolved like a terminal
|
|
1411
|
+
* emulator: an explicitly configured shell (the `shell` config field) wins,
|
|
1412
|
+
* then `$SHELL` on POSIX (deployment override), then the account's login
|
|
1413
|
+
* shell from passwd, then `/bin/bash`. The passwd step matters because
|
|
1414
|
+
* service managers and container inits often start dsh without `SHELL`, and
|
|
1415
|
+
* the tab should still open the user's login shell (e.g. zsh) instead of
|
|
1416
|
+
* silently degrading to bash.
|
|
1417
|
+
*
|
|
1418
|
+
* Windows previously short-circuited to `powershell.exe` (the inbox 5.1)
|
|
1419
|
+
* before any resolution, so PowerShell 7 users always got a legacy shell
|
|
1420
|
+
* without `??`/`?.`/ternary and with poor ANSI/UTF-8 defaults. The Windows
|
|
1421
|
+
* chain is now: explicit shell → `DSH_SIDEBAR_SHELL` env override → first
|
|
1422
|
+
* `pwsh.exe` found on PATH or in a known install directory → the 5.1
|
|
1423
|
+
* fallback (machines without PowerShell 7 keep working).
|
|
1424
|
+
*/
|
|
1425
|
+
function defaultShell(options = {}) {
|
|
1426
|
+
const platform = options.platform ?? process.platform;
|
|
1427
|
+
const env = options.env ?? process.env;
|
|
1428
|
+
const exists = options.exists ?? existsSync;
|
|
1429
|
+
const explicit = options.explicit;
|
|
1430
|
+
if (explicit !== void 0 && explicit.trim() !== "") return explicit.trim();
|
|
1431
|
+
if (platform === "win32") {
|
|
1432
|
+
const envShell = env.DSH_SIDEBAR_SHELL;
|
|
1433
|
+
if (envShell !== void 0 && envShell.trim() !== "") return envShell.trim();
|
|
1434
|
+
for (const dir of windowsPwshCandidateDirs(env)) {
|
|
1435
|
+
const candidate = join(dir, "pwsh.exe");
|
|
1436
|
+
if (exists(candidate)) return candidate;
|
|
1437
|
+
}
|
|
1438
|
+
return "powershell.exe";
|
|
1439
|
+
}
|
|
1440
|
+
const envShell = env.SHELL;
|
|
1441
|
+
if (envShell !== void 0 && envShell.trim() !== "") return envShell.trim();
|
|
1442
|
+
try {
|
|
1443
|
+
const loginShell = userInfo().shell;
|
|
1444
|
+
if (typeof loginShell === "string" && loginShell.trim() !== "") return loginShell;
|
|
1445
|
+
} catch {}
|
|
1446
|
+
return "/bin/bash";
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* A short display name for a shell executable, used as the terminal tab
|
|
1450
|
+
* title. `/bin/zsh` → `zsh`, `C:\...\powershell.exe` → `powershell`.
|
|
1451
|
+
* Falls back to the raw value when no basename can be derived.
|
|
1452
|
+
*/
|
|
1453
|
+
function shellDisplayName(shell) {
|
|
1454
|
+
const normalized = shell.replace(/\\/g, "/");
|
|
1455
|
+
const base = normalized.slice(normalized.lastIndexOf("/") + 1);
|
|
1456
|
+
if (base === "") return shell;
|
|
1457
|
+
return base.replace(/\.(exe|cmd|bat)$/i, "");
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Spawn arguments that make the shell behave like a terminal-emulator tab:
|
|
1461
|
+
* POSIX shells start as login shells (`-l`) so they read the profile files
|
|
1462
|
+
* (`~/.profile`, `~/.zprofile`); Windows PowerShell takes no login flag.
|
|
1463
|
+
*
|
|
1464
|
+
* When explicit `configured` args are supplied they REPLACE the platform
|
|
1465
|
+
* defaults entirely, giving deployments full control over shell startup.
|
|
1466
|
+
*/
|
|
1467
|
+
function shellSpawnArgs(configured = []) {
|
|
1468
|
+
if (configured.length > 0) return [...configured];
|
|
1469
|
+
return process.platform === "win32" ? [] : ["-l"];
|
|
1470
|
+
}
|
|
1471
|
+
//#endregion
|
|
1472
|
+
//#region src/agent-pty.ts
|
|
1473
|
+
/**
|
|
1474
|
+
* Agent-owned terminal registry: a uuid-keyed table of long-lived PTY
|
|
1475
|
+
* sessions created by the model through the `terminal_create` tool. Each
|
|
1476
|
+
* handle survives across tool calls (and across WebSocket disconnects from
|
|
1477
|
+
* the sidebar view) until the model calls `terminal_close` or the user
|
|
1478
|
+
* closes the corresponding sidebar tab — tmux semantics, scoped per agent
|
|
1479
|
+
* session.
|
|
1480
|
+
*
|
|
1481
|
+
* This is a parallel registry to {@link PtyManager}: UI tabs are keyed by
|
|
1482
|
+
* `${sessionId}:${tabId}` and capped per session, while agent terminals are
|
|
1483
|
+
* keyed by uuid and uncapped (the model is trusted to close unused ones).
|
|
1484
|
+
* Both registries share the same shell resolver and spawn-helper fix.
|
|
1485
|
+
*/
|
|
1486
|
+
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
|
|
1487
|
+
const TRANSCRIPT_LIMIT = 1 << 20;
|
|
1488
|
+
/** POSIX signals the registry forwards to a live pty. */
|
|
1489
|
+
const ALLOWED_SIGNALS = [
|
|
1490
|
+
"SIGINT",
|
|
1491
|
+
"SIGTERM",
|
|
1492
|
+
"SIGKILL",
|
|
1493
|
+
"SIGHUP",
|
|
1494
|
+
"SIGTSTP"
|
|
1495
|
+
];
|
|
1496
|
+
/** Largest pty dimension the registry accepts (mirrors the tool contract). */
|
|
1497
|
+
const TERMINAL_DIM_MAX = 1024;
|
|
1498
|
+
/** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
|
|
1499
|
+
function clampDims(cols, rows) {
|
|
1500
|
+
const clamp = (value) => Math.min(TERMINAL_DIM_MAX, Math.max(2, Math.floor(value)));
|
|
1501
|
+
return {
|
|
1502
|
+
cols: clamp(cols),
|
|
1503
|
+
rows: clamp(rows)
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
/** Map a POSIX signal number to its conventional name (best-effort). */
|
|
1507
|
+
const SIGNAL_NAMES = {
|
|
1508
|
+
1: "SIGHUP",
|
|
1509
|
+
2: "SIGINT",
|
|
1510
|
+
3: "SIGQUIT",
|
|
1511
|
+
4: "SIGILL",
|
|
1512
|
+
6: "SIGABRT",
|
|
1513
|
+
9: "SIGKILL",
|
|
1514
|
+
11: "SIGSEGV",
|
|
1515
|
+
13: "SIGPIPE",
|
|
1516
|
+
14: "SIGALRM",
|
|
1517
|
+
15: "SIGTERM",
|
|
1518
|
+
17: "SIGCHLD",
|
|
1519
|
+
18: "SIGCONT",
|
|
1520
|
+
19: "SIGSTOP",
|
|
1521
|
+
20: "SIGTSTP"
|
|
1522
|
+
};
|
|
1523
|
+
/** Convert a raw signal number to a name (or null when absent/unknown). */
|
|
1524
|
+
function signalNameOf(signal) {
|
|
1525
|
+
if (signal === null || signal === void 0) return null;
|
|
1526
|
+
return SIGNAL_NAMES[signal] ?? `signal ${signal}`;
|
|
1527
|
+
}
|
|
1528
|
+
/** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
|
|
1529
|
+
function locateNeedle(transcript, needle) {
|
|
1530
|
+
if (needle === "") return void 0;
|
|
1531
|
+
const idx = transcript.indexOf(needle);
|
|
1532
|
+
if (idx === -1) return void 0;
|
|
1533
|
+
let line = 0;
|
|
1534
|
+
let lineStart = 0;
|
|
1535
|
+
for (let i = 0; i < idx; i += 1) if (transcript.charCodeAt(i) === 10) {
|
|
1536
|
+
line += 1;
|
|
1537
|
+
lineStart = i + 1;
|
|
1538
|
+
}
|
|
1539
|
+
return {
|
|
1540
|
+
line,
|
|
1541
|
+
column: idx - lineStart
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
/** Snapshot projection of a handle (drops the pty reference and transcript). */
|
|
1545
|
+
function snapshotOf(handle) {
|
|
1546
|
+
const out = {
|
|
1547
|
+
uuid: handle.uuid,
|
|
1548
|
+
title: handle.title,
|
|
1549
|
+
command: handle.command,
|
|
1550
|
+
exited: handle.exited
|
|
1551
|
+
};
|
|
1552
|
+
if (handle.exited) {
|
|
1553
|
+
out.exitCode = handle.exitCode ?? null;
|
|
1554
|
+
out.exitSignal = signalNameOf(handle.exitSignal);
|
|
1555
|
+
}
|
|
1556
|
+
return out;
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* The agent terminal registry. The constructor takes the resolved shell
|
|
1560
|
+
* binary (the same `defaultShell()` the UI-tab registry uses) and runs the
|
|
1561
|
+
* spawn-helper chmod fix once at construction so the first agent terminal
|
|
1562
|
+
* does not race a lazy fixer.
|
|
1563
|
+
*/
|
|
1564
|
+
var AgentPtyRegistry = class {
|
|
1565
|
+
shell;
|
|
1566
|
+
shellArgs;
|
|
1567
|
+
nodePty;
|
|
1568
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1569
|
+
changeListeners = /* @__PURE__ */ new Set();
|
|
1570
|
+
constructor(shell, shellArgs = [], nodePty = loadRequiredNodePty()) {
|
|
1571
|
+
this.shell = shell;
|
|
1572
|
+
this.shellArgs = shellArgs;
|
|
1573
|
+
this.nodePty = nodePty;
|
|
1574
|
+
ensureSpawnHelper();
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Spawn one agent terminal: start the shell in `cwd`, then write
|
|
1578
|
+
* `command + '\n'` to stdin so the command runs in the fresh shell. The
|
|
1579
|
+
* terminal stays alive after the command exits — the model can send more
|
|
1580
|
+
* input through `terminal_send` until it calls `terminal_close` or the
|
|
1581
|
+
* user closes the sidebar tab. An empty `command` spawns a bare shell.
|
|
1582
|
+
* @returns the new handle's uuid (the model-facing opaque id).
|
|
1583
|
+
*/
|
|
1584
|
+
create(sessionId, title, command, cwd, cols = 80, rows = 24, shell, shellArgs) {
|
|
1585
|
+
const uuid = randomUUID();
|
|
1586
|
+
const dims = clampDims(cols, rows);
|
|
1587
|
+
const pty = this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
|
1588
|
+
name: "xterm-256color",
|
|
1589
|
+
cols: dims.cols,
|
|
1590
|
+
rows: dims.rows,
|
|
1591
|
+
cwd,
|
|
1592
|
+
env: { ...process.env }
|
|
1593
|
+
});
|
|
1594
|
+
const handle = {
|
|
1595
|
+
uuid,
|
|
1596
|
+
sessionId,
|
|
1597
|
+
title,
|
|
1598
|
+
command,
|
|
1599
|
+
cwd,
|
|
1600
|
+
pty,
|
|
1601
|
+
transcript: "",
|
|
1602
|
+
exited: false
|
|
1603
|
+
};
|
|
1604
|
+
pty.onData((data) => {
|
|
1605
|
+
handle.transcript += data;
|
|
1606
|
+
if (handle.transcript.length > TRANSCRIPT_LIMIT) handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT);
|
|
1607
|
+
});
|
|
1608
|
+
pty.onExit(({ exitCode, signal }) => {
|
|
1609
|
+
handle.exited = true;
|
|
1610
|
+
handle.exitCode = exitCode;
|
|
1611
|
+
handle.exitSignal = signal;
|
|
1612
|
+
this.notify();
|
|
1613
|
+
});
|
|
1614
|
+
if (command !== "") try {
|
|
1615
|
+
pty.write(`${command}\r`);
|
|
1616
|
+
} catch {}
|
|
1617
|
+
this.sessions.set(uuid, handle);
|
|
1618
|
+
this.notify();
|
|
1619
|
+
return uuid;
|
|
1620
|
+
}
|
|
1621
|
+
/** All live agent terminals belonging to one conversation. */
|
|
1622
|
+
list(sessionId) {
|
|
1623
|
+
const out = [];
|
|
1624
|
+
for (const handle of this.sessions.values()) if (handle.sessionId === sessionId) out.push(snapshotOf(handle));
|
|
1625
|
+
return out;
|
|
1626
|
+
}
|
|
1627
|
+
/** Resolve a live handle by uuid, or throw `not-found`. */
|
|
1628
|
+
expect(uuid) {
|
|
1629
|
+
const handle = this.sessions.get(uuid);
|
|
1630
|
+
if (handle === void 0) throw new SidebarError("not-found", `agent terminal "${uuid}" not found`, 404);
|
|
1631
|
+
return handle;
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Resolve a live handle that belongs to `sessionId`, or throw `not-found`.
|
|
1635
|
+
* The model-facing tools call this before every uuid-keyed operation: a
|
|
1636
|
+
* uuid from another session is indistinguishable from an unknown one, so a
|
|
1637
|
+
* model can never reach (or probe) a terminal it does not own.
|
|
1638
|
+
*/
|
|
1639
|
+
assertOwned(uuid, sessionId) {
|
|
1640
|
+
const handle = this.expect(uuid);
|
|
1641
|
+
if (handle.sessionId !== sessionId) throw new SidebarError("not-found", `agent terminal "${uuid}" not found`, 404);
|
|
1642
|
+
return handle;
|
|
1643
|
+
}
|
|
1644
|
+
/** Resolve a handle's snapshot, or undefined if it does not exist. */
|
|
1645
|
+
snapshot(uuid) {
|
|
1646
|
+
const handle = this.sessions.get(uuid);
|
|
1647
|
+
return handle === void 0 ? void 0 : snapshotOf(handle);
|
|
1648
|
+
}
|
|
1649
|
+
/** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */
|
|
1650
|
+
send(uuid, text) {
|
|
1651
|
+
const handle = this.expect(uuid);
|
|
1652
|
+
if (handle.exited) throw new SidebarError("bad-request", `agent terminal "${uuid}" has exited`, 400);
|
|
1653
|
+
handle.pty.write(text);
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Read one bounded page of the retained transcript. `offset` is a 0-based
|
|
1657
|
+
* line index from the start of the retained transcript (default 0);
|
|
1658
|
+
* `count` caps the page size (default 500). A negative `offset` reads
|
|
1659
|
+
* from the end (e.g. -50 reads the last 50 lines). Returns `totalLines`
|
|
1660
|
+
* so the model can paginate.
|
|
1661
|
+
*/
|
|
1662
|
+
read(uuid, offset, count) {
|
|
1663
|
+
const lines = this.expect(uuid).transcript.split("\n");
|
|
1664
|
+
const totalLines = lines.length;
|
|
1665
|
+
const pageSize = Math.max(1, Math.min(count ?? 500, 500));
|
|
1666
|
+
let start;
|
|
1667
|
+
if (offset === void 0 || offset === 0) start = 0;
|
|
1668
|
+
else if (offset < 0) start = Math.max(0, totalLines + offset);
|
|
1669
|
+
else start = Math.min(offset, totalLines);
|
|
1670
|
+
const end = Math.min(start + pageSize, totalLines);
|
|
1671
|
+
return {
|
|
1672
|
+
text: lines.slice(start, end).join("\n"),
|
|
1673
|
+
totalLines,
|
|
1674
|
+
lineBegin: start,
|
|
1675
|
+
lineEnd: end
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Resize a terminal's pty, clamped to the 2..1024 sane range.
|
|
1680
|
+
* @returns the dimensions actually applied (the caller echoes these, so the
|
|
1681
|
+
* reported value always matches the pty).
|
|
1682
|
+
*/
|
|
1683
|
+
resize(uuid, cols, rows) {
|
|
1684
|
+
const handle = this.expect(uuid);
|
|
1685
|
+
const dims = clampDims(cols, rows);
|
|
1686
|
+
if (!handle.exited) handle.pty.resize(dims.cols, dims.rows);
|
|
1687
|
+
return dims;
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Wait for `needle` to appear in a terminal's transcript, or for the
|
|
1691
|
+
* terminal to exit, or for the timeout to elapse — whichever happens
|
|
1692
|
+
* first. The wait polls the live transcript every ~50ms and short-circuits
|
|
1693
|
+
* on `signal` abort (re-thrown as the abort reason so the tool layer
|
|
1694
|
+
* surfaces cancellation).
|
|
1695
|
+
*
|
|
1696
|
+
* The match scans the FULL retained transcript on each poll, not just the
|
|
1697
|
+
* delta since the last poll — a needle that scrolled past the most recent
|
|
1698
|
+
* chunk but is still within the ~1 MiB bound is still a match. The
|
|
1699
|
+
* returned line/column locate the FIRST occurrence (oldest), which is what
|
|
1700
|
+
* a user watching the terminal would have seen first.
|
|
1701
|
+
*
|
|
1702
|
+
* The implementation uses polling (not pty onData subscription) because
|
|
1703
|
+
* node-pty's onData fires before the registry's own onData listener
|
|
1704
|
+
* updates the transcript (listener order is not guaranteed), and on
|
|
1705
|
+
* Windows ConPTY output can arrive in bursts with batching delays that
|
|
1706
|
+
* make event-driven wakeups unreliable. A 50ms poll is fast enough for
|
|
1707
|
+
* interactive use and simple enough to be obviously correct.
|
|
1708
|
+
* @param uuid - terminal to watch.
|
|
1709
|
+
* @param needle - substring to search for (case-sensitive, verbatim).
|
|
1710
|
+
* @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
|
|
1711
|
+
* @param signal - caller-owned cancellation; aborts the wait re-throwing.
|
|
1712
|
+
* @returns one of `found` / `timeout` / `exited`.
|
|
1713
|
+
*/
|
|
1714
|
+
async waitFor(uuid, needle, timeoutMs = 1e4, signal) {
|
|
1715
|
+
if (needle === "") throw new SidebarError("bad-request", "needle must be a non-empty string", 400);
|
|
1716
|
+
const handle = this.expect(uuid);
|
|
1717
|
+
const timeout = Math.max(100, Math.floor(timeoutMs));
|
|
1718
|
+
const start = Date.now();
|
|
1719
|
+
const deadline = start + timeout;
|
|
1720
|
+
if (handle.exited) return {
|
|
1721
|
+
kind: "exited",
|
|
1722
|
+
needle,
|
|
1723
|
+
exitCode: handle.exitCode ?? null,
|
|
1724
|
+
exitSignal: signalNameOf(handle.exitSignal)
|
|
1725
|
+
};
|
|
1726
|
+
const firstHit = locateNeedle(handle.transcript, needle);
|
|
1727
|
+
if (firstHit !== void 0) return {
|
|
1728
|
+
kind: "found",
|
|
1729
|
+
needle,
|
|
1730
|
+
line: firstHit.line,
|
|
1731
|
+
column: firstHit.column,
|
|
1732
|
+
elapsedMs: Date.now() - start
|
|
1733
|
+
};
|
|
1734
|
+
while (true) {
|
|
1735
|
+
if (signal?.aborted) signal.throwIfAborted();
|
|
1736
|
+
if (handle.exited) return {
|
|
1737
|
+
kind: "exited",
|
|
1738
|
+
needle,
|
|
1739
|
+
exitCode: handle.exitCode ?? null,
|
|
1740
|
+
exitSignal: signalNameOf(handle.exitSignal)
|
|
1741
|
+
};
|
|
1742
|
+
const hit = locateNeedle(handle.transcript, needle);
|
|
1743
|
+
if (hit !== void 0) return {
|
|
1744
|
+
kind: "found",
|
|
1745
|
+
needle,
|
|
1746
|
+
line: hit.line,
|
|
1747
|
+
column: hit.column,
|
|
1748
|
+
elapsedMs: Date.now() - start
|
|
1749
|
+
};
|
|
1750
|
+
if (Date.now() >= deadline) return {
|
|
1751
|
+
kind: "timeout",
|
|
1752
|
+
needle,
|
|
1753
|
+
timeoutMs: timeout,
|
|
1754
|
+
totalLines: handle.transcript.split("\n").length
|
|
1755
|
+
};
|
|
1756
|
+
await new Promise((resolve) => {
|
|
1757
|
+
const t = setTimeout(resolve, 50);
|
|
1758
|
+
if (typeof t === "object" && "unref" in t) t.unref();
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* Send a POSIX signal to a terminal's foreground process.
|
|
1764
|
+
*
|
|
1765
|
+
* Two delivery paths, by signal kind:
|
|
1766
|
+
* - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by
|
|
1767
|
+
* writing the corresponding control character to the pty stdin. This is
|
|
1768
|
+
* how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel
|
|
1769
|
+
* line discipline (POSIX ISIG mode) or the ConPTY input pipeline
|
|
1770
|
+
* (Windows), which translates it into a SIGINT/SIGTSTP for the
|
|
1771
|
+
* foreground process group. This works on every platform — calling
|
|
1772
|
+
* `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX,
|
|
1773
|
+
* but writing `\x03` is universally correct.
|
|
1774
|
+
* - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`,
|
|
1775
|
+
* which maps to the platform's process-termination path (POSIX
|
|
1776
|
+
* `kill(2)`, Windows `TerminateProcess`). These cannot be faked with
|
|
1777
|
+
* control characters.
|
|
1778
|
+
*/
|
|
1779
|
+
signal(uuid, signal) {
|
|
1780
|
+
const handle = this.expect(uuid);
|
|
1781
|
+
if (handle.exited) return;
|
|
1782
|
+
if (signal === "SIGINT" || signal === "SIGTSTP") {
|
|
1783
|
+
const ctrlByte = signal === "SIGINT" ? "" : "";
|
|
1784
|
+
try {
|
|
1785
|
+
handle.pty.write(ctrlByte);
|
|
1786
|
+
} catch {}
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
try {
|
|
1790
|
+
handle.pty.kill(signal);
|
|
1791
|
+
} catch {
|
|
1792
|
+
try {
|
|
1793
|
+
handle.pty.kill();
|
|
1794
|
+
} catch {}
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Close a terminal and drop its state. Idempotent: a second close of the
|
|
1799
|
+
* same uuid is a no-op. Returns true iff a live handle was actually
|
|
1800
|
+
* dropped.
|
|
1801
|
+
*/
|
|
1802
|
+
close(uuid) {
|
|
1803
|
+
const handle = this.sessions.get(uuid);
|
|
1804
|
+
if (handle === void 0) return false;
|
|
1805
|
+
this.sessions.delete(uuid);
|
|
1806
|
+
try {
|
|
1807
|
+
handle.pty.kill();
|
|
1808
|
+
} catch {}
|
|
1809
|
+
this.notify();
|
|
1810
|
+
return true;
|
|
1811
|
+
}
|
|
1812
|
+
/** Resolve a live handle by uuid (for the WS attach path). */
|
|
1813
|
+
get(uuid) {
|
|
1814
|
+
return this.sessions.get(uuid);
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Subscribe to registry changes (create / close / exit). The sidebar push
|
|
1818
|
+
* endpoint uses this to forward snapshots to the connected view. Returns
|
|
1819
|
+
* the unsubscribe function.
|
|
1820
|
+
*/
|
|
1821
|
+
subscribe(listener) {
|
|
1822
|
+
this.changeListeners.add(listener);
|
|
1823
|
+
return () => {
|
|
1824
|
+
this.changeListeners.delete(listener);
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
/** Close every agent terminal (plugin teardown). */
|
|
1828
|
+
disposeAll() {
|
|
1829
|
+
for (const uuid of [...this.sessions.keys()]) this.close(uuid);
|
|
1830
|
+
}
|
|
1831
|
+
/** Fire every change listener (callers wrap in try/catch if needed). */
|
|
1832
|
+
notify() {
|
|
1833
|
+
for (const listener of [...this.changeListeners]) try {
|
|
1834
|
+
listener();
|
|
1835
|
+
} catch {}
|
|
1836
|
+
}
|
|
1837
|
+
};
|
|
1838
|
+
//#endregion
|
|
1839
|
+
//#region src/tools.ts
|
|
1840
|
+
/**
|
|
1841
|
+
* Eight model-facing tools for the agent-owned sidebar terminals (tmux
|
|
1842
|
+
* semantics: spawn-and-detach, send-keys, read, wait-for, resize, signal,
|
|
1843
|
+
* close, list). Each tool binds to the calling agent's session through
|
|
1844
|
+
* `exec.agent.session.id`, so the model never passes a sessionId — the
|
|
1845
|
+
* agent identity is the scope.
|
|
1846
|
+
*
|
|
1847
|
+
* Conventions (per plugin-development-guide.md §3):
|
|
1848
|
+
* C1 — parameters schema-validated before `execute` runs.
|
|
1849
|
+
* C4 — `execute` returns one canonical JSON value; `render` is a separate
|
|
1850
|
+
* pure text projection.
|
|
1851
|
+
* C6 — `exec.signal.throwIfAborted()` before any spawn.
|
|
1852
|
+
* C10 — no UI/transport vocabulary in the canonical value.
|
|
1853
|
+
*/
|
|
1854
|
+
/** Maximum UTF-8 bytes of one `terminal_read` result text. */
|
|
1855
|
+
const READ_BYTE_LIMIT = 262144;
|
|
1856
|
+
/**
|
|
1857
|
+
* Bound a string to a byte limit, marking truncation. Truncation never
|
|
1858
|
+
* splits a multi-byte UTF-8 sequence: when the byte cap lands inside one,
|
|
1859
|
+
* the walk-back retreats to the sequence's leading byte so the retained
|
|
1860
|
+
* prefix decodes cleanly (a split would decode to U+FFFD).
|
|
1861
|
+
* @internal exported for the unit tests, like {@link snapshotOf}.
|
|
1862
|
+
*/
|
|
1863
|
+
function boundBytes(text, maxBytes) {
|
|
1864
|
+
const buf = Buffer.from(text, "utf8");
|
|
1865
|
+
if (buf.byteLength <= maxBytes) return {
|
|
1866
|
+
text,
|
|
1867
|
+
truncated: false
|
|
1868
|
+
};
|
|
1869
|
+
let end = maxBytes;
|
|
1870
|
+
while (end > 0 && ((buf[end] ?? 0) & 192) === 128) end -= 1;
|
|
1871
|
+
return {
|
|
1872
|
+
text: buf.subarray(0, end).toString("utf8"),
|
|
1873
|
+
truncated: true
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
/** Pure text projection helper (the canonical value is already structured). */
|
|
1877
|
+
function textRender(fn) {
|
|
1878
|
+
return (_args, value) => [{
|
|
1879
|
+
type: "text",
|
|
1880
|
+
text: fn(value)
|
|
1881
|
+
}];
|
|
1882
|
+
}
|
|
1883
|
+
/** Extract the calling agent or throw the canonical "no agent" error. */
|
|
1884
|
+
function requireAgent(agent) {
|
|
1885
|
+
if (agent === void 0) throw new Error("sidebar terminal tools require an initiating agent");
|
|
1886
|
+
return agent;
|
|
1887
|
+
}
|
|
1888
|
+
/** Resolve the calling agent's session id (the registry scope + ownership key). */
|
|
1889
|
+
function sessionIdOf(exec) {
|
|
1890
|
+
return requireAgent(exec.agent).session.id;
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* Register the eight terminal tools against the host tool registry. The
|
|
1894
|
+
* `resolveCwd` callback threads the live session cwd (authoritative from the
|
|
1895
|
+
* session store, falling back to the process cwd) so a freshly-created
|
|
1896
|
+
* terminal lands in the right directory without the model passing it.
|
|
1897
|
+
* Every uuid-keyed tool first asserts the terminal belongs to the calling
|
|
1898
|
+
* session (`registry.assertOwned`), so one agent can never reach another
|
|
1899
|
+
* session's terminals.
|
|
1900
|
+
* @param ctx - host plugin context (carries the tools service).
|
|
1901
|
+
* @param registry - the agent-owned terminal registry.
|
|
1902
|
+
* @param resolveCwd - live cwd resolver for one session id.
|
|
1903
|
+
* @returns a disposer that unregisters all eight tools (the caller gates
|
|
1904
|
+
* registration on the side-card setting and calls this to turn them off).
|
|
1905
|
+
*/
|
|
1906
|
+
function registerTools(ctx, registry, resolveCwd, readShellOverrides) {
|
|
1907
|
+
const disposers = [];
|
|
1908
|
+
const register = (tool) => {
|
|
1909
|
+
disposers.push(ctx.tools.register(tool));
|
|
1910
|
+
};
|
|
1911
|
+
register(defineTool({
|
|
1912
|
+
name: "terminal_create",
|
|
1913
|
+
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.",
|
|
1914
|
+
parameters: {
|
|
1915
|
+
title: {
|
|
1916
|
+
type: "string",
|
|
1917
|
+
required: true,
|
|
1918
|
+
description: "Short human-readable label for the terminal tab (e.g. \"dev server\", \"python repl\")."
|
|
1919
|
+
},
|
|
1920
|
+
command: {
|
|
1921
|
+
type: "string",
|
|
1922
|
+
required: true,
|
|
1923
|
+
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."
|
|
1924
|
+
}
|
|
1925
|
+
},
|
|
1926
|
+
output: {
|
|
1927
|
+
schema: {
|
|
1928
|
+
type: "object",
|
|
1929
|
+
additionalProperties: false,
|
|
1930
|
+
properties: {
|
|
1931
|
+
uuid: {
|
|
1932
|
+
type: "string",
|
|
1933
|
+
required: true,
|
|
1934
|
+
description: "Opaque handle for the new terminal. Pass to terminal_send / terminal_read / terminal_resize / terminal_signal / terminal_close."
|
|
1935
|
+
},
|
|
1936
|
+
title: {
|
|
1937
|
+
type: "string",
|
|
1938
|
+
required: true,
|
|
1939
|
+
description: "The title you provided (echoed for confirmation)."
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
},
|
|
1943
|
+
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.`)
|
|
1944
|
+
},
|
|
1945
|
+
execute: (args, exec) => {
|
|
1946
|
+
exec.signal.throwIfAborted();
|
|
1947
|
+
const sessionId = sessionIdOf(exec);
|
|
1948
|
+
const cwd = resolveCwd(sessionId);
|
|
1949
|
+
const { shell, shellArgs } = readShellOverrides();
|
|
1950
|
+
const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24, shell, shellArgs);
|
|
1951
|
+
return Promise.resolve({
|
|
1952
|
+
uuid,
|
|
1953
|
+
title: args.title
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
}));
|
|
1957
|
+
register(defineTool({
|
|
1958
|
+
name: "terminal_list",
|
|
1959
|
+
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.",
|
|
1960
|
+
parameters: {},
|
|
1961
|
+
output: {
|
|
1962
|
+
schema: {
|
|
1963
|
+
type: "array",
|
|
1964
|
+
items: {
|
|
1965
|
+
type: "object",
|
|
1966
|
+
additionalProperties: false,
|
|
1967
|
+
properties: {
|
|
1968
|
+
uuid: {
|
|
1969
|
+
type: "string",
|
|
1970
|
+
required: true
|
|
1971
|
+
},
|
|
1972
|
+
title: {
|
|
1973
|
+
type: "string",
|
|
1974
|
+
required: true
|
|
1975
|
+
},
|
|
1976
|
+
command: {
|
|
1977
|
+
type: "string",
|
|
1978
|
+
required: true
|
|
1979
|
+
},
|
|
1980
|
+
exited: {
|
|
1981
|
+
type: "boolean",
|
|
1982
|
+
required: true
|
|
1983
|
+
},
|
|
1984
|
+
exitCode: { oneOf: [{ type: "integer" }, { type: "null" }] },
|
|
1985
|
+
exitSignal: { oneOf: [{ type: "string" }, { type: "null" }] }
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
},
|
|
1989
|
+
render: (_args, value) => {
|
|
1990
|
+
const list = value;
|
|
1991
|
+
if (list.length === 0) return [{
|
|
1992
|
+
type: "text",
|
|
1993
|
+
text: "No agent terminals open in this session."
|
|
1994
|
+
}];
|
|
1995
|
+
return [{
|
|
1996
|
+
type: "text",
|
|
1997
|
+
text: `Agent terminals in this session:\n${list.map((t) => {
|
|
1998
|
+
const status = t.exited ? `exited (code ${t.exitCode ?? "?"}, signal ${t.exitSignal ?? "none"})` : "running";
|
|
1999
|
+
return ` ${t.uuid} "${t.title}" [${status}] $ ${t.command}`;
|
|
2000
|
+
}).join("\n")}`
|
|
2001
|
+
}];
|
|
2002
|
+
}
|
|
2003
|
+
},
|
|
2004
|
+
execute: (_args, exec) => {
|
|
2005
|
+
const sessionId = sessionIdOf(exec);
|
|
2006
|
+
return Promise.resolve(registry.list(sessionId));
|
|
2007
|
+
}
|
|
2008
|
+
}));
|
|
2009
|
+
register(defineTool({
|
|
2010
|
+
name: "terminal_send",
|
|
2011
|
+
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.",
|
|
2012
|
+
parameters: {
|
|
2013
|
+
uuid: {
|
|
2014
|
+
type: "string",
|
|
2015
|
+
required: true,
|
|
2016
|
+
description: "Terminal uuid from terminal_create or terminal_list."
|
|
2017
|
+
},
|
|
2018
|
+
text: {
|
|
2019
|
+
type: "string",
|
|
2020
|
+
required: true,
|
|
2021
|
+
description: "UTF-8 text to write to the terminal stdin (verbatim, no shell escaping). Do not include trailing newlines — use the submit flag instead."
|
|
2022
|
+
},
|
|
2023
|
+
submit: {
|
|
2024
|
+
type: "boolean",
|
|
2025
|
+
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."
|
|
2026
|
+
}
|
|
2027
|
+
},
|
|
2028
|
+
output: {
|
|
2029
|
+
schema: {
|
|
2030
|
+
type: "object",
|
|
2031
|
+
additionalProperties: false,
|
|
2032
|
+
properties: {
|
|
2033
|
+
uuid: {
|
|
2034
|
+
type: "string",
|
|
2035
|
+
required: true
|
|
2036
|
+
},
|
|
2037
|
+
bytes: {
|
|
2038
|
+
type: "integer",
|
|
2039
|
+
required: true,
|
|
2040
|
+
description: "Number of UTF-8 bytes written (including the Enter key if submit was true)."
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
},
|
|
2044
|
+
render: textRender((v) => `Sent ${v.bytes} byte(s) to terminal ${v.uuid}.`)
|
|
2045
|
+
},
|
|
2046
|
+
execute: (args, exec) => {
|
|
2047
|
+
exec.signal.throwIfAborted();
|
|
2048
|
+
const sessionId = sessionIdOf(exec);
|
|
2049
|
+
registry.assertOwned(args.uuid, sessionId);
|
|
2050
|
+
const payload = args.submit === true ? `${args.text}\r` : args.text;
|
|
2051
|
+
registry.send(args.uuid, payload);
|
|
2052
|
+
return Promise.resolve({
|
|
2053
|
+
uuid: args.uuid,
|
|
2054
|
+
bytes: Buffer.byteLength(payload, "utf8")
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
}));
|
|
2058
|
+
register(defineTool({
|
|
2059
|
+
name: "terminal_read",
|
|
2060
|
+
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.",
|
|
2061
|
+
parameters: {
|
|
2062
|
+
uuid: {
|
|
2063
|
+
type: "string",
|
|
2064
|
+
required: true,
|
|
2065
|
+
description: "Terminal uuid from terminal_create or terminal_list."
|
|
2066
|
+
},
|
|
2067
|
+
offset: {
|
|
2068
|
+
type: "number",
|
|
2069
|
+
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)."
|
|
2070
|
+
},
|
|
2071
|
+
count: {
|
|
2072
|
+
type: "number",
|
|
2073
|
+
description: "Maximum lines to return (default 500, hard cap 500)."
|
|
2074
|
+
}
|
|
2075
|
+
},
|
|
2076
|
+
output: {
|
|
2077
|
+
schema: {
|
|
2078
|
+
type: "object",
|
|
2079
|
+
additionalProperties: false,
|
|
2080
|
+
properties: {
|
|
2081
|
+
text: {
|
|
2082
|
+
type: "string",
|
|
2083
|
+
required: true,
|
|
2084
|
+
description: "The slice of transcript for the requested page."
|
|
2085
|
+
},
|
|
2086
|
+
totalLines: {
|
|
2087
|
+
type: "integer",
|
|
2088
|
+
required: true,
|
|
2089
|
+
description: "Total lines in the retained transcript."
|
|
2090
|
+
},
|
|
2091
|
+
lineBegin: {
|
|
2092
|
+
type: "integer",
|
|
2093
|
+
required: true,
|
|
2094
|
+
description: "0-based index of the first line in `text` (inclusive)."
|
|
2095
|
+
},
|
|
2096
|
+
lineEnd: {
|
|
2097
|
+
type: "integer",
|
|
2098
|
+
required: true,
|
|
2099
|
+
description: "0-based index of the last line in `text` (exclusive)."
|
|
2100
|
+
},
|
|
2101
|
+
truncated: {
|
|
2102
|
+
type: "boolean",
|
|
2103
|
+
required: true,
|
|
2104
|
+
description: "Whether `text` was truncated to fit the 256 KiB read cap."
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
},
|
|
2108
|
+
render: (_args, value) => {
|
|
2109
|
+
const v = value;
|
|
2110
|
+
return [{
|
|
2111
|
+
type: "text",
|
|
2112
|
+
text: `${`[lines ${v.lineBegin}..${v.lineEnd} of ${v.totalLines}${v.truncated ? "; truncated to 256KiB" : ""}]`}\n${v.text}`
|
|
2113
|
+
}];
|
|
2114
|
+
}
|
|
2115
|
+
},
|
|
2116
|
+
execute: (args, exec) => {
|
|
2117
|
+
exec.signal.throwIfAborted();
|
|
2118
|
+
const sessionId = sessionIdOf(exec);
|
|
2119
|
+
registry.assertOwned(args.uuid, sessionId);
|
|
2120
|
+
const result = registry.read(args.uuid, args.offset, args.count);
|
|
2121
|
+
const bounded = boundBytes(result.text, READ_BYTE_LIMIT);
|
|
2122
|
+
return Promise.resolve({
|
|
2123
|
+
text: bounded.text,
|
|
2124
|
+
totalLines: result.totalLines,
|
|
2125
|
+
lineBegin: result.lineBegin,
|
|
2126
|
+
lineEnd: result.lineEnd,
|
|
2127
|
+
truncated: bounded.truncated
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
}));
|
|
2131
|
+
register(defineTool({
|
|
2132
|
+
name: "terminal_wait_for",
|
|
2133
|
+
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.",
|
|
2134
|
+
parameters: {
|
|
2135
|
+
uuid: {
|
|
2136
|
+
type: "string",
|
|
2137
|
+
required: true,
|
|
2138
|
+
description: "Terminal uuid from terminal_create or terminal_list."
|
|
2139
|
+
},
|
|
2140
|
+
needle: {
|
|
2141
|
+
type: "string",
|
|
2142
|
+
required: true,
|
|
2143
|
+
description: "Substring to wait for (case-sensitive, verbatim). Must be non-empty."
|
|
2144
|
+
},
|
|
2145
|
+
timeout_ms: {
|
|
2146
|
+
type: "number",
|
|
2147
|
+
description: "Maximum wait in milliseconds (default 10000, i.e. 10s). Clamped to a minimum of 100ms."
|
|
2148
|
+
}
|
|
2149
|
+
},
|
|
2150
|
+
output: {
|
|
2151
|
+
schema: { oneOf: [
|
|
2152
|
+
{
|
|
2153
|
+
type: "object",
|
|
2154
|
+
additionalProperties: false,
|
|
2155
|
+
properties: {
|
|
2156
|
+
kind: {
|
|
2157
|
+
type: "string",
|
|
2158
|
+
required: true,
|
|
2159
|
+
const: "found"
|
|
2160
|
+
},
|
|
2161
|
+
needle: {
|
|
2162
|
+
type: "string",
|
|
2163
|
+
required: true
|
|
2164
|
+
},
|
|
2165
|
+
line: {
|
|
2166
|
+
type: "integer",
|
|
2167
|
+
required: true,
|
|
2168
|
+
description: "0-based line index in the retained transcript where the needle first appeared."
|
|
2169
|
+
},
|
|
2170
|
+
column: {
|
|
2171
|
+
type: "integer",
|
|
2172
|
+
required: true,
|
|
2173
|
+
description: "0-based column index within that line where the match starts."
|
|
2174
|
+
},
|
|
2175
|
+
elapsedMs: {
|
|
2176
|
+
type: "integer",
|
|
2177
|
+
required: true,
|
|
2178
|
+
description: "Wall-clock milliseconds from wait start to match."
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
},
|
|
2182
|
+
{
|
|
2183
|
+
type: "object",
|
|
2184
|
+
additionalProperties: false,
|
|
2185
|
+
properties: {
|
|
2186
|
+
kind: {
|
|
2187
|
+
type: "string",
|
|
2188
|
+
required: true,
|
|
2189
|
+
const: "timeout"
|
|
2190
|
+
},
|
|
2191
|
+
needle: {
|
|
2192
|
+
type: "string",
|
|
2193
|
+
required: true
|
|
2194
|
+
},
|
|
2195
|
+
timeoutMs: {
|
|
2196
|
+
type: "integer",
|
|
2197
|
+
required: true,
|
|
2198
|
+
description: "The configured timeout that elapsed."
|
|
2199
|
+
},
|
|
2200
|
+
totalLines: {
|
|
2201
|
+
type: "integer",
|
|
2202
|
+
required: true,
|
|
2203
|
+
description: "Total lines retained when the timeout fired. Call terminal_read to inspect the tail."
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
},
|
|
2207
|
+
{
|
|
2208
|
+
type: "object",
|
|
2209
|
+
additionalProperties: false,
|
|
2210
|
+
properties: {
|
|
2211
|
+
kind: {
|
|
2212
|
+
type: "string",
|
|
2213
|
+
required: true,
|
|
2214
|
+
const: "exited"
|
|
2215
|
+
},
|
|
2216
|
+
needle: {
|
|
2217
|
+
type: "string",
|
|
2218
|
+
required: true
|
|
2219
|
+
},
|
|
2220
|
+
exitCode: {
|
|
2221
|
+
oneOf: [{ type: "integer" }, { type: "null" }],
|
|
2222
|
+
description: "Exit code, if known."
|
|
2223
|
+
},
|
|
2224
|
+
exitSignal: {
|
|
2225
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
2226
|
+
description: "Exit signal name, if killed by a signal."
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
] },
|
|
2231
|
+
render: (_args, value) => {
|
|
2232
|
+
const v = value;
|
|
2233
|
+
if (v.kind === "found") return [{
|
|
2234
|
+
type: "text",
|
|
2235
|
+
text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsedMs}ms).`
|
|
2236
|
+
}];
|
|
2237
|
+
if (v.kind === "timeout") return [{
|
|
2238
|
+
type: "text",
|
|
2239
|
+
text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.`
|
|
2240
|
+
}];
|
|
2241
|
+
const exitInfo = v.exitCode !== void 0 && v.exitCode !== null ? ` (exit code ${v.exitCode})` : "";
|
|
2242
|
+
return [{
|
|
2243
|
+
type: "text",
|
|
2244
|
+
text: `Terminal exited before "${v.needle}" appeared${exitInfo}.`
|
|
2245
|
+
}];
|
|
2246
|
+
}
|
|
2247
|
+
},
|
|
2248
|
+
async execute(args, exec) {
|
|
2249
|
+
exec.signal.throwIfAborted();
|
|
2250
|
+
const sessionId = sessionIdOf(exec);
|
|
2251
|
+
registry.assertOwned(args.uuid, sessionId);
|
|
2252
|
+
const timeoutMs = args.timeout_ms ?? 1e4;
|
|
2253
|
+
return await registry.waitFor(args.uuid, args.needle, timeoutMs, exec.signal);
|
|
2254
|
+
}
|
|
2255
|
+
}));
|
|
2256
|
+
register(defineTool({
|
|
2257
|
+
name: "terminal_resize",
|
|
2258
|
+
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.",
|
|
2259
|
+
parameters: {
|
|
2260
|
+
uuid: {
|
|
2261
|
+
type: "string",
|
|
2262
|
+
required: true,
|
|
2263
|
+
description: "Terminal uuid from terminal_create or terminal_list."
|
|
2264
|
+
},
|
|
2265
|
+
cols: {
|
|
2266
|
+
type: "integer",
|
|
2267
|
+
required: true,
|
|
2268
|
+
description: "New column count ( clamped to 2..1024 )."
|
|
2269
|
+
},
|
|
2270
|
+
rows: {
|
|
2271
|
+
type: "integer",
|
|
2272
|
+
required: true,
|
|
2273
|
+
description: "New row count ( clamped to 2..1024 )."
|
|
2274
|
+
}
|
|
2275
|
+
},
|
|
2276
|
+
output: {
|
|
2277
|
+
schema: {
|
|
2278
|
+
type: "object",
|
|
2279
|
+
additionalProperties: false,
|
|
2280
|
+
properties: {
|
|
2281
|
+
uuid: {
|
|
2282
|
+
type: "string",
|
|
2283
|
+
required: true
|
|
2284
|
+
},
|
|
2285
|
+
cols: {
|
|
2286
|
+
type: "integer",
|
|
2287
|
+
required: true
|
|
2288
|
+
},
|
|
2289
|
+
rows: {
|
|
2290
|
+
type: "integer",
|
|
2291
|
+
required: true
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
},
|
|
2295
|
+
render: textRender((v) => `Resized terminal ${v.uuid} to ${v.cols}×${v.rows}.`)
|
|
2296
|
+
},
|
|
2297
|
+
execute: (args, exec) => {
|
|
2298
|
+
exec.signal.throwIfAborted();
|
|
2299
|
+
const sessionId = sessionIdOf(exec);
|
|
2300
|
+
registry.assertOwned(args.uuid, sessionId);
|
|
2301
|
+
const dims = registry.resize(args.uuid, args.cols, args.rows);
|
|
2302
|
+
return Promise.resolve({
|
|
2303
|
+
uuid: args.uuid,
|
|
2304
|
+
...dims
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
}));
|
|
2308
|
+
register(defineTool({
|
|
2309
|
+
name: "terminal_signal",
|
|
2310
|
+
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.",
|
|
2311
|
+
parameters: {
|
|
2312
|
+
uuid: {
|
|
2313
|
+
type: "string",
|
|
2314
|
+
required: true,
|
|
2315
|
+
description: "Terminal uuid from terminal_create or terminal_list."
|
|
2316
|
+
},
|
|
2317
|
+
signal: {
|
|
2318
|
+
type: "string",
|
|
2319
|
+
required: true,
|
|
2320
|
+
enum: ALLOWED_SIGNALS,
|
|
2321
|
+
description: "Signal to deliver: SIGINT (Ctrl+C) | SIGTERM | SIGKILL | SIGHUP | SIGTSTP (Ctrl+Z)."
|
|
2322
|
+
}
|
|
2323
|
+
},
|
|
2324
|
+
output: {
|
|
2325
|
+
schema: {
|
|
2326
|
+
type: "object",
|
|
2327
|
+
additionalProperties: false,
|
|
2328
|
+
properties: {
|
|
2329
|
+
uuid: {
|
|
2330
|
+
type: "string",
|
|
2331
|
+
required: true
|
|
2332
|
+
},
|
|
2333
|
+
signal: {
|
|
2334
|
+
type: "string",
|
|
2335
|
+
required: true
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
},
|
|
2339
|
+
render: textRender((v) => `Sent ${v.signal} to terminal ${v.uuid}.`)
|
|
2340
|
+
},
|
|
2341
|
+
execute: (args, exec) => {
|
|
2342
|
+
exec.signal.throwIfAborted();
|
|
2343
|
+
const sessionId = sessionIdOf(exec);
|
|
2344
|
+
registry.assertOwned(args.uuid, sessionId);
|
|
2345
|
+
registry.signal(args.uuid, args.signal);
|
|
2346
|
+
return Promise.resolve({
|
|
2347
|
+
uuid: args.uuid,
|
|
2348
|
+
signal: args.signal
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
}));
|
|
2352
|
+
register(defineTool({
|
|
2353
|
+
name: "terminal_close",
|
|
2354
|
+
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.",
|
|
2355
|
+
parameters: { uuid: {
|
|
2356
|
+
type: "string",
|
|
2357
|
+
required: true,
|
|
2358
|
+
description: "Terminal uuid from terminal_create or terminal_list."
|
|
2359
|
+
} },
|
|
2360
|
+
output: {
|
|
2361
|
+
schema: {
|
|
2362
|
+
type: "object",
|
|
2363
|
+
additionalProperties: false,
|
|
2364
|
+
properties: {
|
|
2365
|
+
uuid: {
|
|
2366
|
+
type: "string",
|
|
2367
|
+
required: true
|
|
2368
|
+
},
|
|
2369
|
+
closed: {
|
|
2370
|
+
type: "boolean",
|
|
2371
|
+
required: true,
|
|
2372
|
+
description: "Whether a live terminal was actually dropped (false if the uuid was already gone)."
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
},
|
|
2376
|
+
render: textRender((v) => v.closed ? `Closed terminal ${v.uuid}.` : `Terminal ${v.uuid} was already closed.`)
|
|
2377
|
+
},
|
|
2378
|
+
execute: (args, exec) => {
|
|
2379
|
+
exec.signal.throwIfAborted();
|
|
2380
|
+
const sessionId = sessionIdOf(exec);
|
|
2381
|
+
registry.assertOwned(args.uuid, sessionId);
|
|
2382
|
+
const closed = registry.close(args.uuid);
|
|
2383
|
+
return Promise.resolve({
|
|
2384
|
+
uuid: args.uuid,
|
|
2385
|
+
closed
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
2388
|
+
}));
|
|
2389
|
+
return () => {
|
|
2390
|
+
for (const dispose of disposers) dispose();
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
//#endregion
|
|
2394
|
+
//#region src/jobs-routes.ts
|
|
2395
|
+
/**
|
|
2396
|
+
* Extract the plain text of a finalized tool result: the text blocks inside
|
|
2397
|
+
* the 'tool-result' block, joined with newlines. Error results and
|
|
2398
|
+
* non-text blocks contribute nothing.
|
|
2399
|
+
*/
|
|
2400
|
+
function resultText(message) {
|
|
2401
|
+
if (!Array.isArray(message.content)) return void 0;
|
|
2402
|
+
const parts = [];
|
|
2403
|
+
for (const block of message.content) {
|
|
2404
|
+
if (block === null || typeof block !== "object") continue;
|
|
2405
|
+
const candidate = block;
|
|
2406
|
+
if (candidate.type !== "tool-result") continue;
|
|
2407
|
+
const inner = candidate.content;
|
|
2408
|
+
if (!Array.isArray(inner)) continue;
|
|
2409
|
+
for (const item of inner) {
|
|
2410
|
+
if (item === null || typeof item !== "object") continue;
|
|
2411
|
+
const textItem = item;
|
|
2412
|
+
if (textItem.type === "text" && typeof textItem.text === "string") parts.push(textItem.text);
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
return parts.length > 0 ? parts.join("\n") : void 0;
|
|
2416
|
+
}
|
|
2417
|
+
/** Whether a tool/result is an error result (the inner block's isError flag). */
|
|
2418
|
+
function resultIsError(message) {
|
|
2419
|
+
if (!Array.isArray(message.content)) return false;
|
|
2420
|
+
return message.content.some((block) => {
|
|
2421
|
+
if (block === null || typeof block !== "object") return false;
|
|
2422
|
+
return block.type === "tool-result" && block.isError === true;
|
|
2423
|
+
});
|
|
2424
|
+
}
|
|
2425
|
+
/** Whether a job_output result carries no new output — the controller's
|
|
2426
|
+
* model-facing "(no new output)" body, noise for the human pane. */
|
|
2427
|
+
function isNoNewOutput(text) {
|
|
2428
|
+
return text.startsWith("(no new output)");
|
|
2429
|
+
}
|
|
2430
|
+
/** Extract the job_output trace of one raw session event (undefined = unrelated). */
|
|
2431
|
+
function traceOf(event) {
|
|
2432
|
+
if (event.type === "tool/call") {
|
|
2433
|
+
const data = event.data;
|
|
2434
|
+
if (data.name !== "job_output" || typeof data.callId !== "string") return void 0;
|
|
2435
|
+
let jobId;
|
|
2436
|
+
try {
|
|
2437
|
+
const args = JSON.parse(typeof data.arguments === "string" ? data.arguments : "");
|
|
2438
|
+
if (typeof args.job_id === "string") jobId = args.job_id;
|
|
2439
|
+
} catch {}
|
|
2440
|
+
if (jobId === void 0) return void 0;
|
|
2441
|
+
return {
|
|
2442
|
+
seq: event.seq,
|
|
2443
|
+
kind: "call",
|
|
2444
|
+
callId: data.callId,
|
|
2445
|
+
jobId
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
if (event.type === "tool/result") {
|
|
2449
|
+
const message = event.data.message;
|
|
2450
|
+
if (message === void 0) return void 0;
|
|
2451
|
+
const callId = message.source?.callId;
|
|
2452
|
+
if (typeof callId !== "string") return void 0;
|
|
2453
|
+
return {
|
|
2454
|
+
seq: event.seq,
|
|
2455
|
+
kind: "result",
|
|
2456
|
+
callId,
|
|
2457
|
+
text: resultText(message),
|
|
2458
|
+
isError: resultIsError(message)
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
/** Per-session cap of mirrored live traces (a bounded, lossy ring). */
|
|
2463
|
+
const MIRROR_MAX_ENTRIES = 200;
|
|
2464
|
+
/**
|
|
2465
|
+
* The live job_output mirror: subscribes to the session append feed and
|
|
2466
|
+
* caches the job_output traces the session store's own log can lag behind
|
|
2467
|
+
* (after a host restart the store session stays frozen at its rehydration
|
|
2468
|
+
* boundary, so `session.events` misses everything appended since — the very
|
|
2469
|
+
* reads the pane exists to show). Zero DSH writes: the api-proxy pushes the
|
|
2470
|
+
* same feed to browsers.
|
|
2471
|
+
*/
|
|
2472
|
+
function createJobOutputMirror(ctx) {
|
|
2473
|
+
const perSession = /* @__PURE__ */ new Map();
|
|
2474
|
+
const callIds = /* @__PURE__ */ new Map();
|
|
2475
|
+
if (typeof ctx.on !== "function") return { entries: () => [] };
|
|
2476
|
+
const dispose = ctx.on("session/event", (session, event) => {
|
|
2477
|
+
const sessionId = session?.id;
|
|
2478
|
+
if (typeof sessionId !== "string") return;
|
|
2479
|
+
if (event.type === "tool/call") {
|
|
2480
|
+
const trace = traceOf(event);
|
|
2481
|
+
if (trace?.kind !== "call") return;
|
|
2482
|
+
let ids = callIds.get(sessionId);
|
|
2483
|
+
if (ids === void 0) callIds.set(sessionId, ids = /* @__PURE__ */ new Set());
|
|
2484
|
+
ids.add(trace.callId);
|
|
2485
|
+
push(sessionId, trace);
|
|
2486
|
+
} else if (event.type === "tool/result") {
|
|
2487
|
+
const trace = traceOf(event);
|
|
2488
|
+
if (trace?.kind !== "result") return;
|
|
2489
|
+
if (!callIds.get(sessionId)?.has(trace.callId)) return;
|
|
2490
|
+
push(sessionId, trace);
|
|
2491
|
+
}
|
|
2492
|
+
});
|
|
2493
|
+
ctx.effect(() => dispose, "dsh-better-sidebar: job-output event mirror");
|
|
2494
|
+
const push = (sessionId, trace) => {
|
|
2495
|
+
let list = perSession.get(sessionId);
|
|
2496
|
+
if (list === void 0) perSession.set(sessionId, list = []);
|
|
2497
|
+
list.push(trace);
|
|
2498
|
+
if (list.length > MIRROR_MAX_ENTRIES) {
|
|
2499
|
+
const removed = list.splice(0, list.length - MIRROR_MAX_ENTRIES);
|
|
2500
|
+
const ids = callIds.get(sessionId);
|
|
2501
|
+
if (ids !== void 0) {
|
|
2502
|
+
for (const entry of removed) if (entry.kind === "call") ids.delete(entry.callId);
|
|
2503
|
+
if (ids.size === 0) callIds.delete(sessionId);
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
};
|
|
2507
|
+
return { entries: (sessionId) => perSession.get(sessionId) ?? [] };
|
|
2508
|
+
}
|
|
2509
|
+
/**
|
|
2510
|
+
* Build the jobs routes bound to the plugin context. `output` merges the
|
|
2511
|
+
* owner session's own event log with the live job_output mirror; `kill`
|
|
2512
|
+
* reads the jobs/agents services lazily and degrades to a 503 when the
|
|
2513
|
+
* deployment lacks the registry.
|
|
2514
|
+
* @param ctx - host plugin context.
|
|
2515
|
+
* @param outputLimit - response cap for one output replay in bytes; longer
|
|
2516
|
+
* texts are sliced and flagged `truncated` (mirrors the fs.read cap).
|
|
2517
|
+
*/
|
|
2518
|
+
function buildJobsApi(ctx, outputLimit) {
|
|
2519
|
+
const jobs = ctx.get("jobs");
|
|
2520
|
+
const agents = ctx.get("agents");
|
|
2521
|
+
const mirror = createJobOutputMirror(ctx);
|
|
2522
|
+
/** The live caller whose session id the registry fence compares against. */
|
|
2523
|
+
const callerOf = (sessionId) => agents?.get(sessionId);
|
|
2524
|
+
/** Registry refusals become a 404 job-error; unknown and foreign ids are indistinguishable. */
|
|
2525
|
+
const registryError = (error) => new SidebarError("job-error", error instanceof Error ? error.message : String(error), 404);
|
|
2526
|
+
return {
|
|
2527
|
+
output(payload) {
|
|
2528
|
+
const sessionId = requireString(payload, "sessionId");
|
|
2529
|
+
const id = requireString(payload, "id");
|
|
2530
|
+
const bySeq = /* @__PURE__ */ new Map();
|
|
2531
|
+
for (const event of ctx.sessions.get(sessionId)?.events ?? []) {
|
|
2532
|
+
const trace = traceOf(event);
|
|
2533
|
+
if (trace !== void 0) bySeq.set(trace.seq, trace);
|
|
2534
|
+
}
|
|
2535
|
+
for (const trace of mirror.entries(sessionId)) bySeq.set(trace.seq, trace);
|
|
2536
|
+
const jobOf = /* @__PURE__ */ new Map();
|
|
2537
|
+
const parts = [];
|
|
2538
|
+
let read = false;
|
|
2539
|
+
for (const trace of [...bySeq.values()].sort((left, right) => left.seq - right.seq)) if (trace.kind === "call") {
|
|
2540
|
+
if (trace.jobId !== void 0) jobOf.set(trace.callId, trace.jobId);
|
|
2541
|
+
} else if (jobOf.get(trace.callId) === id) {
|
|
2542
|
+
read = true;
|
|
2543
|
+
if (trace.isError !== true && trace.text !== void 0 && !isNoNewOutput(trace.text)) parts.push(trace.text);
|
|
2544
|
+
}
|
|
2545
|
+
const text = parts.join("\n");
|
|
2546
|
+
return {
|
|
2547
|
+
text: text.length > outputLimit ? text.slice(0, outputLimit) : text,
|
|
2548
|
+
truncated: text.length > outputLimit,
|
|
2549
|
+
read
|
|
2550
|
+
};
|
|
2551
|
+
},
|
|
2552
|
+
kill(payload) {
|
|
2553
|
+
if (jobs === void 0) throw new SidebarError("job-error", "the background-job registry is not mounted in this deployment", 503);
|
|
2554
|
+
const sessionId = requireString(payload, "sessionId");
|
|
2555
|
+
const id = requireString(payload, "id");
|
|
2556
|
+
const record = payload;
|
|
2557
|
+
const reason = typeof record?.reason === "string" && record.reason !== "" ? record.reason : "user requested via sidebar";
|
|
2558
|
+
try {
|
|
2559
|
+
return {
|
|
2560
|
+
ok: true,
|
|
2561
|
+
outcome: jobs.kill(id, callerOf(sessionId), reason)
|
|
2562
|
+
};
|
|
2563
|
+
} catch (error) {
|
|
2564
|
+
throw registryError(error);
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
};
|
|
2568
|
+
}
|
|
2569
|
+
//#endregion
|
|
2570
|
+
//#region src/sidechat-core.ts
|
|
2571
|
+
/** The durable thread-label prefix (also the row filter in the client list). */
|
|
2572
|
+
const SIDE_LABEL_PREFIX = "Side: ";
|
|
2573
|
+
/** The pinned label of a freshly created thread that no prompt has reached
|
|
2574
|
+
* yet (Codex-style immediate create: the tab opens an EMPTY thread, the
|
|
2575
|
+
* first composer message carries the boundary and earns the real label).
|
|
2576
|
+
* The client renders it localized; the prefix keeps the row filter honest. */
|
|
2577
|
+
const SIDE_NEW_THREAD_TITLE = "Side: New thread";
|
|
2578
|
+
/** The plugin identity stamped on the source of context-injection messages
|
|
2579
|
+
* (boundary prompt + parked snapshot), so the transcript recognizes them
|
|
2580
|
+
* structurally — not by text prefix. */
|
|
2581
|
+
const SIDE_INJECTION_PLUGIN = "dsh-better-sidebar";
|
|
2582
|
+
/**
|
|
2583
|
+
* The boundary prompt delivered as the thread's first user message: the
|
|
2584
|
+
* inherited seed is reference context only, never active instruction.
|
|
2585
|
+
* Model-facing contract — change only with intent, tests pin the sentences.
|
|
2586
|
+
*/
|
|
2587
|
+
const SIDE_BOUNDARY_PROMPT = `Side conversation boundary.
|
|
2588
|
+
|
|
2589
|
+
Everything before this boundary is inherited history from the parent session: its completed turns, its pending question, and — if the parent was mid-turn — its in-progress output frozen at the moment this side conversation started. It is reference context only. It is not your current task.
|
|
2590
|
+
|
|
2591
|
+
Do not continue, execute, or complete any instructions, plans, tool calls, approvals, edits, or requests from before this boundary. Only messages submitted after this boundary are active user instructions for this side conversation.
|
|
2592
|
+
|
|
2593
|
+
Mode: this is a continuable side conversation. Your answers stay in this side thread and are viewed in the side panel; they are never delivered into the parent session.`;
|
|
2594
|
+
/** The data record of one event (narrowed from the loose face). */
|
|
2595
|
+
function dataOf(event) {
|
|
2596
|
+
return event.data;
|
|
2597
|
+
}
|
|
2598
|
+
/** Copy parent events verbatim (their live seq === array index contract).
|
|
2599
|
+
* The FULL envelope is preserved — stripping `surfaceOp` would make the
|
|
2600
|
+
* seed validator reject every surface-eligible message event. */
|
|
2601
|
+
function copyEvents(events) {
|
|
2602
|
+
return events.map((event) => {
|
|
2603
|
+
const source = event;
|
|
2604
|
+
return {
|
|
2605
|
+
type: source.type,
|
|
2606
|
+
seq: source.seq,
|
|
2607
|
+
time: source.time,
|
|
2608
|
+
data: dataOf(source),
|
|
2609
|
+
...source.surfaceOp === void 0 ? {} : { surfaceOp: source.surfaceOp },
|
|
2610
|
+
...source.sourceEventSeqs === void 0 ? {} : { sourceEventSeqs: source.sourceEventSeqs },
|
|
2611
|
+
...source.ignorable === void 0 ? {} : { ignorable: source.ignorable }
|
|
2612
|
+
};
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
/** Index of the last `turn/start` or `turn/end`, or -1. */
|
|
2616
|
+
function lastTurnBoundary(events) {
|
|
2617
|
+
for (let index = events.length - 1; index >= 0; index--) {
|
|
2618
|
+
const type = events[index]?.type;
|
|
2619
|
+
if (type === "turn/start" || type === "turn/end") return index;
|
|
2620
|
+
}
|
|
2621
|
+
return -1;
|
|
2622
|
+
}
|
|
2623
|
+
/** Numeric field of an event's data (turn / step numbers). */
|
|
2624
|
+
function numberAt(data, key) {
|
|
2625
|
+
const value = data[key];
|
|
2626
|
+
return typeof value === "number" && Number.isSafeInteger(value) ? value : 0;
|
|
2627
|
+
}
|
|
2628
|
+
/** The step number still open at the log tail inside the turn starting at
|
|
2629
|
+
* `turnStart` (undefined when no step is open). */
|
|
2630
|
+
function openStepInTurn(events, turnStart) {
|
|
2631
|
+
let open;
|
|
2632
|
+
for (let index = turnStart + 1; index < events.length; index++) {
|
|
2633
|
+
const event = events[index];
|
|
2634
|
+
if (event === void 0) continue;
|
|
2635
|
+
if (event.type === "step/start") open = numberAt(dataOf(event), "step");
|
|
2636
|
+
else if (event.type === "step/end") open = void 0;
|
|
2637
|
+
}
|
|
2638
|
+
return open;
|
|
2639
|
+
}
|
|
2640
|
+
/**
|
|
2641
|
+
* Whether the open turn ending the log has a `tool/call` without its paired
|
|
2642
|
+
* `tool/result` in the CURRENT open step. Providers reject dangling
|
|
2643
|
+
* assistant calls, so such a turn cannot be honestly closed and the
|
|
2644
|
+
* inheritance must fall back to the snapshot.
|
|
2645
|
+
*/
|
|
2646
|
+
function hasDanglingToolCall(events, turnStart) {
|
|
2647
|
+
const pending = /* @__PURE__ */ new Set();
|
|
2648
|
+
for (let index = turnStart + 1; index < events.length; index++) {
|
|
2649
|
+
const event = events[index];
|
|
2650
|
+
if (event === void 0) continue;
|
|
2651
|
+
const data = dataOf(event);
|
|
2652
|
+
if (event.type === "step/end") {
|
|
2653
|
+
pending.clear();
|
|
2654
|
+
continue;
|
|
2655
|
+
}
|
|
2656
|
+
if (event.type === "tool/call") {
|
|
2657
|
+
const callId = data.callId;
|
|
2658
|
+
if (typeof callId === "string") pending.add(callId);
|
|
2659
|
+
continue;
|
|
2660
|
+
}
|
|
2661
|
+
if (event.type === "tool/result") {
|
|
2662
|
+
const callId = data.message?.source?.callId;
|
|
2663
|
+
if (typeof callId === "string") pending.delete(callId);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
return pending.size > 0;
|
|
2667
|
+
}
|
|
2668
|
+
/** The plain text of one tool/result message (text blocks inside its
|
|
2669
|
+
* `tool-result` content block). */
|
|
2670
|
+
function toolResultText(data) {
|
|
2671
|
+
const content = data.message?.content;
|
|
2672
|
+
if (!Array.isArray(content)) return "";
|
|
2673
|
+
const parts = [];
|
|
2674
|
+
for (const block of content) {
|
|
2675
|
+
if (block === null || typeof block !== "object") continue;
|
|
2676
|
+
const candidate = block;
|
|
2677
|
+
if (candidate.type !== "tool-result") continue;
|
|
2678
|
+
const inner = candidate.content;
|
|
2679
|
+
if (!Array.isArray(inner)) continue;
|
|
2680
|
+
for (const item of inner) {
|
|
2681
|
+
if (item === null || typeof item !== "object") continue;
|
|
2682
|
+
const textItem = item;
|
|
2683
|
+
if (textItem.type === "text" && typeof textItem.text === "string") parts.push(textItem.text);
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
return parts.join("\n");
|
|
2687
|
+
}
|
|
2688
|
+
/** Cap applied to one tool-result's text inside a snapshot (prompt budget). */
|
|
2689
|
+
const SNAPSHOT_RESULT_CAP = 2e3;
|
|
2690
|
+
/** Cap applied to the whole snapshot (prompt budget). */
|
|
2691
|
+
const SNAPSHOT_TOTAL_CAP = 8e3;
|
|
2692
|
+
/**
|
|
2693
|
+
* Build the side-thread inheritance for one parent log: the full event log
|
|
2694
|
+
* up to the click moment, honestly closed when it ends inside an open turn.
|
|
2695
|
+
*/
|
|
2696
|
+
function buildSidechatInheritance(events) {
|
|
2697
|
+
if (events.length === 0) return {
|
|
2698
|
+
seed: [],
|
|
2699
|
+
snapshot: null
|
|
2700
|
+
};
|
|
2701
|
+
const boundary = lastTurnBoundary(events);
|
|
2702
|
+
if (boundary < 0 || events[boundary]?.type === "turn/end") return {
|
|
2703
|
+
seed: copyEvents(events),
|
|
2704
|
+
snapshot: null
|
|
2705
|
+
};
|
|
2706
|
+
if (hasDanglingToolCall(events, boundary)) return {
|
|
2707
|
+
seed: copyEvents(events.slice(0, boundary)),
|
|
2708
|
+
snapshot: buildOpenTurnSnapshot(events)
|
|
2709
|
+
};
|
|
2710
|
+
const seed = copyEvents(events);
|
|
2711
|
+
const last = events[events.length - 1];
|
|
2712
|
+
const turn = numberAt(dataOf(events[boundary]), "turn");
|
|
2713
|
+
const now = last?.time ?? 0;
|
|
2714
|
+
const openStep = openStepInTurn(events, boundary);
|
|
2715
|
+
if (openStep !== void 0) seed.push({
|
|
2716
|
+
type: "step/end",
|
|
2717
|
+
seq: seed.length,
|
|
2718
|
+
time: now,
|
|
2719
|
+
data: {
|
|
2720
|
+
turn,
|
|
2721
|
+
step: openStep
|
|
2722
|
+
}
|
|
2723
|
+
});
|
|
2724
|
+
seed.push({
|
|
2725
|
+
type: "turn/end",
|
|
2726
|
+
seq: seed.length,
|
|
2727
|
+
time: now,
|
|
2728
|
+
data: {
|
|
2729
|
+
turn,
|
|
2730
|
+
reason: { kind: "interrupted" }
|
|
2731
|
+
}
|
|
2732
|
+
});
|
|
2733
|
+
return {
|
|
2734
|
+
seed,
|
|
2735
|
+
snapshot: null
|
|
2736
|
+
};
|
|
2737
|
+
}
|
|
2738
|
+
/**
|
|
2739
|
+
* Structured text snapshot of the parent's OPEN turn (from its `turn/start`
|
|
2740
|
+
* to the log tail): the accumulated assistant/reasoning output verbatim
|
|
2741
|
+
* (code blocks ride the raw deltas) and the tool activity — executed tools
|
|
2742
|
+
* with their result text, the still-executing one marked. Returns null when
|
|
2743
|
+
* there is no open turn or nothing to show.
|
|
2744
|
+
*/
|
|
2745
|
+
function buildOpenTurnSnapshot(events) {
|
|
2746
|
+
const boundary = lastTurnBoundary(events);
|
|
2747
|
+
if (boundary < 0 || events[boundary]?.type !== "turn/start") return null;
|
|
2748
|
+
let text = "";
|
|
2749
|
+
let reasoning = "";
|
|
2750
|
+
const tools = [];
|
|
2751
|
+
const pendingCalls = /* @__PURE__ */ new Map();
|
|
2752
|
+
let total = 0;
|
|
2753
|
+
for (let index = boundary + 1; index < events.length; index++) {
|
|
2754
|
+
const event = events[index];
|
|
2755
|
+
if (event === void 0) continue;
|
|
2756
|
+
const data = dataOf(event);
|
|
2757
|
+
if (event.type === "step/end") {
|
|
2758
|
+
pendingCalls.clear();
|
|
2759
|
+
continue;
|
|
2760
|
+
}
|
|
2761
|
+
if (event.type === "assistant/chunk") {
|
|
2762
|
+
const chunk = data.chunk;
|
|
2763
|
+
if (chunk === null || typeof chunk !== "object") continue;
|
|
2764
|
+
if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
|
|
2765
|
+
else if (chunk.type === "reasoning-delta" && typeof chunk.text === "string") reasoning += chunk.text;
|
|
2766
|
+
continue;
|
|
2767
|
+
}
|
|
2768
|
+
if (event.type === "tool/call") {
|
|
2769
|
+
const callId = data.callId;
|
|
2770
|
+
if (typeof callId === "string") pendingCalls.set(callId, {
|
|
2771
|
+
name: typeof data.name === "string" ? data.name : "tool",
|
|
2772
|
+
args: typeof data.arguments === "string" ? data.arguments : ""
|
|
2773
|
+
});
|
|
2774
|
+
continue;
|
|
2775
|
+
}
|
|
2776
|
+
if (event.type === "tool/result") {
|
|
2777
|
+
const source = data.message;
|
|
2778
|
+
const callId = typeof source?.source?.callId === "string" ? source.source.callId : void 0;
|
|
2779
|
+
const name = callId !== void 0 ? pendingCalls.get(callId)?.name : void 0;
|
|
2780
|
+
const args = callId !== void 0 ? pendingCalls.get(callId)?.args : void 0;
|
|
2781
|
+
if (callId !== void 0) pendingCalls.delete(callId);
|
|
2782
|
+
const result = toolResultText(data).slice(0, SNAPSHOT_RESULT_CAP);
|
|
2783
|
+
const failed = data.error !== void 0;
|
|
2784
|
+
const line = [`- \`${name ?? "tool"}\`${failed ? " (failed)" : ""}` + (args !== void 0 && args !== "" ? ` — arguments: \`${args}\`` : ""), ...result === "" ? [] : [` Result: ${result}`]].join("\n");
|
|
2785
|
+
tools.push(line);
|
|
2786
|
+
total += line.length;
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
for (const [, call] of pendingCalls) {
|
|
2790
|
+
const line = `- \`${call.name}\` (executing) — arguments: \`${call.args}\``;
|
|
2791
|
+
tools.push(line);
|
|
2792
|
+
total += line.length;
|
|
2793
|
+
}
|
|
2794
|
+
const sections = [];
|
|
2795
|
+
if (text.trim() !== "") sections.push(`Assistant output so far:\n\n${text}`);
|
|
2796
|
+
if (reasoning.trim() !== "") sections.push(`Reasoning so far:\n\n${reasoning}`);
|
|
2797
|
+
if (tools.length > 0) sections.push(`Tool activity:\n${tools.join("\n")}`);
|
|
2798
|
+
if (sections.length === 0) return null;
|
|
2799
|
+
const body = sections.join("\n\n");
|
|
2800
|
+
return body.length > SNAPSHOT_TOTAL_CAP ? `Parent session in-progress turn (reference only):\n\n${body.slice(0, SNAPSHOT_TOTAL_CAP)}…` : `Parent session in-progress turn (reference only):\n\n${body}`;
|
|
2801
|
+
}
|
|
2802
|
+
/** Truncate + prefix a question into a durable thread label. */
|
|
2803
|
+
function sideLabel(question) {
|
|
2804
|
+
const flat = question.replace(/\s+/g, " ").trim();
|
|
2805
|
+
const max = Math.max(1, 42);
|
|
2806
|
+
const body = flat.length > max ? `${flat.slice(0, 41)}…` : flat;
|
|
2807
|
+
return `${SIDE_LABEL_PREFIX}${body}`;
|
|
2808
|
+
}
|
|
2809
|
+
/**
|
|
2810
|
+
* Whether the thread log already carries the side boundary message — i.e.
|
|
2811
|
+
* the first prompt was delivered. Tolerant to the content shape (block
|
|
2812
|
+
* array or bare string) and to inherited seed messages (only an OWN
|
|
2813
|
+
* boundary message starts with the prefix; seed messages came from the
|
|
2814
|
+
* parent's log, which never contains one).
|
|
2815
|
+
*/
|
|
2816
|
+
function boundaryDelivered(events) {
|
|
2817
|
+
for (const event of events) {
|
|
2818
|
+
if (event.type !== "user/message") continue;
|
|
2819
|
+
if (messageLeadText(dataOf(event)).startsWith("Side conversation boundary")) return true;
|
|
2820
|
+
}
|
|
2821
|
+
return false;
|
|
2822
|
+
}
|
|
2823
|
+
/** The leading text of a user/message's content (block array or bare string). */
|
|
2824
|
+
function messageLeadText(data) {
|
|
2825
|
+
const content = data.content;
|
|
2826
|
+
const first = Array.isArray(content) ? content[0] : content;
|
|
2827
|
+
return typeof first === "string" ? first : typeof first === "object" && first !== null && "text" in first ? String(first.text) : "";
|
|
2828
|
+
}
|
|
2829
|
+
/**
|
|
2830
|
+
* The agent preset a session actually runs: newest `agent-preset/selected`
|
|
2831
|
+
* event wins, else the creation header (mirror of the dsh-agent-presets
|
|
2832
|
+
* resolveSessionPreset helper — replicated here to avoid a host dependency
|
|
2833
|
+
* on that package).
|
|
2834
|
+
*/
|
|
2835
|
+
function resolvePresetId(header, events) {
|
|
2836
|
+
for (let index = events.length - 1; index >= 0; index--) {
|
|
2837
|
+
const event = events[index];
|
|
2838
|
+
if (event?.type !== "agent-preset/selected") continue;
|
|
2839
|
+
const preset = dataOf(event).agentPreset;
|
|
2840
|
+
if (typeof preset === "string") return preset;
|
|
2841
|
+
}
|
|
2842
|
+
return header.agentPreset;
|
|
2843
|
+
}
|
|
2844
|
+
//#endregion
|
|
2845
|
+
//#region src/subagent-activity.ts
|
|
2846
|
+
/**
|
|
2847
|
+
* Extract the concatenated plain text of a content-block list (the durable
|
|
2848
|
+
* `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry
|
|
2849
|
+
* `text`; anything else — tool_use, image, … — contributes nothing).
|
|
2850
|
+
* @param content - the raw `content` field of a message event.
|
|
2851
|
+
* @returns the joined text, or undefined when the message carries no text.
|
|
2852
|
+
*/
|
|
2853
|
+
function contentText(content) {
|
|
2854
|
+
if (!Array.isArray(content)) return void 0;
|
|
2855
|
+
const parts = [];
|
|
2856
|
+
for (const block of content) {
|
|
2857
|
+
if (block === null || typeof block !== "object") continue;
|
|
2858
|
+
const candidate = block;
|
|
2859
|
+
if (candidate.type === "text" && typeof candidate.text === "string") parts.push(candidate.text);
|
|
2860
|
+
}
|
|
2861
|
+
return parts.length > 0 ? parts.join("\n") : void 0;
|
|
2862
|
+
}
|
|
2863
|
+
/**
|
|
2864
|
+
* Fold a session event log into the last text output + last tool call (each
|
|
2865
|
+
* is the LAST occurrence in event order). Lifecycle events and raw
|
|
2866
|
+
* `assistant/chunk` rows are ignored — the card shows what the subagent is
|
|
2867
|
+
* doing right now, not its plumbing. The scan runs BACKWARD from the newest
|
|
2868
|
+
* event and stops once both fields are found, so a long history costs only
|
|
2869
|
+
* the recent tail in the common case.
|
|
2870
|
+
* @param events - the session's append-only event log (oldest → newest).
|
|
2871
|
+
* @param maxMessages - optional message-boundary window: only the tail's
|
|
2872
|
+
* last `maxMessages` surface messages (`user/message`, `assistant/message`)
|
|
2873
|
+
* and the events between them are considered, mirroring the old
|
|
2874
|
+
* `subagents.history({ maxMessages })` window. Stale activity older than
|
|
2875
|
+
* the window is never surfaced, and a long log is never scanned in full.
|
|
2876
|
+
* @returns the last text and/or tool call; an empty object when the log has neither.
|
|
2877
|
+
*/
|
|
2878
|
+
function lastActivity(events, maxMessages = Infinity) {
|
|
2879
|
+
let text;
|
|
2880
|
+
let tool;
|
|
2881
|
+
let messagesSeen = 0;
|
|
2882
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
2883
|
+
if (text !== void 0 && tool !== void 0) break;
|
|
2884
|
+
const event = events[index];
|
|
2885
|
+
if (event === void 0) continue;
|
|
2886
|
+
const { type, data } = event;
|
|
2887
|
+
if (type === "user/message" || type === "assistant/message") {
|
|
2888
|
+
messagesSeen += 1;
|
|
2889
|
+
if (messagesSeen > maxMessages) break;
|
|
2890
|
+
} else if (messagesSeen >= maxMessages) continue;
|
|
2891
|
+
if (text === void 0 && type === "assistant/message") {
|
|
2892
|
+
const message = data.message;
|
|
2893
|
+
const extracted = contentText(message?.content);
|
|
2894
|
+
if (extracted !== void 0) text = extracted;
|
|
2895
|
+
} else if (tool === void 0 && type === "tool/call") tool = {
|
|
2896
|
+
name: typeof data.name === "string" ? data.name : "tool",
|
|
2897
|
+
args: typeof data.arguments === "string" ? data.arguments : ""
|
|
2898
|
+
};
|
|
2899
|
+
}
|
|
2900
|
+
if (text === void 0 && tool === void 0) return {};
|
|
2901
|
+
return {
|
|
2902
|
+
...text === void 0 ? {} : { text },
|
|
2903
|
+
...tool === void 0 ? {} : { tool }
|
|
2904
|
+
};
|
|
2905
|
+
}
|
|
2906
|
+
/**
|
|
2907
|
+
* Build the live-preview routes bound to the plugin context.
|
|
2908
|
+
* @param ctx - host plugin context.
|
|
2909
|
+
*/
|
|
2910
|
+
function buildSubagentLiveApi(ctx) {
|
|
2911
|
+
return { async live(payload) {
|
|
2912
|
+
const rootSessionId = requireString(payload, "rootSessionId");
|
|
2913
|
+
const subagents = ctx.get("subagents");
|
|
2914
|
+
if (subagents === void 0 || typeof subagents.listDescendants !== "function") throw new SidebarError("subagents-unavailable", "the subagent service is not mounted in this deployment", 503);
|
|
2915
|
+
let descendants;
|
|
2916
|
+
try {
|
|
2917
|
+
descendants = await subagents.listDescendants(rootSessionId);
|
|
2918
|
+
} catch (error) {
|
|
2919
|
+
throw new SidebarError("subagents-unavailable", `subagent catalog read failed: ${error instanceof Error ? error.message : String(error)}`, 503);
|
|
2920
|
+
}
|
|
2921
|
+
const live = {};
|
|
2922
|
+
for (const entry of descendants) {
|
|
2923
|
+
if (entry.kind !== "child" || entry.activity !== "running") continue;
|
|
2924
|
+
if (entry.label?.startsWith("Side: ") ?? false) continue;
|
|
2925
|
+
try {
|
|
2926
|
+
const activity = lastActivity(ctx.sessions.get(entry.id)?.events ?? [], 12);
|
|
2927
|
+
if (activity.text !== void 0 || activity.tool !== void 0) live[entry.id] = activity;
|
|
2928
|
+
} catch {}
|
|
2929
|
+
}
|
|
2930
|
+
return { live };
|
|
2931
|
+
} };
|
|
2932
|
+
}
|
|
2933
|
+
//#endregion
|
|
2934
|
+
//#region src/sidechat-routes.ts
|
|
2935
|
+
/**
|
|
2936
|
+
* Side Chat routes of the /sidebar JSON API ('sidechat.start' /
|
|
2937
|
+
* 'sidechat.prompt' / 'sidechat.cancel' / 'sidechat.dispose').
|
|
2938
|
+
*
|
|
2939
|
+
* A side thread is a child session the plugin creates ITSELF with a custom
|
|
2940
|
+
* seed — the parent's full event log up to the click moment, honestly closed
|
|
2941
|
+
* at an in-progress turn (see sidechat-core.ts). The child is marked
|
|
2942
|
+
* `origin: 'subagent'` so the main session list hides it, and EVERY
|
|
2943
|
+
* operation goes through these routes because the generic session RPCs are
|
|
2944
|
+
* fenced away from subagent-origin identities (the api-remotes
|
|
2945
|
+
* agent-lookup ownership fence). No DSH source is touched:
|
|
2946
|
+
*
|
|
2947
|
+
* - creation uses the public AgentRegistry.create seam (the same one
|
|
2948
|
+
* api-proxy's session.fork and the subagent fork provider use), with the
|
|
2949
|
+
* parent's preset composition and provider/model selection so the child's
|
|
2950
|
+
* first request shares the parent's token prefix (provider-side prefix
|
|
2951
|
+
* cache reuse);
|
|
2952
|
+
* - the first prompt (boundary + question) and every follow-up are admitted
|
|
2953
|
+
* with the stock `agent.followup`;
|
|
2954
|
+
* - a cold thread (DSH restart, or a closed thread) is resumed with
|
|
2955
|
+
* AgentRegistry.resume, composing the preset the child recorded.
|
|
2956
|
+
*/
|
|
2957
|
+
/** Timeout guarding the create call (the registry detaches it before the
|
|
2958
|
+
* handle becomes visible, so the child is never cancelled by it). */
|
|
2959
|
+
const CREATE_TIMEOUT_MS = 15e3;
|
|
2960
|
+
/** Per-activation disposers of created thread agents (the dispose route
|
|
2961
|
+
* releases them; the session and its history always stay persisted). */
|
|
2962
|
+
const threadDisposers = /* @__PURE__ */ new Map();
|
|
2963
|
+
/** The in-progress-turn snapshot captured at creation of an EMPTY thread,
|
|
2964
|
+
* waiting to ride the first prompt (lost on a host restart — the boundary
|
|
2965
|
+
* prompt is then delivered alone, a logged degradation). */
|
|
2966
|
+
const pendingSnapshots = /* @__PURE__ */ new Map();
|
|
2967
|
+
/** Resolve the parent's preset and build the child's composition setup
|
|
2968
|
+
* (mirror of api-proxy's composeAgent minus the model-selection install —
|
|
2969
|
+
* the child carries the parent's provider/model in agentOptions). */
|
|
2970
|
+
async function composeChildSetup(ctx, presetId) {
|
|
2971
|
+
const presets = ctx.get("agentPresets");
|
|
2972
|
+
if (presets === void 0) return { setup: () => Promise.resolve() };
|
|
2973
|
+
const resolved = await presets.resolve(presetId);
|
|
2974
|
+
return {
|
|
2975
|
+
agentPreset: resolved.id,
|
|
2976
|
+
setup: async (agentCtx) => {
|
|
2977
|
+
await presets.mount(agentCtx, resolved.id);
|
|
2978
|
+
}
|
|
2979
|
+
};
|
|
2980
|
+
}
|
|
2981
|
+
/** Build the cold-resume setup from the thread's PERSISTED record (the
|
|
2982
|
+
* recorded preset wins, newest selection event first). */
|
|
2983
|
+
async function composePersistedSetup(ctx, childId) {
|
|
2984
|
+
const persistence = ctx.get("sessionPersistence");
|
|
2985
|
+
if (persistence === void 0) return () => Promise.resolve();
|
|
2986
|
+
const inspected = await persistence.inspect(childId);
|
|
2987
|
+
const presetId = resolvePresetId(inspected.meta, inspected.events);
|
|
2988
|
+
const presets = ctx.get("agentPresets");
|
|
2989
|
+
if (presets === void 0 || presetId === void 0) return () => Promise.resolve();
|
|
2990
|
+
const resolved = await presets.resolve(presetId);
|
|
2991
|
+
return async (agentCtx) => {
|
|
2992
|
+
await presets.mount(agentCtx, resolved.id);
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
/** One text-block prompt (the thread boundary + question, or a follow-up). */
|
|
2996
|
+
function textPrompt(text) {
|
|
2997
|
+
return [{
|
|
2998
|
+
type: "text",
|
|
2999
|
+
text
|
|
3000
|
+
}];
|
|
3001
|
+
}
|
|
3002
|
+
/** Admit one user message to a live agent through the stock followup path. */
|
|
3003
|
+
function admitFollowup(agent, blocks) {
|
|
3004
|
+
const message = createUserMessage({
|
|
3005
|
+
content: blocks,
|
|
3006
|
+
source: { kind: "user" }
|
|
3007
|
+
});
|
|
3008
|
+
agent.followup(message);
|
|
3009
|
+
}
|
|
3010
|
+
/**
|
|
3011
|
+
* Deliver the thread's FIRST contact as TWO log-separated messages: the
|
|
3012
|
+
* boundary prompt (+ the parked in-progress snapshot) rides `agent.inject`
|
|
3013
|
+
* — queued model-facing context that does NOT wake the driver and is
|
|
3014
|
+
* claimed FIRST at the opening step (Inbox.claim drains next-step before
|
|
3015
|
+
* next-turn) — and the user's question is the follow-up that wakes it. The
|
|
3016
|
+
* log therefore records two user/message events (injection, then question)
|
|
3017
|
+
* instead of one wrapped blob: the transcript shows the question as a user
|
|
3018
|
+
* bubble and collapses the injection as a context row. The injection source
|
|
3019
|
+
* is stamped `kind: 'plugin'` so recognition is structural; its text still
|
|
3020
|
+
* opens with SIDE_BOUNDARY_PREFIX, keeping boundaryDelivered intact.
|
|
3021
|
+
*/
|
|
3022
|
+
function admitFirstContact(agent, injectionText, question) {
|
|
3023
|
+
agent.inject(createUserMessage({
|
|
3024
|
+
content: textPrompt(injectionText),
|
|
3025
|
+
source: {
|
|
3026
|
+
kind: "plugin",
|
|
3027
|
+
plugin: SIDE_INJECTION_PLUGIN
|
|
3028
|
+
}
|
|
3029
|
+
}));
|
|
3030
|
+
admitFollowup(agent, textPrompt(question));
|
|
3031
|
+
}
|
|
3032
|
+
/** The live thread agent, or undefined (cold — the caller resumes). */
|
|
3033
|
+
function liveThreadAgent(ctx, childId) {
|
|
3034
|
+
return ctx.get("agents")?.get(childId);
|
|
3035
|
+
}
|
|
3036
|
+
/** Build the Side Chat routes (all optional services degrade to a wire
|
|
3037
|
+
* error the tab surfaces inline). The record keys are the FULL wire method
|
|
3038
|
+
* names the /sidebar/api dispatcher looks up (`api[method]`). */
|
|
3039
|
+
function buildSidechatApi(ctx) {
|
|
3040
|
+
return {
|
|
3041
|
+
"sidechat.start": async (payload) => {
|
|
3042
|
+
const sessionId = requireString(payload, "sessionId");
|
|
3043
|
+
const rawQuestion = payload.question;
|
|
3044
|
+
const question = typeof rawQuestion === "string" ? rawQuestion.trim() : "";
|
|
3045
|
+
const parent = liveThreadAgent(ctx, sessionId);
|
|
3046
|
+
if (parent === void 0) throw new SidebarError("sidechat-error", `parent session "${sessionId}" is not running`, 409);
|
|
3047
|
+
const parentSession = parent.session;
|
|
3048
|
+
const inheritance = buildSidechatInheritance(parentSession.events);
|
|
3049
|
+
const { agentPreset, setup } = await composeChildSetup(ctx, resolvePresetId(parentSession.header, parentSession.events));
|
|
3050
|
+
const childId = `session-${randomUUID()}`;
|
|
3051
|
+
const label = question === "" ? SIDE_NEW_THREAD_TITLE : sideLabel(question);
|
|
3052
|
+
const descriptor = snapshotSubagentDescriptor({
|
|
3053
|
+
mode: "continuable",
|
|
3054
|
+
provider: "sidechat",
|
|
3055
|
+
label,
|
|
3056
|
+
...parent.options.provider === void 0 ? {} : { agentProvider: parent.options.provider },
|
|
3057
|
+
...parent.options.model === void 0 ? {} : { agentModel: parent.options.model }
|
|
3058
|
+
});
|
|
3059
|
+
const descriptorEvent = {
|
|
3060
|
+
type: "subagent/descriptor",
|
|
3061
|
+
seq: inheritance.seed.length,
|
|
3062
|
+
time: Date.now(),
|
|
3063
|
+
data: descriptor
|
|
3064
|
+
};
|
|
3065
|
+
const seed = [...inheritance.seed, descriptorEvent];
|
|
3066
|
+
const options = {
|
|
3067
|
+
sessionId: childId,
|
|
3068
|
+
meta: {
|
|
3069
|
+
...parentSession.header.cwd === void 0 ? {} : { cwd: parentSession.header.cwd },
|
|
3070
|
+
parentSession: parentSession.id,
|
|
3071
|
+
seedLength: seed.length,
|
|
3072
|
+
origin: "subagent",
|
|
3073
|
+
delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1,
|
|
3074
|
+
...agentPreset === void 0 ? {} : { agentPreset }
|
|
3075
|
+
},
|
|
3076
|
+
seed,
|
|
3077
|
+
agentOptions: { ...parent.options },
|
|
3078
|
+
setup,
|
|
3079
|
+
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS)
|
|
3080
|
+
};
|
|
3081
|
+
const agents = ctx.get("agents");
|
|
3082
|
+
if (agents?.create === void 0) throw new SidebarError("sidechat-error", "the agents service is unavailable", 503);
|
|
3083
|
+
let handle;
|
|
3084
|
+
try {
|
|
3085
|
+
handle = await agents.create(options);
|
|
3086
|
+
} catch (error) {
|
|
3087
|
+
throw new SidebarError("sidechat-error", `thread creation failed: ${error instanceof Error ? error.message : String(error)}`, 500);
|
|
3088
|
+
}
|
|
3089
|
+
threadDisposers.set(childId, () => handle.dispose());
|
|
3090
|
+
const titles = ctx.get("sessionTitle");
|
|
3091
|
+
const pinTitle = (label) => {
|
|
3092
|
+
if (titles === void 0) return;
|
|
3093
|
+
try {
|
|
3094
|
+
titles.rename(handle.agent.session, label);
|
|
3095
|
+
} catch {}
|
|
3096
|
+
};
|
|
3097
|
+
if (question === "") {
|
|
3098
|
+
if (inheritance.snapshot !== null) pendingSnapshots.set(childId, inheritance.snapshot);
|
|
3099
|
+
pinTitle(SIDE_NEW_THREAD_TITLE);
|
|
3100
|
+
} else {
|
|
3101
|
+
const promptParts = [SIDE_BOUNDARY_PROMPT];
|
|
3102
|
+
if (inheritance.snapshot !== null) promptParts.push(inheritance.snapshot);
|
|
3103
|
+
admitFirstContact(handle.agent, promptParts.join("\n\n"), question);
|
|
3104
|
+
pinTitle(sideLabel(question));
|
|
3105
|
+
}
|
|
3106
|
+
return { childId };
|
|
3107
|
+
},
|
|
3108
|
+
"sidechat.prompt": async (payload) => {
|
|
3109
|
+
const childId = requireString(payload, "childId");
|
|
3110
|
+
const text = requireString(payload, "text").trim();
|
|
3111
|
+
if (text === "") throw new SidebarError("bad-request", "text is required");
|
|
3112
|
+
let agent = liveThreadAgent(ctx, childId);
|
|
3113
|
+
if (agent === void 0) {
|
|
3114
|
+
const agents = ctx.get("agents");
|
|
3115
|
+
if (agents?.resume === void 0) throw new SidebarError("sidechat-error", "the agents service is unavailable", 503);
|
|
3116
|
+
const setup = await composePersistedSetup(ctx, childId);
|
|
3117
|
+
try {
|
|
3118
|
+
const handle = await agents.resume({
|
|
3119
|
+
resumeSessionId: childId,
|
|
3120
|
+
setup
|
|
3121
|
+
});
|
|
3122
|
+
threadDisposers.set(childId, () => handle.dispose());
|
|
3123
|
+
agent = handle.agent;
|
|
3124
|
+
} catch (error) {
|
|
3125
|
+
throw new SidebarError("sidechat-error", `thread resume failed: ${error instanceof Error ? error.message : String(error)}`, 500);
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
if (boundaryDelivered(agent.session.events)) admitFollowup(agent, textPrompt(text));
|
|
3129
|
+
else {
|
|
3130
|
+
const parts = [SIDE_BOUNDARY_PROMPT];
|
|
3131
|
+
const snapshot = pendingSnapshots.get(childId);
|
|
3132
|
+
pendingSnapshots.delete(childId);
|
|
3133
|
+
if (snapshot !== void 0) parts.push(snapshot);
|
|
3134
|
+
admitFirstContact(agent, parts.join("\n\n"), text);
|
|
3135
|
+
const titles = ctx.get("sessionTitle");
|
|
3136
|
+
if (titles !== void 0) try {
|
|
3137
|
+
titles.rename(agent.session, sideLabel(text));
|
|
3138
|
+
} catch {}
|
|
3139
|
+
}
|
|
3140
|
+
return { accepted: true };
|
|
3141
|
+
},
|
|
3142
|
+
"sidechat.cancel": async (payload) => {
|
|
3143
|
+
const agent = liveThreadAgent(ctx, requireString(payload, "childId"));
|
|
3144
|
+
if (agent !== void 0) agent.cancel({ kind: "user" }, { keepInbox: true });
|
|
3145
|
+
return { accepted: true };
|
|
3146
|
+
},
|
|
3147
|
+
"sidechat.dispose": async (payload) => {
|
|
3148
|
+
const childId = requireString(payload, "childId");
|
|
3149
|
+
pendingSnapshots.delete(childId);
|
|
3150
|
+
const dispose = threadDisposers.get(childId);
|
|
3151
|
+
if (dispose !== void 0) {
|
|
3152
|
+
threadDisposers.delete(childId);
|
|
3153
|
+
try {
|
|
3154
|
+
await dispose();
|
|
3155
|
+
} catch {}
|
|
3156
|
+
}
|
|
3157
|
+
return { accepted: true };
|
|
3158
|
+
},
|
|
3159
|
+
"sidechat.info": async (payload) => {
|
|
3160
|
+
const childId = requireString(payload, "childId");
|
|
3161
|
+
const agent = liveThreadAgent(ctx, childId);
|
|
3162
|
+
if (agent !== void 0) {
|
|
3163
|
+
const preset = agent.session.header.agentPreset;
|
|
3164
|
+
return {
|
|
3165
|
+
live: true,
|
|
3166
|
+
status: agent.status,
|
|
3167
|
+
...agent.options.provider === void 0 ? {} : { provider: agent.options.provider },
|
|
3168
|
+
...agent.options.model === void 0 ? {} : { model: agent.options.model },
|
|
3169
|
+
...preset === void 0 ? {} : { preset }
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
const persistence = ctx.get("sessionPersistence");
|
|
3173
|
+
if (persistence !== void 0) try {
|
|
3174
|
+
const inspected = await persistence.inspect(childId);
|
|
3175
|
+
const preset = resolvePresetId(inspected.meta, inspected.events);
|
|
3176
|
+
return {
|
|
3177
|
+
live: false,
|
|
3178
|
+
...preset === void 0 ? {} : { preset }
|
|
3179
|
+
};
|
|
3180
|
+
} catch {}
|
|
3181
|
+
return { live: false };
|
|
3182
|
+
}
|
|
3183
|
+
};
|
|
3184
|
+
}
|
|
3185
|
+
//#endregion
|
|
3186
|
+
//#region src/index.ts
|
|
3187
|
+
/**
|
|
3188
|
+
* dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
|
|
3189
|
+
* read/write, git), the /sidebar/file media route (images), the /sidebar/html
|
|
3190
|
+
* preview route, the /sidebar/bundle lazy-chunk route (client code splits),
|
|
3191
|
+
* and the terminal WebSocket upgrade. Every route passes the same
|
|
3192
|
+
* browser-trust fence as the /api gateway — Host-header loopback or the
|
|
3193
|
+
* web runtime's `trustedHosts` (LAN IP literals sampled at boot plus
|
|
3194
|
+
* `--trusted-host` authorities), read per request from the live service
|
|
3195
|
+
* value so the fence tracks the same trust source the /api gateway derives
|
|
3196
|
+
* its list from.
|
|
3197
|
+
*
|
|
3198
|
+
* All operations are conversation-scoped: requests carry a sessionId, the
|
|
3199
|
+
* session's authoritative cwd comes from the session store, and terminal
|
|
3200
|
+
* processes are keyed by session.
|
|
3201
|
+
*/
|
|
3202
|
+
/** Plugin identity for cordis.yml rows. */
|
|
3203
|
+
const name = "dsh-better-sidebar";
|
|
3204
|
+
/** Services required before mounting: the webserver routes, the session store, the web runtime's trusted hosts, and the tool registry. */
|
|
3205
|
+
const inject = [
|
|
3206
|
+
"webServer",
|
|
3207
|
+
"sessions",
|
|
3208
|
+
"webRuntime",
|
|
3209
|
+
"tools"
|
|
3210
|
+
];
|
|
3211
|
+
/** Content types for the media route, by extension. */
|
|
3212
|
+
const MEDIA_TYPES = {
|
|
3213
|
+
".png": "image/png",
|
|
3214
|
+
".jpg": "image/jpeg",
|
|
3215
|
+
".jpeg": "image/jpeg",
|
|
3216
|
+
".gif": "image/gif",
|
|
3217
|
+
".webp": "image/webp",
|
|
3218
|
+
".svg": "image/svg+xml",
|
|
3219
|
+
".bmp": "image/bmp",
|
|
3220
|
+
".ico": "image/x-icon",
|
|
3221
|
+
".avif": "image/avif",
|
|
3222
|
+
".pdf": "application/pdf",
|
|
3223
|
+
".html": "text/html",
|
|
3224
|
+
".htm": "text/html"
|
|
3225
|
+
};
|
|
3226
|
+
/** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
|
|
3227
|
+
function mediaTypeForPath(path) {
|
|
3228
|
+
return MEDIA_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
3229
|
+
}
|
|
3230
|
+
/**
|
|
3231
|
+
* Resolve a session's authoritative working directory. The attached session
|
|
3232
|
+
* header wins; while the session is still hydrating from persistence (the
|
|
3233
|
+
* web client attaches the current conversation a moment after page load, so
|
|
3234
|
+
* the very first sidebar requests can arrive detached) the caller's own
|
|
3235
|
+
* list-summary cwd is used; the process cwd is the last resort (blank
|
|
3236
|
+
* sessions have no cwd anywhere yet). Never throws for a missing cwd, so
|
|
3237
|
+
* explorer/git/terminal work from first paint instead of surfacing
|
|
3238
|
+
* "session ... has no working directory".
|
|
3239
|
+
*/
|
|
3240
|
+
function sessionCwdOf(ctx, sessionId, clientCwd) {
|
|
3241
|
+
const headerCwd = ctx.sessions.get(sessionId)?.header.cwd;
|
|
3242
|
+
if (headerCwd !== void 0 && headerCwd !== "") return headerCwd;
|
|
3243
|
+
if (clientCwd !== void 0 && clientCwd !== "") try {
|
|
3244
|
+
return requireAbsolute(clientCwd);
|
|
3245
|
+
} catch {
|
|
3246
|
+
throw new SidebarError("bad-request", `invalid working directory "${clientCwd}"`);
|
|
3247
|
+
}
|
|
3248
|
+
return process.cwd();
|
|
3249
|
+
}
|
|
3250
|
+
/**
|
|
3251
|
+
* Resolve a path that a git command reported — `git status`/`git diff`
|
|
3252
|
+
* print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the
|
|
3253
|
+
* session cwd (a session inside a subdirectory of a repository). Absolute
|
|
3254
|
+
* paths pass through; relative ones join the repo root (falling back to the
|
|
3255
|
+
* cwd when the root cannot be resolved, e.g. a bare directory).
|
|
3256
|
+
*/
|
|
3257
|
+
async function resolveGitPath(cwd, raw) {
|
|
3258
|
+
if (isAbsolute(raw)) return requireAbsolute(raw);
|
|
3259
|
+
const root = await repoRoot(cwd).catch(() => cwd);
|
|
3260
|
+
return requireAbsolute(join(root, raw));
|
|
3261
|
+
}
|
|
3262
|
+
/** How many leading bytes a binary read returns for client-side detect sniffing. */
|
|
3263
|
+
const READ_HEAD_LIMIT = 4096;
|
|
3264
|
+
/** Text read of a file with the size cap; binary detection via NUL probe.
|
|
3265
|
+
* Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64)
|
|
3266
|
+
* so the client can re-match viewers by content (`detect`). */
|
|
3267
|
+
async function readText(path, readLimit) {
|
|
3268
|
+
const info = await stat(path).catch((error) => {
|
|
3269
|
+
throw new SidebarError("fs-error", `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
3270
|
+
});
|
|
3271
|
+
if (info.isDirectory()) throw new SidebarError("fs-error", `"${path}" is a directory`, 400);
|
|
3272
|
+
const size = info.size;
|
|
3273
|
+
const truncated = size > readLimit;
|
|
3274
|
+
const handle = await open(path, "r").catch((error) => {
|
|
3275
|
+
throw new SidebarError("fs-error", `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
3276
|
+
});
|
|
3277
|
+
try {
|
|
3278
|
+
const buffer = Buffer.alloc(Math.min(size, readLimit));
|
|
3279
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
3280
|
+
const slice = buffer.subarray(0, bytesRead);
|
|
3281
|
+
const binary = slice.includes(0);
|
|
3282
|
+
const head = binary ? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString("base64") : void 0;
|
|
3283
|
+
return {
|
|
3284
|
+
content: binary ? "" : slice.toString("utf8"),
|
|
3285
|
+
truncated,
|
|
3286
|
+
binary,
|
|
3287
|
+
size,
|
|
3288
|
+
head
|
|
3289
|
+
};
|
|
3290
|
+
} finally {
|
|
3291
|
+
await handle.close();
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, resolved config, and effective terminal shell. */
|
|
3295
|
+
/**
|
|
3296
|
+
* Resolve the settings-page terminal shell overrides (the terminal card's
|
|
3297
|
+
* gear rows). Empty fields mean "unset": keep the yaml `config.shell` /
|
|
3298
|
+
* `shellArgs` (or the platform auto-resolution). The settings page is the
|
|
3299
|
+
* runtime complement to the boot-time yaml — same contract, later binding:
|
|
3300
|
+
* the values here win for terminals opened afterwards.
|
|
3301
|
+
*/
|
|
3302
|
+
function shellOverridesOf(getSettings) {
|
|
3303
|
+
const value = getSettings()?.get().value;
|
|
3304
|
+
if (value === null || typeof value !== "object") return {};
|
|
3305
|
+
const record = value;
|
|
3306
|
+
const shell = typeof record.terminalShell === "string" ? record.terminalShell.trim() : "";
|
|
3307
|
+
const args = typeof record.terminalShellArgs === "string" ? record.terminalShellArgs.trim() : "";
|
|
3308
|
+
return {
|
|
3309
|
+
shell: shell === "" ? void 0 : shell,
|
|
3310
|
+
shellArgs: args === "" ? void 0 : args.split(/\s+/).filter(Boolean)
|
|
3311
|
+
};
|
|
3312
|
+
}
|
|
3313
|
+
function buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, getSettings) {
|
|
3314
|
+
const cwdOf = (payload) => {
|
|
3315
|
+
const sessionId = requireString(payload, "sessionId");
|
|
3316
|
+
const record = payload;
|
|
3317
|
+
return {
|
|
3318
|
+
sessionId,
|
|
3319
|
+
cwd: sessionCwdOf(ctx, sessionId, typeof record?.cwd === "string" && record.cwd !== "" ? record.cwd : void 0)
|
|
3320
|
+
};
|
|
3321
|
+
};
|
|
3322
|
+
const editableSessions = /* @__PURE__ */ new Set();
|
|
3323
|
+
const requireWritable = (sessionId) => {
|
|
3324
|
+
if (!editableSessions.has(sessionId)) throw new SidebarError("forbidden", "read-only mode: switch the editor to \"editable\" to modify files", 403);
|
|
3325
|
+
};
|
|
3326
|
+
const jobsApi = buildJobsApi(ctx, resolved.readLimit);
|
|
3327
|
+
const subagentLiveApi = buildSubagentLiveApi(ctx);
|
|
3328
|
+
return {
|
|
3329
|
+
"session.cwd": (payload) => {
|
|
3330
|
+
const { sessionId, cwd } = cwdOf(payload);
|
|
3331
|
+
return {
|
|
3332
|
+
sessionId,
|
|
3333
|
+
cwd,
|
|
3334
|
+
root: rootLabel(cwd),
|
|
3335
|
+
parent: parentOf(cwd) ?? null
|
|
3336
|
+
};
|
|
3337
|
+
},
|
|
3338
|
+
"fs.tree": async (payload) => {
|
|
3339
|
+
const { cwd } = cwdOf(payload);
|
|
3340
|
+
return listDirectory(payload.path === void 0 ? cwd : requireAbsolute(requireString(payload, "path")), resolved.listLimit);
|
|
3341
|
+
},
|
|
3342
|
+
"fs.search": async (payload) => {
|
|
3343
|
+
const { cwd } = cwdOf(payload);
|
|
3344
|
+
return searchFiles(cwd, requireString(payload, "query"));
|
|
3345
|
+
},
|
|
3346
|
+
"fs.read": async (payload) => {
|
|
3347
|
+
const { cwd } = cwdOf(payload);
|
|
3348
|
+
const { content, truncated, binary, size, head } = await readText(await resolveGitPath(cwd, requireString(payload, "path")), resolved.readLimit);
|
|
3349
|
+
if (binary) return {
|
|
3350
|
+
kind: "binary",
|
|
3351
|
+
size,
|
|
3352
|
+
truncated,
|
|
3353
|
+
head
|
|
3354
|
+
};
|
|
3355
|
+
return {
|
|
3356
|
+
kind: "text",
|
|
3357
|
+
content,
|
|
3358
|
+
truncated
|
|
3359
|
+
};
|
|
3360
|
+
},
|
|
3361
|
+
"fs.write": async (payload) => {
|
|
3362
|
+
const { sessionId, cwd } = cwdOf(payload);
|
|
3363
|
+
requireWritable(sessionId);
|
|
3364
|
+
const path = requireAbsolute(requireString(payload, "path"));
|
|
3365
|
+
const content = requireString(payload, "content");
|
|
3366
|
+
const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`;
|
|
3367
|
+
try {
|
|
3368
|
+
await mkdir(dirname(path), { recursive: true });
|
|
3369
|
+
await writeFile(tmp, content, "utf8");
|
|
3370
|
+
await rename(tmp, path);
|
|
3371
|
+
} catch (error) {
|
|
3372
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
3373
|
+
throw new SidebarError("fs-error", `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
3374
|
+
}
|
|
3375
|
+
return { ok: true };
|
|
3376
|
+
},
|
|
3377
|
+
"git.status": async (payload) => {
|
|
3378
|
+
const { cwd } = cwdOf(payload);
|
|
3379
|
+
return status(cwd);
|
|
3380
|
+
},
|
|
3381
|
+
"git.diff": async (payload) => {
|
|
3382
|
+
const { cwd } = cwdOf(payload);
|
|
3383
|
+
const record = payload;
|
|
3384
|
+
return { diff: await diff(cwd, record.path === void 0 ? void 0 : await resolveGitPath(cwd, requireString(payload, "path")), record.staged === true) };
|
|
3385
|
+
},
|
|
3386
|
+
"git.stage": async (payload) => {
|
|
3387
|
+
const { sessionId, cwd } = cwdOf(payload);
|
|
3388
|
+
requireWritable(sessionId);
|
|
3389
|
+
await stage(cwd, payload.path === void 0 ? void 0 : requireString(payload, "path"));
|
|
3390
|
+
return { ok: true };
|
|
3391
|
+
},
|
|
3392
|
+
"git.unstage": async (payload) => {
|
|
3393
|
+
const { sessionId, cwd } = cwdOf(payload);
|
|
3394
|
+
requireWritable(sessionId);
|
|
3395
|
+
await unstage(cwd, payload.path === void 0 ? void 0 : requireString(payload, "path"));
|
|
3396
|
+
return { ok: true };
|
|
3397
|
+
},
|
|
3398
|
+
"git.commit": async (payload) => {
|
|
3399
|
+
const { sessionId, cwd } = cwdOf(payload);
|
|
3400
|
+
requireWritable(sessionId);
|
|
3401
|
+
await commit(cwd, requireString(payload, "message"));
|
|
3402
|
+
return { ok: true };
|
|
3403
|
+
},
|
|
3404
|
+
"git.branch": async (payload) => {
|
|
3405
|
+
const { cwd } = cwdOf(payload);
|
|
3406
|
+
return branches(cwd);
|
|
3407
|
+
},
|
|
3408
|
+
"git.checkout": async (payload) => {
|
|
3409
|
+
const { sessionId, cwd } = cwdOf(payload);
|
|
3410
|
+
requireWritable(sessionId);
|
|
3411
|
+
await checkout(cwd, requireString(payload, "branch"));
|
|
3412
|
+
return { ok: true };
|
|
3413
|
+
},
|
|
3414
|
+
"git.log": async (payload) => {
|
|
3415
|
+
const { cwd } = cwdOf(payload);
|
|
3416
|
+
const record = payload;
|
|
3417
|
+
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);
|
|
3418
|
+
},
|
|
3419
|
+
"git.commit-diff": async (payload) => {
|
|
3420
|
+
const { cwd } = cwdOf(payload);
|
|
3421
|
+
return { diff: await commitDiff(cwd, requireString(payload, "hash")) };
|
|
3422
|
+
},
|
|
3423
|
+
"git.discard": async (payload) => {
|
|
3424
|
+
const { sessionId, cwd } = cwdOf(payload);
|
|
3425
|
+
requireWritable(sessionId);
|
|
3426
|
+
await discard(cwd, await resolveGitPath(cwd, requireString(payload, "path")));
|
|
3427
|
+
return { ok: true };
|
|
3428
|
+
},
|
|
3429
|
+
"git.revert": async (payload) => {
|
|
3430
|
+
const { sessionId, cwd } = cwdOf(payload);
|
|
3431
|
+
requireWritable(sessionId);
|
|
3432
|
+
await revert(cwd, requireString(payload, "hash"));
|
|
3433
|
+
return { ok: true };
|
|
3434
|
+
},
|
|
3435
|
+
"git.cherry-pick": async (payload) => {
|
|
3436
|
+
const { sessionId, cwd } = cwdOf(payload);
|
|
3437
|
+
requireWritable(sessionId);
|
|
3438
|
+
await cherryPick(cwd, requireString(payload, "hash"));
|
|
3439
|
+
return { ok: true };
|
|
3440
|
+
},
|
|
3441
|
+
"git.show": async (payload) => {
|
|
3442
|
+
const { cwd } = cwdOf(payload);
|
|
3443
|
+
const path = await resolveGitPath(cwd, requireString(payload, "path"));
|
|
3444
|
+
return { content: await show(cwd, requireString(payload, "rev"), path) };
|
|
3445
|
+
},
|
|
3446
|
+
"pty.close": (payload) => {
|
|
3447
|
+
const sessionId = requireString(payload, "sessionId");
|
|
3448
|
+
const tab = requireString(payload, "tab");
|
|
3449
|
+
ptyManager?.close(`${sessionId}:${tab}`);
|
|
3450
|
+
return { ok: true };
|
|
3451
|
+
},
|
|
3452
|
+
"agent-pty.close": (payload) => {
|
|
3453
|
+
const uuid = requireString(payload, "uuid");
|
|
3454
|
+
agentPtyRegistry?.close(uuid);
|
|
3455
|
+
return { ok: true };
|
|
3456
|
+
},
|
|
3457
|
+
"terminal.deps": () => depsStatus(),
|
|
3458
|
+
"jobs.output": (payload) => jobsApi.output(payload),
|
|
3459
|
+
"jobs.kill": (payload) => jobsApi.kill(payload),
|
|
3460
|
+
"subagents.live": (payload) => subagentLiveApi.live(payload),
|
|
3461
|
+
"shell.get": () => ({
|
|
3462
|
+
shell: terminalShell,
|
|
3463
|
+
name: shellDisplayName(terminalShell)
|
|
3464
|
+
}),
|
|
3465
|
+
"config.get": (payload) => {
|
|
3466
|
+
const { sessionId } = cwdOf(payload);
|
|
3467
|
+
return { editable: editableSessions.has(sessionId) };
|
|
3468
|
+
},
|
|
3469
|
+
"config.set-editable": (payload) => {
|
|
3470
|
+
const { sessionId } = cwdOf(payload);
|
|
3471
|
+
if (payload?.editable === true) editableSessions.add(sessionId);
|
|
3472
|
+
else editableSessions.delete(sessionId);
|
|
3473
|
+
return { ok: true };
|
|
3474
|
+
},
|
|
3475
|
+
"settings.get": () => {
|
|
3476
|
+
const settings = getSettings();
|
|
3477
|
+
return settings === void 0 ? {
|
|
3478
|
+
value: void 0,
|
|
3479
|
+
revision: void 0,
|
|
3480
|
+
externalDisable: false
|
|
3481
|
+
} : {
|
|
3482
|
+
...settings.get(),
|
|
3483
|
+
externalDisable: settings.externalDisable()
|
|
3484
|
+
};
|
|
3485
|
+
},
|
|
3486
|
+
"settings.update": async (payload) => {
|
|
3487
|
+
const settings = getSettings();
|
|
3488
|
+
if (settings === void 0) throw new SidebarError("settings-rejected", "the settings service is not mounted in this deployment", 503);
|
|
3489
|
+
const record = payload;
|
|
3490
|
+
const patch = record?.patch;
|
|
3491
|
+
if (patch === null || typeof patch !== "object" || Array.isArray(patch)) throw new SidebarError("bad-request", "patch must be a plain object");
|
|
3492
|
+
const expectedRevision = typeof record?.expectedRevision === "number" ? record.expectedRevision : void 0;
|
|
3493
|
+
try {
|
|
3494
|
+
return await settings.update(patch, expectedRevision);
|
|
3495
|
+
} catch (error) {
|
|
3496
|
+
if (error instanceof SettingsConflictError) throw new SidebarError("settings-conflict", error.message, 409);
|
|
3497
|
+
throw new SidebarError("settings-rejected", error instanceof Error ? error.message : String(error), 400);
|
|
3498
|
+
}
|
|
3499
|
+
},
|
|
3500
|
+
"browser.probe": async (payload) => {
|
|
3501
|
+
const raw = requireString(payload, "url");
|
|
3502
|
+
let parsed;
|
|
3503
|
+
try {
|
|
3504
|
+
parsed = new URL(raw);
|
|
3505
|
+
} catch {
|
|
3506
|
+
throw new SidebarError("bad-request", "invalid url", 400);
|
|
3507
|
+
}
|
|
3508
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new SidebarError("bad-request", "only http/https urls can be probed", 400);
|
|
3509
|
+
if (isLoopbackHostname(parsed.hostname)) throw new SidebarError("bad-request", "local addresses are not probed", 400);
|
|
3510
|
+
const controller = new AbortController();
|
|
3511
|
+
const timer = setTimeout(() => controller.abort(), 8e3);
|
|
3512
|
+
try {
|
|
3513
|
+
let response = await fetch(parsed, {
|
|
3514
|
+
method: "HEAD",
|
|
3515
|
+
redirect: "follow",
|
|
3516
|
+
signal: controller.signal
|
|
3517
|
+
});
|
|
3518
|
+
if (response.status === 405 || response.status === 501) response = await fetch(parsed, {
|
|
3519
|
+
method: "GET",
|
|
3520
|
+
redirect: "follow",
|
|
3521
|
+
signal: controller.signal
|
|
3522
|
+
});
|
|
3523
|
+
const frameAncestors = extractFrameAncestors(response.headers.get("content-security-policy"));
|
|
3524
|
+
const xFrameOptions = response.headers.get("x-frame-options");
|
|
3525
|
+
return {
|
|
3526
|
+
reachable: true,
|
|
3527
|
+
url: response.url,
|
|
3528
|
+
status: response.status,
|
|
3529
|
+
...xFrameOptions !== null ? { xFrameOptions } : {},
|
|
3530
|
+
...frameAncestors !== void 0 ? { frameAncestors } : {}
|
|
3531
|
+
};
|
|
3532
|
+
} catch {
|
|
3533
|
+
return { reachable: false };
|
|
3534
|
+
} finally {
|
|
3535
|
+
clearTimeout(timer);
|
|
3536
|
+
}
|
|
3537
|
+
},
|
|
3538
|
+
"open.external": (payload) => {
|
|
3539
|
+
const action = payload?.action;
|
|
3540
|
+
if (action === "reveal") return launchExternal("reveal", requireString(payload, "path"));
|
|
3541
|
+
if (action === "url") return launchExternal("url", requireString(payload, "url"));
|
|
3542
|
+
throw new SidebarError("bad-request", "action must be \"reveal\" or \"url\"");
|
|
3543
|
+
},
|
|
3544
|
+
...buildSidechatApi(ctx)
|
|
3545
|
+
};
|
|
3546
|
+
}
|
|
3547
|
+
/**
|
|
3548
|
+
* Plugin body: mount the fenced routes and the pty lifecycle.
|
|
3549
|
+
* @param ctx - host plugin context (webServer, sessions, webRuntime).
|
|
3550
|
+
* @param config - deployment-provided limits; the Loader validates against
|
|
3551
|
+
* {@link Config} and fills defaults, direct callers get them from
|
|
3552
|
+
* {@link resolveSidebarConfig}.
|
|
3553
|
+
*/
|
|
3554
|
+
function apply(ctx, config) {
|
|
3555
|
+
ensureSpawnHelper();
|
|
3556
|
+
const resolved = resolveSidebarConfig(config);
|
|
3557
|
+
const terminalShell = defaultShell({ explicit: resolved.shell });
|
|
3558
|
+
const fence = (req) => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts);
|
|
3559
|
+
const nodePty = loadNodePty();
|
|
3560
|
+
if (nodePty === null) {
|
|
3561
|
+
const status = depsStatus();
|
|
3562
|
+
const detail = status.ok ? "unknown cause" : `${status.cause}. Repair: ${status.command}`;
|
|
3563
|
+
ctx.logger?.warn(`[dsh-better-sidebar] node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${detail}`);
|
|
3564
|
+
}
|
|
3565
|
+
const ptyManager = nodePty !== null ? new PtyManager(terminalShell, resolved.terminalsPerSession, resolved.shellArgs, nodePty) : null;
|
|
3566
|
+
const agentPtyRegistry = nodePty !== null ? new AgentPtyRegistry(terminalShell, resolved.shellArgs, nodePty) : null;
|
|
3567
|
+
let settingsFace;
|
|
3568
|
+
let toolsDisposers = null;
|
|
3569
|
+
const syncToolsGate = (scope) => {
|
|
3570
|
+
if (scope.get().agentTerminalTools) {
|
|
3571
|
+
if (toolsDisposers === null) {
|
|
3572
|
+
if (agentPtyRegistry === null) return;
|
|
3573
|
+
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId), () => shellOverridesOf(() => settingsFace));
|
|
3574
|
+
}
|
|
3575
|
+
} else if (toolsDisposers !== null) {
|
|
3576
|
+
toolsDisposers();
|
|
3577
|
+
toolsDisposers = null;
|
|
3578
|
+
agentPtyRegistry?.disposeAll();
|
|
3579
|
+
}
|
|
3580
|
+
};
|
|
3581
|
+
ctx.inject(["settings"], (sctx) => {
|
|
3582
|
+
const ns = settingsNamespace(SIDEBAR_PREFS_NS);
|
|
3583
|
+
const scope = sctx.settings.register(ns, PrefsSchema);
|
|
3584
|
+
const viewOf = () => {
|
|
3585
|
+
const descriptor = sctx.settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === ns);
|
|
3586
|
+
return descriptor === void 0 ? {
|
|
3587
|
+
value: void 0,
|
|
3588
|
+
revision: void 0
|
|
3589
|
+
} : {
|
|
3590
|
+
value: descriptor.value,
|
|
3591
|
+
revision: descriptor.revision
|
|
3592
|
+
};
|
|
3593
|
+
};
|
|
3594
|
+
const externalDisable = () => {
|
|
3595
|
+
return (sctx.settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === "aionui-panel")?.value)?.rightPanel === "aionui-panel";
|
|
3596
|
+
};
|
|
3597
|
+
settingsFace = {
|
|
3598
|
+
get: viewOf,
|
|
3599
|
+
externalDisable,
|
|
3600
|
+
update: async (patch, expectedRevision) => {
|
|
3601
|
+
await sctx.settings.update(ns, patch, expectedRevision);
|
|
3602
|
+
return viewOf();
|
|
3603
|
+
}
|
|
3604
|
+
};
|
|
3605
|
+
syncToolsGate(scope);
|
|
3606
|
+
scope.watch(() => {
|
|
3607
|
+
syncToolsGate(scope);
|
|
3608
|
+
});
|
|
3609
|
+
});
|
|
3610
|
+
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, () => settingsFace);
|
|
3611
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3612
|
+
kind: "prefix",
|
|
3613
|
+
path: "/sidebar/api",
|
|
3614
|
+
handler: async (req, res) => {
|
|
3615
|
+
if (!fence(req)) {
|
|
3616
|
+
writeJson(res, 403, {
|
|
3617
|
+
ok: false,
|
|
3618
|
+
error: {
|
|
3619
|
+
code: "forbidden",
|
|
3620
|
+
message: "forbidden"
|
|
3621
|
+
}
|
|
3622
|
+
});
|
|
3623
|
+
return;
|
|
3624
|
+
}
|
|
3625
|
+
if (req.method !== "POST") {
|
|
3626
|
+
writeJson(res, 405, {
|
|
3627
|
+
ok: false,
|
|
3628
|
+
error: {
|
|
3629
|
+
code: "method-error",
|
|
3630
|
+
message: "method not allowed"
|
|
3631
|
+
}
|
|
3632
|
+
});
|
|
3633
|
+
return;
|
|
3634
|
+
}
|
|
3635
|
+
const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
|
|
3636
|
+
const method = pathname.startsWith("/sidebar/api/") ? pathname.slice(13) : void 0;
|
|
3637
|
+
if (method === void 0 || method.includes("/")) {
|
|
3638
|
+
writeError(res, new SidebarError("not-found", "unknown sidebar API method", 404));
|
|
3639
|
+
return;
|
|
3640
|
+
}
|
|
3641
|
+
try {
|
|
3642
|
+
const payload = await readJsonBody(req);
|
|
3643
|
+
const handler = api[method];
|
|
3644
|
+
if (handler === void 0) throw new SidebarError("not-found", `unknown sidebar API method "${method}"`, 404);
|
|
3645
|
+
writeOk(res, await handler(payload));
|
|
3646
|
+
} catch (error) {
|
|
3647
|
+
writeError(res, error);
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3650
|
+
}), "dsh-better-sidebar: /sidebar/api routes");
|
|
3651
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3652
|
+
kind: "exact",
|
|
3653
|
+
path: "/sidebar/upload",
|
|
3654
|
+
handler: async (req, res) => {
|
|
3655
|
+
if (!fence(req)) {
|
|
3656
|
+
writeJson(res, 403, {
|
|
3657
|
+
ok: false,
|
|
3658
|
+
error: {
|
|
3659
|
+
code: "forbidden",
|
|
3660
|
+
message: "forbidden"
|
|
3661
|
+
}
|
|
3662
|
+
});
|
|
3663
|
+
return;
|
|
3664
|
+
}
|
|
3665
|
+
if (req.method !== "POST") {
|
|
3666
|
+
writeJson(res, 405, {
|
|
3667
|
+
ok: false,
|
|
3668
|
+
error: {
|
|
3669
|
+
code: "method-error",
|
|
3670
|
+
message: "method not allowed"
|
|
3671
|
+
}
|
|
3672
|
+
});
|
|
3673
|
+
return;
|
|
3674
|
+
}
|
|
3675
|
+
try {
|
|
3676
|
+
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
3677
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
3678
|
+
const dir = url.searchParams.get("dir");
|
|
3679
|
+
const relativePath = url.searchParams.get("relativePath");
|
|
3680
|
+
if (sessionId === null || dir === null || relativePath === null || relativePath.trim() === "") throw new SidebarError("bad-request", "sessionId, dir, and relativePath are required");
|
|
3681
|
+
const { path, size } = await writeWorkspaceUpload({
|
|
3682
|
+
cwd: sessionCwdOf(ctx, sessionId, url.searchParams.get("cwd") ?? void 0),
|
|
3683
|
+
dir,
|
|
3684
|
+
relativePath,
|
|
3685
|
+
chunks: req,
|
|
3686
|
+
limit: resolved.uploadLimit
|
|
3687
|
+
});
|
|
3688
|
+
writeOk(res, {
|
|
3689
|
+
path,
|
|
3690
|
+
size
|
|
3691
|
+
});
|
|
3692
|
+
} catch (error) {
|
|
3693
|
+
writeError(res, error);
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
}), "dsh-better-sidebar: /sidebar/upload route");
|
|
3697
|
+
ctx.effect(() => registerBundleRoute(ctx, fence), "dsh-better-sidebar: /sidebar/bundle chunk route");
|
|
3698
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3699
|
+
kind: "prefix",
|
|
3700
|
+
path: "/sidebar/file",
|
|
3701
|
+
handler: async (req, res) => {
|
|
3702
|
+
if (!fence(req)) {
|
|
3703
|
+
res.writeHead(403);
|
|
3704
|
+
res.end("forbidden");
|
|
3705
|
+
return;
|
|
3706
|
+
}
|
|
3707
|
+
if (req.method !== "GET") {
|
|
3708
|
+
res.writeHead(405);
|
|
3709
|
+
res.end();
|
|
3710
|
+
return;
|
|
3711
|
+
}
|
|
3712
|
+
try {
|
|
3713
|
+
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
3714
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
3715
|
+
const raw = url.searchParams.get("path");
|
|
3716
|
+
if (sessionId === null || raw === null) throw new SidebarError("bad-request", "sessionId and path are required");
|
|
3717
|
+
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get("cwd") ?? void 0);
|
|
3718
|
+
const path = requireAbsolute(raw);
|
|
3719
|
+
if (!isWithin(cwd, path)) throw new SidebarError("fs-error", "media path outside the session working directory", 403);
|
|
3720
|
+
const info = await stat(path);
|
|
3721
|
+
if (!info.isFile() || info.size > resolved.mediaLimit) throw new SidebarError("fs-error", "not a file or too large", 400);
|
|
3722
|
+
const type = mediaTypeForPath(path);
|
|
3723
|
+
const body = await readFile(path);
|
|
3724
|
+
const headers = {
|
|
3725
|
+
"content-type": type,
|
|
3726
|
+
"cache-control": "no-cache"
|
|
3727
|
+
};
|
|
3728
|
+
if (url.searchParams.get("download") === "1") headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}`;
|
|
3729
|
+
res.writeHead(200, headers);
|
|
3730
|
+
res.end(body);
|
|
3731
|
+
} catch (error) {
|
|
3732
|
+
writeError(res, error);
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
}), "dsh-better-sidebar: /sidebar/file media route");
|
|
3736
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3737
|
+
kind: "prefix",
|
|
3738
|
+
path: "/sidebar/html",
|
|
3739
|
+
handler: async (req, res) => {
|
|
3740
|
+
if (!fence(req)) {
|
|
3741
|
+
res.writeHead(403);
|
|
3742
|
+
res.end("forbidden");
|
|
3743
|
+
return;
|
|
3744
|
+
}
|
|
3745
|
+
if (req.method !== "GET") {
|
|
3746
|
+
res.writeHead(405);
|
|
3747
|
+
res.end();
|
|
3748
|
+
return;
|
|
3749
|
+
}
|
|
3750
|
+
try {
|
|
3751
|
+
const decoded = decodeHtmlUrl(new URL(req.url ?? "/", "http://dsh.internal").pathname);
|
|
3752
|
+
if (!decoded.ok) {
|
|
3753
|
+
writeError(res, new SidebarError("bad-request", decoded.message, decoded.status));
|
|
3754
|
+
return;
|
|
3755
|
+
}
|
|
3756
|
+
const { sessionId, path } = decoded.ref;
|
|
3757
|
+
const cwd = sessionCwdOf(ctx, sessionId);
|
|
3758
|
+
const absolute = requireAbsolute(path);
|
|
3759
|
+
if (!isWithin(cwd, absolute)) throw new SidebarError("fs-error", "html path outside the session working directory", 403);
|
|
3760
|
+
const info = await stat(absolute);
|
|
3761
|
+
if (!info.isFile() || info.size > resolved.mediaLimit) throw new SidebarError("fs-error", "not a file or too large", 400);
|
|
3762
|
+
const type = mediaTypeForPath(absolute);
|
|
3763
|
+
const body = await readFile(absolute);
|
|
3764
|
+
res.writeHead(200, {
|
|
3765
|
+
"content-type": type,
|
|
3766
|
+
"cache-control": "no-cache",
|
|
3767
|
+
"x-content-type-options": "nosniff",
|
|
3768
|
+
"referrer-policy": "no-referrer",
|
|
3769
|
+
"content-security-policy": "sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'"
|
|
3770
|
+
});
|
|
3771
|
+
res.end(body);
|
|
3772
|
+
} catch (error) {
|
|
3773
|
+
writeError(res, error);
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
}), "dsh-better-sidebar: /sidebar/html preview route");
|
|
3777
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
3778
|
+
ctx.effect(() => ctx.webServer.registerUpgrade({
|
|
3779
|
+
path: "/sidebar/ws/terminal",
|
|
3780
|
+
handler: (req, socket, head) => {
|
|
3781
|
+
if (!fence(req)) {
|
|
3782
|
+
socket.destroy();
|
|
3783
|
+
return;
|
|
3784
|
+
}
|
|
3785
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
3786
|
+
attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved, () => settingsFace);
|
|
3787
|
+
});
|
|
3788
|
+
}
|
|
3789
|
+
}), "dsh-better-sidebar: terminal WebSocket");
|
|
3790
|
+
const agentListWss = new WebSocketServer({ noServer: true });
|
|
3791
|
+
ctx.effect(() => ctx.webServer.registerUpgrade({
|
|
3792
|
+
path: "/sidebar/ws/agent-terminals",
|
|
3793
|
+
handler: (req, socket, head) => {
|
|
3794
|
+
if (!fence(req)) {
|
|
3795
|
+
socket.destroy();
|
|
3796
|
+
return;
|
|
3797
|
+
}
|
|
3798
|
+
agentListWss.handleUpgrade(req, socket, head, (ws) => {
|
|
3799
|
+
attachAgentList(agentPtyRegistry, ws, req);
|
|
3800
|
+
});
|
|
3801
|
+
}
|
|
3802
|
+
}), "dsh-better-sidebar: agent-terminals push WebSocket");
|
|
3803
|
+
ctx.effect(() => () => {
|
|
3804
|
+
toolsDisposers?.();
|
|
3805
|
+
ptyManager?.disposeAll();
|
|
3806
|
+
agentPtyRegistry?.disposeAll();
|
|
3807
|
+
wss.close();
|
|
3808
|
+
agentListWss.close();
|
|
3809
|
+
}, "dsh-better-sidebar: teardown");
|
|
3810
|
+
}
|
|
3811
|
+
/** Push the live agent-terminal list for one session to a connected sidebar view. */
|
|
3812
|
+
async function attachAgentList(registry, ws, req) {
|
|
3813
|
+
try {
|
|
3814
|
+
const sessionId = new URL(req.url ?? "/", "http://dsh.internal").searchParams.get("sessionId");
|
|
3815
|
+
if (sessionId === null) {
|
|
3816
|
+
ws.close(1008, "sessionId is required");
|
|
3817
|
+
return;
|
|
3818
|
+
}
|
|
3819
|
+
const send = () => {
|
|
3820
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(registry?.list(sessionId) ?? []));
|
|
3821
|
+
};
|
|
3822
|
+
send();
|
|
3823
|
+
const unsubscribe = registry?.subscribe(send);
|
|
3824
|
+
ws.on("close", () => {
|
|
3825
|
+
unsubscribe?.();
|
|
3826
|
+
});
|
|
3827
|
+
ws.on("error", () => {
|
|
3828
|
+
unsubscribe?.();
|
|
3829
|
+
});
|
|
3830
|
+
} catch (error) {
|
|
3831
|
+
ws.close(1011, error instanceof Error ? error.message : String(error));
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
/**
|
|
3835
|
+
* Wire one terminal socket to its pty: replay transcript, pump both ways.
|
|
3836
|
+
* Two attach modes share the wire protocol:
|
|
3837
|
+
* - `?uuid=...` attaches to an agent-owned terminal (created by the
|
|
3838
|
+
* `terminal_create` tool). The close frame kills the pty immediately
|
|
3839
|
+
* (the agent's terminal closes when the user closes the sidebar tab); a
|
|
3840
|
+
* bare socket drop (refresh, tab switch) leaves the pty alive for the
|
|
3841
|
+
* reconnect grace, exactly like UI-tab terminals.
|
|
3842
|
+
* - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user
|
|
3843
|
+
* created it from the + menu). The close frame schedules a 0-ms close
|
|
3844
|
+
* (the host's reconnect grace keeps the shell alive across a refresh).
|
|
3845
|
+
* The park frame (sent when the user switches to another conversation)
|
|
3846
|
+
* marks the pty as parked so the upcoming bare socket drop does NOT start
|
|
3847
|
+
* the grace countdown — the tab is still open in its session's state, so
|
|
3848
|
+
* the shell must survive until the user switches back or closes the tab.
|
|
3849
|
+
*/
|
|
3850
|
+
async function attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved, getSettings) {
|
|
3851
|
+
try {
|
|
3852
|
+
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
3853
|
+
const uuid = url.searchParams.get("uuid");
|
|
3854
|
+
if (uuid !== null) {
|
|
3855
|
+
if (agentPtyRegistry === null) {
|
|
3856
|
+
ws.close(1011, `agent terminal "${uuid}" not found`);
|
|
3857
|
+
return;
|
|
3858
|
+
}
|
|
3859
|
+
const handle = agentPtyRegistry.get(uuid);
|
|
3860
|
+
if (handle === void 0) {
|
|
3861
|
+
ws.close(1011, `agent terminal "${uuid}" not found`);
|
|
3862
|
+
return;
|
|
3863
|
+
}
|
|
3864
|
+
pumpAgentTerminal(agentPtyRegistry, handle, ws);
|
|
3865
|
+
return;
|
|
3866
|
+
}
|
|
3867
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
3868
|
+
const tabId = url.searchParams.get("tab");
|
|
3869
|
+
if (sessionId === null || tabId === null) {
|
|
3870
|
+
ws.close(1008, "either ?uuid or ?sessionId+?tab are required");
|
|
3871
|
+
return;
|
|
3872
|
+
}
|
|
3873
|
+
if (ptyManager === null) {
|
|
3874
|
+
ws.close(1011, PTY_DEPS_MISSING);
|
|
3875
|
+
return;
|
|
3876
|
+
}
|
|
3877
|
+
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get("cwd") ?? void 0);
|
|
3878
|
+
const overrides = shellOverridesOf(getSettings);
|
|
3879
|
+
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs);
|
|
3880
|
+
if (handle.transcript !== "") ws.send(handle.transcript);
|
|
3881
|
+
const onData = (data) => {
|
|
3882
|
+
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4194304) ws.send(data);
|
|
3883
|
+
};
|
|
3884
|
+
const onExit = ({ exitCode }) => {
|
|
3885
|
+
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`);
|
|
3886
|
+
};
|
|
3887
|
+
const dataSub = handle.pty.onData(onData);
|
|
3888
|
+
const exitSub = handle.pty.onExit(onExit);
|
|
3889
|
+
ws.on("message", (data) => {
|
|
3890
|
+
const text = data.toString("utf8");
|
|
3891
|
+
let control = null;
|
|
3892
|
+
try {
|
|
3893
|
+
const parsed = JSON.parse(text);
|
|
3894
|
+
if (parsed !== null && typeof parsed === "object") control = parsed;
|
|
3895
|
+
} catch {}
|
|
3896
|
+
if (control !== null && control.type === "close") {
|
|
3897
|
+
ptyManager.scheduleClose(handle.key, 0);
|
|
3898
|
+
return;
|
|
3899
|
+
}
|
|
3900
|
+
if (control !== null && control.type === "park") {
|
|
3901
|
+
ptyManager.park(handle.key);
|
|
3902
|
+
return;
|
|
3903
|
+
}
|
|
3904
|
+
if (handle.exited) return;
|
|
3905
|
+
if (control !== null && control.type === "resize" && typeof control.cols === "number" && typeof control.rows === "number") {
|
|
3906
|
+
const dims = clampDims(control.cols, control.rows);
|
|
3907
|
+
handle.pty.resize(dims.cols, dims.rows);
|
|
3908
|
+
} else handle.pty.write(text);
|
|
3909
|
+
});
|
|
3910
|
+
ws.on("close", () => {
|
|
3911
|
+
dataSub.dispose();
|
|
3912
|
+
exitSub.dispose();
|
|
3913
|
+
if (!ptyManager.isParked(handle.key)) ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs);
|
|
3914
|
+
});
|
|
3915
|
+
} catch (error) {
|
|
3916
|
+
ws.close(1011, error instanceof Error ? error.message : String(error));
|
|
3917
|
+
}
|
|
3918
|
+
}
|
|
3919
|
+
/**
|
|
3920
|
+
* Pump one agent terminal's pty to a connected view. The close frame kills
|
|
3921
|
+
* the pty immediately (the agent's terminal closes when the user closes the
|
|
3922
|
+
* sidebar tab); a bare socket drop leaves the pty alive — the agent owns
|
|
3923
|
+
* the lifetime, and only `terminal_close`, a `{type:'close'}` frame, or
|
|
3924
|
+
* plugin teardown kills it.
|
|
3925
|
+
*/
|
|
3926
|
+
function pumpAgentTerminal(registry, handle, ws) {
|
|
3927
|
+
if (handle.transcript !== "") ws.send(handle.transcript);
|
|
3928
|
+
const onData = (data) => {
|
|
3929
|
+
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4194304) ws.send(data);
|
|
3930
|
+
};
|
|
3931
|
+
const onExit = ({ exitCode }) => {
|
|
3932
|
+
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`);
|
|
3933
|
+
};
|
|
3934
|
+
const dataSub = handle.pty.onData(onData);
|
|
3935
|
+
const exitSub = handle.pty.onExit(onExit);
|
|
3936
|
+
ws.on("message", (data) => {
|
|
3937
|
+
if (handle.exited) return;
|
|
3938
|
+
const text = data.toString("utf8");
|
|
3939
|
+
let control = null;
|
|
3940
|
+
try {
|
|
3941
|
+
const parsed = JSON.parse(text);
|
|
3942
|
+
if (parsed !== null && typeof parsed === "object") control = parsed;
|
|
3943
|
+
} catch {}
|
|
3944
|
+
if (control !== null && control.type === "close") {
|
|
3945
|
+
registry.close(handle.uuid);
|
|
3946
|
+
return;
|
|
3947
|
+
}
|
|
3948
|
+
if (control !== null && control.type === "resize" && typeof control.cols === "number" && typeof control.rows === "number") {
|
|
3949
|
+
const dims = clampDims(control.cols, control.rows);
|
|
3950
|
+
handle.pty.resize(dims.cols, dims.rows);
|
|
3951
|
+
} else if (control === null) handle.pty.write(text);
|
|
3952
|
+
});
|
|
3953
|
+
ws.on("close", () => {
|
|
3954
|
+
dataSub.dispose();
|
|
3955
|
+
exitSub.dispose();
|
|
3956
|
+
});
|
|
3957
|
+
}
|
|
3958
|
+
//#endregion
|
|
3959
|
+
export { Config, apply, inject, mediaTypeForPath, name };
|