@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/src/tools.ts
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eight model-facing tools for the agent-owned sidebar terminals (tmux
|
|
3
|
+
* semantics: spawn-and-detach, send-keys, read, wait-for, resize, signal,
|
|
4
|
+
* close, list). Each tool binds to the calling agent's session through
|
|
5
|
+
* `exec.agent.session.id`, so the model never passes a sessionId — the
|
|
6
|
+
* agent identity is the scope.
|
|
7
|
+
*
|
|
8
|
+
* Conventions (per plugin-development-guide.md §3):
|
|
9
|
+
* C1 — parameters schema-validated before `execute` runs.
|
|
10
|
+
* C4 — `execute` returns one canonical JSON value; `render` is a separate
|
|
11
|
+
* pure text projection.
|
|
12
|
+
* C6 — `exec.signal.throwIfAborted()` before any spawn.
|
|
13
|
+
* C10 — no UI/transport vocabulary in the canonical value.
|
|
14
|
+
*/
|
|
15
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
16
|
+
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
|
|
17
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
18
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
19
|
+
import type { Context } from './context-types.ts'
|
|
20
|
+
import {
|
|
21
|
+
AgentPtyRegistry,
|
|
22
|
+
ALLOWED_SIGNALS,
|
|
23
|
+
type AgentTerminalSignal,
|
|
24
|
+
type AgentTerminalSnapshot,
|
|
25
|
+
} from './agent-pty.ts'
|
|
26
|
+
|
|
27
|
+
/** Maximum UTF-8 bytes of one `terminal_read` result text. */
|
|
28
|
+
const READ_BYTE_LIMIT = 256 * 1024
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Bound a string to a byte limit, marking truncation. Truncation never
|
|
32
|
+
* splits a multi-byte UTF-8 sequence: when the byte cap lands inside one,
|
|
33
|
+
* the walk-back retreats to the sequence's leading byte so the retained
|
|
34
|
+
* prefix decodes cleanly (a split would decode to U+FFFD).
|
|
35
|
+
* @internal exported for the unit tests, like {@link snapshotOf}.
|
|
36
|
+
*/
|
|
37
|
+
export function boundBytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
|
|
38
|
+
const buf = Buffer.from(text, 'utf8')
|
|
39
|
+
if (buf.byteLength <= maxBytes) return { text, truncated: false }
|
|
40
|
+
// The byte at `end` is a continuation byte (10xxxxxx) exactly when the
|
|
41
|
+
// character that started before `end` spills past the cap. Stop at the
|
|
42
|
+
// first non-continuation byte — the retained prefix is then intact.
|
|
43
|
+
let end = maxBytes
|
|
44
|
+
while (end > 0 && ((buf[end] ?? 0) & 0xc0) === 0x80) end -= 1
|
|
45
|
+
return { text: buf.subarray(0, end).toString('utf8'), truncated: true }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pure text projection helper (the canonical value is already structured). */
|
|
49
|
+
function textRender<T>(fn: (value: T) => string): (_args: unknown, value: unknown) => ContentBlock[] {
|
|
50
|
+
return (_args, value) => [{ type: 'text', text: fn(value as T) }]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Extract the calling agent or throw the canonical "no agent" error. */
|
|
54
|
+
function requireAgent(agent: Agent | undefined): Agent {
|
|
55
|
+
if (agent === undefined) {
|
|
56
|
+
throw new Error('sidebar terminal tools require an initiating agent')
|
|
57
|
+
}
|
|
58
|
+
return agent
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve the calling agent's session id (the registry scope + ownership key). */
|
|
62
|
+
function sessionIdOf(exec: ToolRunContext): string {
|
|
63
|
+
return requireAgent(exec.agent).session.id
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Register the eight terminal tools against the host tool registry. The
|
|
68
|
+
* `resolveCwd` callback threads the live session cwd (authoritative from the
|
|
69
|
+
* session store, falling back to the process cwd) so a freshly-created
|
|
70
|
+
* terminal lands in the right directory without the model passing it.
|
|
71
|
+
* Every uuid-keyed tool first asserts the terminal belongs to the calling
|
|
72
|
+
* session (`registry.assertOwned`), so one agent can never reach another
|
|
73
|
+
* session's terminals.
|
|
74
|
+
* @param ctx - host plugin context (carries the tools service).
|
|
75
|
+
* @param registry - the agent-owned terminal registry.
|
|
76
|
+
* @param resolveCwd - live cwd resolver for one session id.
|
|
77
|
+
* @returns a disposer that unregisters all eight tools (the caller gates
|
|
78
|
+
* registration on the side-card setting and calls this to turn them off).
|
|
79
|
+
*/
|
|
80
|
+
export function registerTools(
|
|
81
|
+
ctx: Context,
|
|
82
|
+
registry: AgentPtyRegistry,
|
|
83
|
+
resolveCwd: (sessionId: string) => string,
|
|
84
|
+
readShellOverrides: () => { shell?: string; shellArgs?: string[] },
|
|
85
|
+
): () => void {
|
|
86
|
+
const disposers: Array<() => void> = []
|
|
87
|
+
const register = (tool: ReturnType<typeof defineTool>): void => {
|
|
88
|
+
disposers.push(ctx.tools.register(tool))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
register(defineTool({
|
|
92
|
+
name: 'terminal_create',
|
|
93
|
+
description:
|
|
94
|
+
'Open a persistent terminal in the sidebar and run a command in it. '
|
|
95
|
+
+ 'Spawns an interactive shell, writes the command + Enter to its stdin, and returns a uuid handle. '
|
|
96
|
+
+ 'The terminal stays alive after the command exits — send more input with terminal_send (set submit=true to run a command), '
|
|
97
|
+
+ 'read output with terminal_read, send Ctrl+C with terminal_signal(signal="SIGINT"), '
|
|
98
|
+
+ 'and close it with terminal_close when done. '
|
|
99
|
+
+ 'Use this for interactive shells, REPLs, long-running dev servers, '
|
|
100
|
+
+ 'or any work that needs persistent terminal state across tool calls. '
|
|
101
|
+
+ '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.',
|
|
102
|
+
parameters: {
|
|
103
|
+
title: {
|
|
104
|
+
type: 'string',
|
|
105
|
+
required: true,
|
|
106
|
+
description: 'Short human-readable label for the terminal tab (e.g. "dev server", "python repl").',
|
|
107
|
+
},
|
|
108
|
+
command: {
|
|
109
|
+
type: 'string',
|
|
110
|
+
required: true,
|
|
111
|
+
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.',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
output: {
|
|
115
|
+
schema: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
additionalProperties: false,
|
|
118
|
+
properties: {
|
|
119
|
+
uuid: { type: 'string', required: true, description: 'Opaque handle for the new terminal. Pass to terminal_send / terminal_read / terminal_resize / terminal_signal / terminal_close.' },
|
|
120
|
+
title: { type: 'string', required: true, description: 'The title you provided (echoed for confirmation).' },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
render: textRender((v: { uuid: string; title: string }) =>
|
|
124
|
+
`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.`,
|
|
125
|
+
),
|
|
126
|
+
},
|
|
127
|
+
execute: (args: { title: string; command: string }, exec) => {
|
|
128
|
+
exec.signal.throwIfAborted()
|
|
129
|
+
const sessionId = sessionIdOf(exec)
|
|
130
|
+
const cwd = resolveCwd(sessionId)
|
|
131
|
+
const { shell, shellArgs } = readShellOverrides()
|
|
132
|
+
const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24, shell, shellArgs)
|
|
133
|
+
return Promise.resolve({ uuid, title: args.title })
|
|
134
|
+
},
|
|
135
|
+
}))
|
|
136
|
+
|
|
137
|
+
register(defineTool({
|
|
138
|
+
name: 'terminal_list',
|
|
139
|
+
description:
|
|
140
|
+
'List every terminal the current agent has opened in this session. Returns each terminal\'s uuid, title, '
|
|
141
|
+
+ 'the command it was started with, and whether the top-level process has exited (with exit code/signal if so). '
|
|
142
|
+
+ 'Use this to recover state after a long sequence of tool calls or to find a terminal you forgot to close.',
|
|
143
|
+
parameters: {},
|
|
144
|
+
output: {
|
|
145
|
+
schema: {
|
|
146
|
+
type: 'array',
|
|
147
|
+
items: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
additionalProperties: false,
|
|
150
|
+
properties: {
|
|
151
|
+
uuid: { type: 'string', required: true },
|
|
152
|
+
title: { type: 'string', required: true },
|
|
153
|
+
command: { type: 'string', required: true },
|
|
154
|
+
exited: { type: 'boolean', required: true },
|
|
155
|
+
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
156
|
+
exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
render: (_args, value) => {
|
|
161
|
+
const list = value as AgentTerminalSnapshot[]
|
|
162
|
+
if (list.length === 0) return [{ type: 'text', text: 'No agent terminals open in this session.' }]
|
|
163
|
+
const lines = list.map((t) => {
|
|
164
|
+
const status = t.exited
|
|
165
|
+
? `exited (code ${t.exitCode ?? '?'}, signal ${t.exitSignal ?? 'none'})`
|
|
166
|
+
: 'running'
|
|
167
|
+
return ` ${t.uuid} "${t.title}" [${status}] $ ${t.command}`
|
|
168
|
+
})
|
|
169
|
+
return [{ type: 'text', text: `Agent terminals in this session:\n${lines.join('\n')}` }]
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
execute: (_args, exec) => {
|
|
173
|
+
const sessionId = sessionIdOf(exec)
|
|
174
|
+
return Promise.resolve(registry.list(sessionId))
|
|
175
|
+
},
|
|
176
|
+
}))
|
|
177
|
+
|
|
178
|
+
register(defineTool({
|
|
179
|
+
name: 'terminal_send',
|
|
180
|
+
description:
|
|
181
|
+
'Send raw text (keystrokes) to a terminal opened with terminal_create — tmux send-keys semantics. '
|
|
182
|
+
+ 'The text is written verbatim to the pty stdin. '
|
|
183
|
+
+ 'To submit a command, set submit=true (appends an Enter key); do NOT put "\\n" or "\\r" in the text yourself. '
|
|
184
|
+
+ '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. '
|
|
185
|
+
+ 'Use terminal_signal with signal="SIGTSTP" for Ctrl+Z (suspend) as well. '
|
|
186
|
+
+ 'This tool does NOT wait for the command to finish or for output to settle — pair with terminal_read to observe the result. '
|
|
187
|
+
+ 'Throws if the terminal has exited.',
|
|
188
|
+
parameters: {
|
|
189
|
+
uuid: {
|
|
190
|
+
type: 'string',
|
|
191
|
+
required: true,
|
|
192
|
+
description: 'Terminal uuid from terminal_create or terminal_list.',
|
|
193
|
+
},
|
|
194
|
+
text: {
|
|
195
|
+
type: 'string',
|
|
196
|
+
required: true,
|
|
197
|
+
description: 'UTF-8 text to write to the terminal stdin (verbatim, no shell escaping). Do not include trailing newlines — use the submit flag instead.',
|
|
198
|
+
},
|
|
199
|
+
submit: {
|
|
200
|
+
type: 'boolean',
|
|
201
|
+
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.',
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
output: {
|
|
205
|
+
schema: {
|
|
206
|
+
type: 'object',
|
|
207
|
+
additionalProperties: false,
|
|
208
|
+
properties: {
|
|
209
|
+
uuid: { type: 'string', required: true },
|
|
210
|
+
bytes: { type: 'integer', required: true, description: 'Number of UTF-8 bytes written (including the Enter key if submit was true).' },
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
render: textRender((v: { uuid: string; bytes: number }) =>
|
|
214
|
+
`Sent ${v.bytes} byte(s) to terminal ${v.uuid}.`,
|
|
215
|
+
),
|
|
216
|
+
},
|
|
217
|
+
execute: (args: { uuid: string; text: string; submit?: boolean }, exec) => {
|
|
218
|
+
exec.signal.throwIfAborted()
|
|
219
|
+
const sessionId = sessionIdOf(exec)
|
|
220
|
+
registry.assertOwned(args.uuid, sessionId)
|
|
221
|
+
const payload = args.submit === true ? `${args.text}\r` : args.text
|
|
222
|
+
registry.send(args.uuid, payload)
|
|
223
|
+
return Promise.resolve({ uuid: args.uuid, bytes: Buffer.byteLength(payload, 'utf8') })
|
|
224
|
+
},
|
|
225
|
+
}))
|
|
226
|
+
|
|
227
|
+
register(defineTool({
|
|
228
|
+
name: 'terminal_read',
|
|
229
|
+
description:
|
|
230
|
+
'Read a bounded page of retained output from an agent terminal without sending input. '
|
|
231
|
+
+ 'The host keeps up to ~1 MiB of scrollback; this tool returns up to 500 lines per call. '
|
|
232
|
+
+ '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 ). '
|
|
233
|
+
+ 'Returns `totalLines` so you know how much scrollback remains. '
|
|
234
|
+
+ 'Output is bounded to 256 KiB per call; longer pages are truncated with the `truncated` flag.',
|
|
235
|
+
parameters: {
|
|
236
|
+
uuid: {
|
|
237
|
+
type: 'string',
|
|
238
|
+
required: true,
|
|
239
|
+
description: 'Terminal uuid from terminal_create or terminal_list.',
|
|
240
|
+
},
|
|
241
|
+
offset: {
|
|
242
|
+
type: 'number',
|
|
243
|
+
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).',
|
|
244
|
+
},
|
|
245
|
+
count: {
|
|
246
|
+
type: 'number',
|
|
247
|
+
description: 'Maximum lines to return (default 500, hard cap 500).',
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
output: {
|
|
251
|
+
schema: {
|
|
252
|
+
type: 'object',
|
|
253
|
+
additionalProperties: false,
|
|
254
|
+
properties: {
|
|
255
|
+
text: { type: 'string', required: true, description: 'The slice of transcript for the requested page.' },
|
|
256
|
+
totalLines: { type: 'integer', required: true, description: 'Total lines in the retained transcript.' },
|
|
257
|
+
lineBegin: { type: 'integer', required: true, description: '0-based index of the first line in `text` (inclusive).' },
|
|
258
|
+
lineEnd: { type: 'integer', required: true, description: '0-based index of the last line in `text` (exclusive).' },
|
|
259
|
+
truncated: { type: 'boolean', required: true, description: 'Whether `text` was truncated to fit the 256 KiB read cap.' },
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
render: (_args, value) => {
|
|
263
|
+
const v = value as { text: string; totalLines: number; lineBegin: number; lineEnd: number; truncated: boolean }
|
|
264
|
+
const head = `[lines ${v.lineBegin}..${v.lineEnd} of ${v.totalLines}${v.truncated ? '; truncated to 256KiB' : ''}]`
|
|
265
|
+
return [{ type: 'text', text: `${head}\n${v.text}` }]
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
execute: (args: { uuid: string; offset?: number; count?: number }, exec) => {
|
|
269
|
+
exec.signal.throwIfAborted()
|
|
270
|
+
const sessionId = sessionIdOf(exec)
|
|
271
|
+
registry.assertOwned(args.uuid, sessionId)
|
|
272
|
+
const result = registry.read(args.uuid, args.offset, args.count)
|
|
273
|
+
const bounded = boundBytes(result.text, READ_BYTE_LIMIT)
|
|
274
|
+
return Promise.resolve({
|
|
275
|
+
text: bounded.text,
|
|
276
|
+
totalLines: result.totalLines,
|
|
277
|
+
lineBegin: result.lineBegin,
|
|
278
|
+
lineEnd: result.lineEnd,
|
|
279
|
+
truncated: bounded.truncated,
|
|
280
|
+
})
|
|
281
|
+
},
|
|
282
|
+
}))
|
|
283
|
+
|
|
284
|
+
register(defineTool({
|
|
285
|
+
name: 'terminal_wait_for',
|
|
286
|
+
description:
|
|
287
|
+
'Block until a substring appears in a terminal\'s retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. '
|
|
288
|
+
+ 'Use this to synchronize on command completion cues ( e.g. a shell prompt, "done", "Listening on", "Build successful" ) '
|
|
289
|
+
+ 'without busy-polling terminal_read. '
|
|
290
|
+
+ '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. '
|
|
291
|
+
+ '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. '
|
|
292
|
+
+ 'Default timeout is 10 seconds; raise it for long-running commands ( dev servers, test suites ). '
|
|
293
|
+
+ 'The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately.',
|
|
294
|
+
parameters: {
|
|
295
|
+
uuid: {
|
|
296
|
+
type: 'string',
|
|
297
|
+
required: true,
|
|
298
|
+
description: 'Terminal uuid from terminal_create or terminal_list.',
|
|
299
|
+
},
|
|
300
|
+
needle: {
|
|
301
|
+
type: 'string',
|
|
302
|
+
required: true,
|
|
303
|
+
description: 'Substring to wait for (case-sensitive, verbatim). Must be non-empty.',
|
|
304
|
+
},
|
|
305
|
+
timeout_ms: {
|
|
306
|
+
type: 'number',
|
|
307
|
+
description: 'Maximum wait in milliseconds (default 10000, i.e. 10s). Clamped to a minimum of 100ms.',
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
output: {
|
|
311
|
+
schema: {
|
|
312
|
+
oneOf: [
|
|
313
|
+
{
|
|
314
|
+
type: 'object',
|
|
315
|
+
additionalProperties: false,
|
|
316
|
+
properties: {
|
|
317
|
+
kind: { type: 'string', required: true, const: 'found' },
|
|
318
|
+
needle: { type: 'string', required: true },
|
|
319
|
+
line: { type: 'integer', required: true, description: '0-based line index in the retained transcript where the needle first appeared.' },
|
|
320
|
+
column: { type: 'integer', required: true, description: '0-based column index within that line where the match starts.' },
|
|
321
|
+
elapsedMs: { type: 'integer', required: true, description: 'Wall-clock milliseconds from wait start to match.' },
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
type: 'object',
|
|
326
|
+
additionalProperties: false,
|
|
327
|
+
properties: {
|
|
328
|
+
kind: { type: 'string', required: true, const: 'timeout' },
|
|
329
|
+
needle: { type: 'string', required: true },
|
|
330
|
+
timeoutMs: { type: 'integer', required: true, description: 'The configured timeout that elapsed.' },
|
|
331
|
+
totalLines: { type: 'integer', required: true, description: 'Total lines retained when the timeout fired. Call terminal_read to inspect the tail.' },
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
type: 'object',
|
|
336
|
+
additionalProperties: false,
|
|
337
|
+
properties: {
|
|
338
|
+
kind: { type: 'string', required: true, const: 'exited' },
|
|
339
|
+
needle: { type: 'string', required: true },
|
|
340
|
+
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }], description: 'Exit code, if known.' },
|
|
341
|
+
exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }], description: 'Exit signal name, if killed by a signal.' },
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
},
|
|
346
|
+
render: (_args, value) => {
|
|
347
|
+
const v = value as { kind: 'found' | 'timeout' | 'exited'; needle: string; elapsedMs?: number; timeoutMs?: number; line?: number; column?: number; exitCode?: number | null; exitSignal?: string | null }
|
|
348
|
+
if (v.kind === 'found') {
|
|
349
|
+
return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsedMs}ms).` }]
|
|
350
|
+
}
|
|
351
|
+
if (v.kind === 'timeout') {
|
|
352
|
+
return [{ type: 'text', text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.` }]
|
|
353
|
+
}
|
|
354
|
+
const exitInfo = v.exitCode !== undefined && v.exitCode !== null ? ` (exit code ${v.exitCode})` : ''
|
|
355
|
+
return [{ type: 'text', text: `Terminal exited before "${v.needle}" appeared${exitInfo}.` }]
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
async execute(args: { uuid: string; needle: string; timeout_ms?: number }, exec) {
|
|
359
|
+
exec.signal.throwIfAborted()
|
|
360
|
+
const sessionId = sessionIdOf(exec)
|
|
361
|
+
registry.assertOwned(args.uuid, sessionId)
|
|
362
|
+
// The registry validates the needle (empty → bad-request) and returns
|
|
363
|
+
// the camelCase result the schema above declares directly — no
|
|
364
|
+
// field-by-field projection.
|
|
365
|
+
const timeoutMs = args.timeout_ms ?? 10_000
|
|
366
|
+
return await registry.waitFor(args.uuid, args.needle, timeoutMs, exec.signal)
|
|
367
|
+
},
|
|
368
|
+
}))
|
|
369
|
+
|
|
370
|
+
register(defineTool({
|
|
371
|
+
name: 'terminal_resize',
|
|
372
|
+
description:
|
|
373
|
+
'Resize an agent terminal\'s pty ( cols × rows ). The host clamps both to a 2..1024 sane range. '
|
|
374
|
+
+ 'Most shells redraw their prompt and any full-screen TUI on the next output frame. '
|
|
375
|
+
+ 'No-op if the terminal has exited. Returns the dimensions actually applied.',
|
|
376
|
+
parameters: {
|
|
377
|
+
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
|
|
378
|
+
cols: { type: 'integer', required: true, description: 'New column count ( clamped to 2..1024 ).' },
|
|
379
|
+
rows: { type: 'integer', required: true, description: 'New row count ( clamped to 2..1024 ).' },
|
|
380
|
+
},
|
|
381
|
+
output: {
|
|
382
|
+
schema: {
|
|
383
|
+
type: 'object',
|
|
384
|
+
additionalProperties: false,
|
|
385
|
+
properties: {
|
|
386
|
+
uuid: { type: 'string', required: true },
|
|
387
|
+
cols: { type: 'integer', required: true },
|
|
388
|
+
rows: { type: 'integer', required: true },
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
render: textRender((v: { uuid: string; cols: number; rows: number }) =>
|
|
392
|
+
`Resized terminal ${v.uuid} to ${v.cols}×${v.rows}.`,
|
|
393
|
+
),
|
|
394
|
+
},
|
|
395
|
+
execute: (args: { uuid: string; cols: number; rows: number }, exec) => {
|
|
396
|
+
exec.signal.throwIfAborted()
|
|
397
|
+
const sessionId = sessionIdOf(exec)
|
|
398
|
+
registry.assertOwned(args.uuid, sessionId)
|
|
399
|
+
const dims = registry.resize(args.uuid, args.cols, args.rows)
|
|
400
|
+
return Promise.resolve({ uuid: args.uuid, ...dims })
|
|
401
|
+
},
|
|
402
|
+
}))
|
|
403
|
+
|
|
404
|
+
register(defineTool({
|
|
405
|
+
name: 'terminal_signal',
|
|
406
|
+
description:
|
|
407
|
+
'Send a POSIX signal to an agent terminal\'s foreground process — this is how you send Ctrl+C, Ctrl+Z, etc. '
|
|
408
|
+
+ 'Use signal="SIGINT" for Ctrl+C (interrupt the running command), signal="SIGTERM" to request termination, '
|
|
409
|
+
+ 'signal="SIGKILL" to force-kill the pty, signal="SIGHUP" to hang up (many shells exit), signal="SIGTSTP" for Ctrl+Z (suspend). '
|
|
410
|
+
+ 'Do NOT try to send control characters (like "\\u0003") through terminal_send — use this tool instead. '
|
|
411
|
+
+ 'On Windows, only SIGKILL and SIGTERM are effective — others are accepted but may no-op. '
|
|
412
|
+
+ 'No-op if the terminal has already exited. Use terminal_close to dispose of the terminal entirely.',
|
|
413
|
+
parameters: {
|
|
414
|
+
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
|
|
415
|
+
signal: {
|
|
416
|
+
type: 'string',
|
|
417
|
+
required: true,
|
|
418
|
+
enum: ALLOWED_SIGNALS as readonly string[],
|
|
419
|
+
description: 'Signal to deliver: SIGINT (Ctrl+C) | SIGTERM | SIGKILL | SIGHUP | SIGTSTP (Ctrl+Z).',
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
output: {
|
|
423
|
+
schema: {
|
|
424
|
+
type: 'object',
|
|
425
|
+
additionalProperties: false,
|
|
426
|
+
properties: {
|
|
427
|
+
uuid: { type: 'string', required: true },
|
|
428
|
+
signal: { type: 'string', required: true },
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
render: textRender((v: { uuid: string; signal: AgentTerminalSignal }) =>
|
|
432
|
+
`Sent ${v.signal} to terminal ${v.uuid}.`,
|
|
433
|
+
),
|
|
434
|
+
},
|
|
435
|
+
execute: (args: { uuid: string; signal: AgentTerminalSignal }, exec) => {
|
|
436
|
+
exec.signal.throwIfAborted()
|
|
437
|
+
const sessionId = sessionIdOf(exec)
|
|
438
|
+
registry.assertOwned(args.uuid, sessionId)
|
|
439
|
+
registry.signal(args.uuid, args.signal)
|
|
440
|
+
return Promise.resolve({ uuid: args.uuid, signal: args.signal })
|
|
441
|
+
},
|
|
442
|
+
}))
|
|
443
|
+
|
|
444
|
+
register(defineTool({
|
|
445
|
+
name: 'terminal_close',
|
|
446
|
+
description:
|
|
447
|
+
'Close an agent terminal and release its process. The uuid becomes invalid for all subsequent tool calls. '
|
|
448
|
+
+ 'Idempotent: closing an already-closed uuid is a no-op. '
|
|
449
|
+
+ 'The corresponding sidebar tab is removed automatically when the host pushes the updated terminal list. '
|
|
450
|
+
+ 'Always close terminals you no longer need — the host keeps the pty alive until you do.',
|
|
451
|
+
parameters: {
|
|
452
|
+
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
|
|
453
|
+
},
|
|
454
|
+
output: {
|
|
455
|
+
schema: {
|
|
456
|
+
type: 'object',
|
|
457
|
+
additionalProperties: false,
|
|
458
|
+
properties: {
|
|
459
|
+
uuid: { type: 'string', required: true },
|
|
460
|
+
closed: { type: 'boolean', required: true, description: 'Whether a live terminal was actually dropped (false if the uuid was already gone).' },
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
render: textRender((v: { uuid: string; closed: boolean }) =>
|
|
464
|
+
v.closed ? `Closed terminal ${v.uuid}.` : `Terminal ${v.uuid} was already closed.`,
|
|
465
|
+
),
|
|
466
|
+
},
|
|
467
|
+
execute: (args: { uuid: string }, exec) => {
|
|
468
|
+
exec.signal.throwIfAborted()
|
|
469
|
+
const sessionId = sessionIdOf(exec)
|
|
470
|
+
registry.assertOwned(args.uuid, sessionId)
|
|
471
|
+
const closed = registry.close(args.uuid)
|
|
472
|
+
return Promise.resolve({ uuid: args.uuid, closed })
|
|
473
|
+
},
|
|
474
|
+
}))
|
|
475
|
+
|
|
476
|
+
return () => {
|
|
477
|
+
for (const dispose of disposers) dispose()
|
|
478
|
+
}
|
|
479
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-trust fence for the sidebar routes, behaviorally identical to the
|
|
3
|
+
* /api gateway's fence in @deepseek-ai/dsh-client-connection
|
|
4
|
+
* (src/api-request-trust.ts + src/loopback-hostname.ts, BSD-3-Clause,
|
|
5
|
+
* copied here because the package does not export these helpers and the
|
|
6
|
+
* plugin must not depend on its internals). Host-header loopback or a
|
|
7
|
+
* configured trusted authority passes; cross-site browser markers refuse.
|
|
8
|
+
* This is a DNS-rebinding / cross-site defense, not authentication.
|
|
9
|
+
*/
|
|
10
|
+
import type { IncomingHttpHeaders } from 'node:http'
|
|
11
|
+
|
|
12
|
+
/** The request facts the fence reads (structural subset of IncomingMessage). */
|
|
13
|
+
interface ApiTrustRequest {
|
|
14
|
+
headers: IncomingHttpHeaders
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
|
18
|
+
const value = headers[name]
|
|
19
|
+
return typeof value === 'string' ? value : undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Normalized URL of a Host-header authority, or undefined when unparsable. */
|
|
23
|
+
function parseAuthority(authority: string): URL | undefined {
|
|
24
|
+
try {
|
|
25
|
+
return new URL(`http://${authority}`)
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether a normalized URL hostname names the local loopback authority. */
|
|
32
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
33
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
34
|
+
const parts = hostname.split('.')
|
|
35
|
+
return parts.length === 4
|
|
36
|
+
&& parts[0] === '127'
|
|
37
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Canonical authority form: hostname, or hostname:port when a port was written. */
|
|
41
|
+
function canonicalAuthority(entry: string, entryUrl: URL): string {
|
|
42
|
+
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
|
|
43
|
+
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether the request authority matches a trustedHosts entry (exact or port-less). */
|
|
47
|
+
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
|
|
48
|
+
return trustedHosts.some((entry) => {
|
|
49
|
+
const entryUrl = parseAuthority(entry)
|
|
50
|
+
if (entryUrl === undefined) return false
|
|
51
|
+
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
|
|
52
|
+
? entryUrl.hostname === hostUrl.hostname
|
|
53
|
+
: entryUrl.host === hostUrl.host
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Decide whether one sidebar request may reach the plugin routes.
|
|
59
|
+
* @param request - node HTTP request facts (headers).
|
|
60
|
+
* @param trustedHosts - non-loopback authorities this deployment serves.
|
|
61
|
+
* @returns true when the Host is ours (loopback or trusted) and browser markers are same-origin.
|
|
62
|
+
*/
|
|
63
|
+
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
|
|
64
|
+
const host = header(request.headers, 'host')
|
|
65
|
+
if (host === undefined) return false
|
|
66
|
+
const hostUrl = parseAuthority(host)
|
|
67
|
+
if (hostUrl === undefined) return false
|
|
68
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
|
|
69
|
+
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
|
|
70
|
+
const origin = header(request.headers, 'origin')
|
|
71
|
+
if (origin === undefined) return true
|
|
72
|
+
try {
|
|
73
|
+
return new URL(origin).host === hostUrl.host
|
|
74
|
+
} catch {
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/wire.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire helpers for the /sidebar JSON API: bounded body reading, response
|
|
3
|
+
* writing, and the shared error envelope. Every API method returns
|
|
4
|
+
* `{ok: true, value}` on success and `{ok: false, error: {code, message}}`
|
|
5
|
+
* (HTTP 4xx/5xx matching the code) on failure.
|
|
6
|
+
*/
|
|
7
|
+
import type { SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
|
|
8
|
+
|
|
9
|
+
/** Machine-readable error codes of the sidebar API. */
|
|
10
|
+
export type SidebarErrorCode =
|
|
11
|
+
| 'bad-request'
|
|
12
|
+
| 'not-found'
|
|
13
|
+
| 'forbidden'
|
|
14
|
+
| 'method-error'
|
|
15
|
+
| 'too-large'
|
|
16
|
+
| 'fs-error'
|
|
17
|
+
| 'git-error'
|
|
18
|
+
| 'pty-error'
|
|
19
|
+
| 'pty-deps-missing'
|
|
20
|
+
| 'job-error'
|
|
21
|
+
| 'sidechat-error'
|
|
22
|
+
| 'subagents-unavailable'
|
|
23
|
+
| 'settings-rejected'
|
|
24
|
+
| 'settings-conflict'
|
|
25
|
+
| 'internal'
|
|
26
|
+
|
|
27
|
+
/** One API failure with its wire code and HTTP status. */
|
|
28
|
+
export class SidebarError extends Error {
|
|
29
|
+
constructor(
|
|
30
|
+
readonly code: SidebarErrorCode,
|
|
31
|
+
message: string,
|
|
32
|
+
readonly status = 400,
|
|
33
|
+
) {
|
|
34
|
+
super(message)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Body size bound of one JSON request (defense against unbounded reads). */
|
|
39
|
+
const MAX_BODY_BYTES = 1 << 20
|
|
40
|
+
|
|
41
|
+
/** Success envelope of one API method. */
|
|
42
|
+
export interface SidebarOk<T> { ok: true; value: T }
|
|
43
|
+
|
|
44
|
+
/** Failure envelope of one API method. */
|
|
45
|
+
export interface SidebarErr { ok: false; error: { code: SidebarErrorCode; message: string } }
|
|
46
|
+
|
|
47
|
+
/** Read and parse the JSON request body (bounded; malformed → bad-request). */
|
|
48
|
+
export async function readJsonBody(req: SidebarHttpRequest): Promise<unknown> {
|
|
49
|
+
const chunks: Buffer[] = []
|
|
50
|
+
let total = 0
|
|
51
|
+
for await (const chunk of req) {
|
|
52
|
+
// The structural request yields string | Uint8Array; Buffer.from accepts
|
|
53
|
+
// both (and the real runtime chunks are node Buffers anyway).
|
|
54
|
+
const buffer = Buffer.from(chunk)
|
|
55
|
+
total += buffer.length
|
|
56
|
+
if (total > MAX_BODY_BYTES) {
|
|
57
|
+
throw new SidebarError('bad-request', 'request body too large')
|
|
58
|
+
}
|
|
59
|
+
chunks.push(buffer)
|
|
60
|
+
}
|
|
61
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
62
|
+
if (text.trim() === '') return {}
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(text) as unknown
|
|
65
|
+
} catch {
|
|
66
|
+
throw new SidebarError('bad-request', 'request body is not valid JSON')
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Write a JSON response with the given status. */
|
|
71
|
+
export function writeJson(res: SidebarHttpResponse, status: number, body: unknown): void {
|
|
72
|
+
const payload = JSON.stringify(body)
|
|
73
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
74
|
+
res.end(payload)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Write the success envelope. */
|
|
78
|
+
export function writeOk(res: SidebarHttpResponse, value: unknown): void {
|
|
79
|
+
writeJson(res, 200, { ok: true, value })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Write the failure envelope for any thrown value (unknown → internal 500). */
|
|
83
|
+
export function writeError(res: SidebarHttpResponse, error: unknown): void {
|
|
84
|
+
if (error instanceof SidebarError) {
|
|
85
|
+
writeJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
89
|
+
writeJson(res, 500, { ok: false, error: { code: 'internal', message } })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Narrow an unknown payload value to a string, else throw bad-request. */
|
|
93
|
+
export function requireString(payload: unknown, key: string): string {
|
|
94
|
+
const record = payload as Record<string, unknown> | null
|
|
95
|
+
const value = record?.[key]
|
|
96
|
+
if (typeof value !== 'string' || value === '') {
|
|
97
|
+
throw new SidebarError('bad-request', `missing or invalid "${key}"`)
|
|
98
|
+
}
|
|
99
|
+
return value
|
|
100
|
+
}
|