@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/index.ts
ADDED
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
|
|
3
|
+
* read/write, git), the /sidebar/file media route (images), the /sidebar/html
|
|
4
|
+
* preview route, the /sidebar/bundle lazy-chunk route (client code splits),
|
|
5
|
+
* and the terminal WebSocket upgrade. Every route passes the same
|
|
6
|
+
* browser-trust fence as the /api gateway — Host-header loopback or the
|
|
7
|
+
* web runtime's `trustedHosts` (LAN IP literals sampled at boot plus
|
|
8
|
+
* `--trusted-host` authorities), read per request from the live service
|
|
9
|
+
* value so the fence tracks the same trust source the /api gateway derives
|
|
10
|
+
* its list from.
|
|
11
|
+
*
|
|
12
|
+
* All operations are conversation-scoped: requests carry a sessionId, the
|
|
13
|
+
* session's authoritative cwd comes from the session store, and terminal
|
|
14
|
+
* processes are keyed by session.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
17
|
+
import { basename, dirname, extname, isAbsolute, join } from 'node:path'
|
|
18
|
+
import type { IncomingMessage } from 'node:http'
|
|
19
|
+
import type { Duplex } from 'node:stream'
|
|
20
|
+
import { WebSocket, WebSocketServer } from 'ws'
|
|
21
|
+
import type { Context, SidebarHttpRequest } from './context-types.ts'
|
|
22
|
+
import {
|
|
23
|
+
Config,
|
|
24
|
+
PrefsSchema,
|
|
25
|
+
resolveSidebarConfig,
|
|
26
|
+
SIDEBAR_PREFS_NS,
|
|
27
|
+
type ResolvedSidebarConfig,
|
|
28
|
+
type SidebarConfig,
|
|
29
|
+
type SidebarPrefs,
|
|
30
|
+
} from './config.ts'
|
|
31
|
+
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
|
|
32
|
+
import { writeWorkspaceUpload } from './fs-operations.ts'
|
|
33
|
+
import { searchFiles } from './fs-search.ts'
|
|
34
|
+
import { decodeHtmlUrl } from './html-route.ts'
|
|
35
|
+
import { extractFrameAncestors } from './browser-probe.ts'
|
|
36
|
+
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
|
|
37
|
+
import { registerBundleRoute } from './bundle-route.ts'
|
|
38
|
+
import { launchExternal } from './open-external.ts'
|
|
39
|
+
import * as git from './git.ts'
|
|
40
|
+
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
41
|
+
import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts'
|
|
42
|
+
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
|
|
43
|
+
import {
|
|
44
|
+
DSH_NODE_PTY_RANGE,
|
|
45
|
+
depsStatus,
|
|
46
|
+
loadNodePty,
|
|
47
|
+
PTY_DEPS_MISSING,
|
|
48
|
+
} from './pty-deps.ts'
|
|
49
|
+
import { registerTools } from './tools.ts'
|
|
50
|
+
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
|
|
51
|
+
import { buildSubagentLiveApi, type SidebarSubagentLiveRoutes } from './subagent-live-route.ts'
|
|
52
|
+
import { buildSidechatApi } from './sidechat-routes.ts'
|
|
53
|
+
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
|
54
|
+
|
|
55
|
+
export { Config }
|
|
56
|
+
export type { SidebarConfig, ResolvedSidebarConfig }
|
|
57
|
+
// Re-export the Context augmentation (declare module 'cordis') so consumers
|
|
58
|
+
// `import type {} from 'dsh-better-sidebar'` and gain `ctx.betterSidebar`.
|
|
59
|
+
// Also re-export the service descriptor types so consumers can type their
|
|
60
|
+
// registerTab / registerFileViewer arguments without reaching into /client.
|
|
61
|
+
export type { Context } from './context-types.ts'
|
|
62
|
+
export type {
|
|
63
|
+
BetterSidebarService,
|
|
64
|
+
TabDescriptor,
|
|
65
|
+
TabComponentProps,
|
|
66
|
+
FileViewerDescriptor,
|
|
67
|
+
FileViewerProps,
|
|
68
|
+
FileFetchStrategy,
|
|
69
|
+
} from './client/service.ts'
|
|
70
|
+
|
|
71
|
+
/** Plugin identity for cordis.yml rows. */
|
|
72
|
+
export const name = 'dsh-better-sidebar'
|
|
73
|
+
|
|
74
|
+
/** Services required before mounting: the webserver routes, the session store, the web runtime's trusted hosts, and the tool registry. */
|
|
75
|
+
export const inject = ['webServer', 'sessions', 'webRuntime', 'tools']
|
|
76
|
+
|
|
77
|
+
/** Content types for the media route, by extension. */
|
|
78
|
+
const MEDIA_TYPES: Record<string, string> = {
|
|
79
|
+
'.png': 'image/png',
|
|
80
|
+
'.jpg': 'image/jpeg',
|
|
81
|
+
'.jpeg': 'image/jpeg',
|
|
82
|
+
'.gif': 'image/gif',
|
|
83
|
+
'.webp': 'image/webp',
|
|
84
|
+
'.svg': 'image/svg+xml',
|
|
85
|
+
'.bmp': 'image/bmp',
|
|
86
|
+
'.ico': 'image/x-icon',
|
|
87
|
+
'.avif': 'image/avif',
|
|
88
|
+
'.pdf': 'application/pdf',
|
|
89
|
+
'.html': 'text/html',
|
|
90
|
+
'.htm': 'text/html',
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
|
|
94
|
+
export function mediaTypeForPath(path: string): string {
|
|
95
|
+
return MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a session's authoritative working directory. The attached session
|
|
100
|
+
* header wins; while the session is still hydrating from persistence (the
|
|
101
|
+
* web client attaches the current conversation a moment after page load, so
|
|
102
|
+
* the very first sidebar requests can arrive detached) the caller's own
|
|
103
|
+
* list-summary cwd is used; the process cwd is the last resort (blank
|
|
104
|
+
* sessions have no cwd anywhere yet). Never throws for a missing cwd, so
|
|
105
|
+
* explorer/git/terminal work from first paint instead of surfacing
|
|
106
|
+
* "session ... has no working directory".
|
|
107
|
+
*/
|
|
108
|
+
function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): string {
|
|
109
|
+
const session = ctx.sessions.get(sessionId)
|
|
110
|
+
const headerCwd = session?.header.cwd
|
|
111
|
+
if (headerCwd !== undefined && headerCwd !== '') return headerCwd
|
|
112
|
+
if (clientCwd !== undefined && clientCwd !== '') {
|
|
113
|
+
try {
|
|
114
|
+
return requireAbsolute(clientCwd)
|
|
115
|
+
} catch {
|
|
116
|
+
throw new SidebarError('bad-request', `invalid working directory "${clientCwd}"`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return process.cwd()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve a path that a git command reported — `git status`/`git diff`
|
|
124
|
+
* print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the
|
|
125
|
+
* session cwd (a session inside a subdirectory of a repository). Absolute
|
|
126
|
+
* paths pass through; relative ones join the repo root (falling back to the
|
|
127
|
+
* cwd when the root cannot be resolved, e.g. a bare directory).
|
|
128
|
+
*/
|
|
129
|
+
async function resolveGitPath(cwd: string, raw: string): Promise<string> {
|
|
130
|
+
if (isAbsolute(raw)) return requireAbsolute(raw)
|
|
131
|
+
const root = await git.repoRoot(cwd).catch(() => cwd)
|
|
132
|
+
return requireAbsolute(join(root, raw))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** How many leading bytes a binary read returns for client-side detect sniffing. */
|
|
136
|
+
const READ_HEAD_LIMIT = 4096
|
|
137
|
+
|
|
138
|
+
/** Text read of a file with the size cap; binary detection via NUL probe.
|
|
139
|
+
* Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64)
|
|
140
|
+
* so the client can re-match viewers by content (`detect`). */
|
|
141
|
+
async function readText(path: string, readLimit: number): Promise<{
|
|
142
|
+
content: string
|
|
143
|
+
truncated: boolean
|
|
144
|
+
binary: boolean
|
|
145
|
+
size: number
|
|
146
|
+
head?: string
|
|
147
|
+
}> {
|
|
148
|
+
const info = await stat(path).catch((error: unknown) => {
|
|
149
|
+
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
|
150
|
+
})
|
|
151
|
+
if (info.isDirectory()) {
|
|
152
|
+
throw new SidebarError('fs-error', `"${path}" is a directory`, 400)
|
|
153
|
+
}
|
|
154
|
+
const size = info.size
|
|
155
|
+
const truncated = size > readLimit
|
|
156
|
+
const handle = await open(path, 'r').catch((error: unknown) => {
|
|
157
|
+
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
|
158
|
+
})
|
|
159
|
+
try {
|
|
160
|
+
const buffer = Buffer.alloc(Math.min(size, readLimit))
|
|
161
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
|
|
162
|
+
const slice = buffer.subarray(0, bytesRead)
|
|
163
|
+
const binary = slice.includes(0)
|
|
164
|
+
const head = binary
|
|
165
|
+
? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64')
|
|
166
|
+
: undefined
|
|
167
|
+
return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size, head }
|
|
168
|
+
} finally {
|
|
169
|
+
await handle.close()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** One API method dispatch table entry. */
|
|
174
|
+
type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The live face of the side card settings namespace, bound to the settings
|
|
178
|
+
* service when it is mounted. The DSH settings RPC domain only serves
|
|
179
|
+
* allowlisted namespaces (api-proxy exposedNamespaces), so the client reads
|
|
180
|
+
* and writes THIS namespace through the plugin's own fenced /sidebar routes,
|
|
181
|
+
* which call the seam in-process — no configuration-client gate involved.
|
|
182
|
+
*/
|
|
183
|
+
export interface SidebarSettingsFace {
|
|
184
|
+
/** The current resolved value + revision (undefined while the settings service is absent). */
|
|
185
|
+
get(): { value?: unknown; revision?: number }
|
|
186
|
+
/**
|
|
187
|
+
* Whether the dsh-web-ui family's aionui-panel has been selected as the
|
|
188
|
+
* right-panel provider (the `aionui-panel` settings namespace resolves
|
|
189
|
+
* `rightPanel: 'aionui-panel'`). While true the sidebar must not mount —
|
|
190
|
+
* the two right panels are mutually exclusive. False when the namespace is
|
|
191
|
+
* absent (no aionui installed) or the provider is anything else.
|
|
192
|
+
*/
|
|
193
|
+
externalDisable(): boolean
|
|
194
|
+
/** Merge a patch (revision-guarded) and return the fresh resolved view. */
|
|
195
|
+
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, resolved config, and effective terminal shell. */
|
|
199
|
+
/**
|
|
200
|
+
* Resolve the settings-page terminal shell overrides (the terminal card's
|
|
201
|
+
* gear rows). Empty fields mean "unset": keep the yaml `config.shell` /
|
|
202
|
+
* `shellArgs` (or the platform auto-resolution). The settings page is the
|
|
203
|
+
* runtime complement to the boot-time yaml — same contract, later binding:
|
|
204
|
+
* the values here win for terminals opened afterwards.
|
|
205
|
+
*/
|
|
206
|
+
function shellOverridesOf(getSettings: () => SidebarSettingsFace | undefined): { shell?: string; shellArgs?: string[] } {
|
|
207
|
+
const settings = getSettings()
|
|
208
|
+
const value = settings?.get().value
|
|
209
|
+
if (value === null || typeof value !== 'object') return {}
|
|
210
|
+
const record = value as Record<string, unknown>
|
|
211
|
+
const shell = typeof record.terminalShell === 'string' ? record.terminalShell.trim() : ''
|
|
212
|
+
const args = typeof record.terminalShellArgs === 'string' ? record.terminalShellArgs.trim() : ''
|
|
213
|
+
return {
|
|
214
|
+
shell: shell === '' ? undefined : shell,
|
|
215
|
+
shellArgs: args === '' ? undefined : args.split(/\s+/).filter(Boolean),
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function buildApi(
|
|
220
|
+
ctx: Context,
|
|
221
|
+
ptyManager: PtyManager | null,
|
|
222
|
+
agentPtyRegistry: AgentPtyRegistry | null,
|
|
223
|
+
resolved: ResolvedSidebarConfig,
|
|
224
|
+
terminalShell: string,
|
|
225
|
+
getSettings: () => SidebarSettingsFace | undefined,
|
|
226
|
+
): Record<string, ApiMethod> {
|
|
227
|
+
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
|
|
228
|
+
const sessionId = requireString(payload, 'sessionId')
|
|
229
|
+
const record = payload as { cwd?: unknown } | null
|
|
230
|
+
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
|
|
231
|
+
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
|
|
232
|
+
}
|
|
233
|
+
// Read-only by default: the sidebar REFUSES every file write and git
|
|
234
|
+
// mutation unless the session has been explicitly switched to "editable"
|
|
235
|
+
// through the editor toolbar toggle (config.set-editable). The set is
|
|
236
|
+
// in-memory and per-session, so a fresh session, a server restart, or a
|
|
237
|
+
// missed "back to read-only" toggle can never silently write — the safe
|
|
238
|
+
// default wins. The client mirror lives in src/client/editable-state.ts.
|
|
239
|
+
const editableSessions = new Set<string>()
|
|
240
|
+
const requireWritable = (sessionId: string): void => {
|
|
241
|
+
if (!editableSessions.has(sessionId)) {
|
|
242
|
+
throw new SidebarError('forbidden', 'read-only mode: switch the editor to "editable" to modify files', 403)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Background jobs: the LIST rides the harness's `session/jobs` push
|
|
246
|
+
// mirror, so these routes only replay output the model has read (from the
|
|
247
|
+
// session's own event log — no DSH source is touched, the model's
|
|
248
|
+
// job_output cursor is never consumed) and kill (the registry's stock
|
|
249
|
+
// API). A deployment without the jobs registry downgrades kill to a 503.
|
|
250
|
+
const jobsApi: SidebarJobsRoutes = buildJobsApi(ctx, resolved.readLimit)
|
|
251
|
+
// Subagent live previews: one batch request instead of N per-child
|
|
252
|
+
// `subagents.history` calls. The route degrades to a 503 when the host
|
|
253
|
+
// subagent runtime is absent (the page has no topology to show anyway).
|
|
254
|
+
const subagentLiveApi: SidebarSubagentLiveRoutes = buildSubagentLiveApi(ctx)
|
|
255
|
+
return {
|
|
256
|
+
'session.cwd': (payload) => {
|
|
257
|
+
const { sessionId, cwd } = cwdOf(payload)
|
|
258
|
+
return { sessionId, cwd, root: rootLabel(cwd), parent: parentOf(cwd) ?? null }
|
|
259
|
+
},
|
|
260
|
+
'fs.tree': async (payload) => {
|
|
261
|
+
const { cwd } = cwdOf(payload)
|
|
262
|
+
const record = payload as { path?: unknown }
|
|
263
|
+
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
|
|
264
|
+
return listDirectory(target, resolved.listLimit)
|
|
265
|
+
},
|
|
266
|
+
'fs.search': async (payload) => {
|
|
267
|
+
// The editor side panel's global name search: rooted at the session
|
|
268
|
+
// cwd (not caller-targetable — the walk is unbounded by design and
|
|
269
|
+
// must never escape the workspace), budgeted inside searchFiles.
|
|
270
|
+
const { cwd } = cwdOf(payload)
|
|
271
|
+
const query = requireString(payload, 'query')
|
|
272
|
+
return searchFiles(cwd, query)
|
|
273
|
+
},
|
|
274
|
+
'fs.read': async (payload) => {
|
|
275
|
+
const { cwd } = cwdOf(payload)
|
|
276
|
+
// Relative paths are git-derived (status/diff report repo-root-relative
|
|
277
|
+
// names; the untracked diff view reads the file through this route).
|
|
278
|
+
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
|
|
279
|
+
const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit)
|
|
280
|
+
if (binary) return { kind: 'binary', size, truncated, head }
|
|
281
|
+
return { kind: 'text', content, truncated }
|
|
282
|
+
},
|
|
283
|
+
'fs.write': async (payload) => {
|
|
284
|
+
const { sessionId, cwd } = cwdOf(payload)
|
|
285
|
+
requireWritable(sessionId)
|
|
286
|
+
const path = requireAbsolute(requireString(payload, 'path'))
|
|
287
|
+
const content = requireString(payload, 'content')
|
|
288
|
+
const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`
|
|
289
|
+
try {
|
|
290
|
+
await mkdir(dirname(path), { recursive: true })
|
|
291
|
+
await writeFile(tmp, content, 'utf8')
|
|
292
|
+
await rename(tmp, path)
|
|
293
|
+
} catch (error) {
|
|
294
|
+
await rm(tmp, { force: true }).catch(() => {})
|
|
295
|
+
throw new SidebarError('fs-error', `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
|
296
|
+
}
|
|
297
|
+
return { ok: true }
|
|
298
|
+
},
|
|
299
|
+
'git.status': async (payload) => {
|
|
300
|
+
const { cwd } = cwdOf(payload)
|
|
301
|
+
return git.status(cwd)
|
|
302
|
+
},
|
|
303
|
+
'git.diff': async (payload) => {
|
|
304
|
+
const { cwd } = cwdOf(payload)
|
|
305
|
+
const record = payload as { path?: unknown; staged?: unknown }
|
|
306
|
+
const path = record.path === undefined ? undefined : await resolveGitPath(cwd, requireString(payload, 'path'))
|
|
307
|
+
return { diff: await git.diff(cwd, path, record.staged === true) }
|
|
308
|
+
},
|
|
309
|
+
'git.stage': async (payload) => {
|
|
310
|
+
const { sessionId, cwd } = cwdOf(payload)
|
|
311
|
+
requireWritable(sessionId)
|
|
312
|
+
const record = payload as { path?: unknown }
|
|
313
|
+
const path = record.path === undefined ? undefined : requireString(payload, 'path')
|
|
314
|
+
await git.stage(cwd, path)
|
|
315
|
+
return { ok: true }
|
|
316
|
+
},
|
|
317
|
+
'git.unstage': async (payload) => {
|
|
318
|
+
const { sessionId, cwd } = cwdOf(payload)
|
|
319
|
+
requireWritable(sessionId)
|
|
320
|
+
const record = payload as { path?: unknown }
|
|
321
|
+
const path = record.path === undefined ? undefined : requireString(payload, 'path')
|
|
322
|
+
await git.unstage(cwd, path)
|
|
323
|
+
return { ok: true }
|
|
324
|
+
},
|
|
325
|
+
'git.commit': async (payload) => {
|
|
326
|
+
const { sessionId, cwd } = cwdOf(payload)
|
|
327
|
+
requireWritable(sessionId)
|
|
328
|
+
const message = requireString(payload, 'message')
|
|
329
|
+
await git.commit(cwd, message)
|
|
330
|
+
return { ok: true }
|
|
331
|
+
},
|
|
332
|
+
'git.branch': async (payload) => {
|
|
333
|
+
const { cwd } = cwdOf(payload)
|
|
334
|
+
return git.branches(cwd)
|
|
335
|
+
},
|
|
336
|
+
'git.checkout': async (payload) => {
|
|
337
|
+
const { sessionId, cwd } = cwdOf(payload)
|
|
338
|
+
requireWritable(sessionId)
|
|
339
|
+
await git.checkout(cwd, requireString(payload, 'branch'))
|
|
340
|
+
return { ok: true }
|
|
341
|
+
},
|
|
342
|
+
'git.log': async (payload) => {
|
|
343
|
+
const { cwd } = cwdOf(payload)
|
|
344
|
+
const record = payload as { count?: unknown; skip?: unknown }
|
|
345
|
+
const count = typeof record.count === 'number' && Number.isInteger(record.count) && record.count > 0
|
|
346
|
+
? record.count
|
|
347
|
+
: undefined
|
|
348
|
+
const skip = typeof record.skip === 'number' && Number.isInteger(record.skip) && record.skip >= 0
|
|
349
|
+
? record.skip
|
|
350
|
+
: undefined
|
|
351
|
+
return git.log(cwd, count, skip)
|
|
352
|
+
},
|
|
353
|
+
'git.commit-diff': async (payload) => {
|
|
354
|
+
const { cwd } = cwdOf(payload)
|
|
355
|
+
return { diff: await git.commitDiff(cwd, requireString(payload, 'hash')) }
|
|
356
|
+
},
|
|
357
|
+
'git.discard': async (payload) => {
|
|
358
|
+
const { sessionId, cwd } = cwdOf(payload)
|
|
359
|
+
requireWritable(sessionId)
|
|
360
|
+
await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path')))
|
|
361
|
+
return { ok: true }
|
|
362
|
+
},
|
|
363
|
+
'git.revert': async (payload) => {
|
|
364
|
+
const { sessionId, cwd } = cwdOf(payload)
|
|
365
|
+
requireWritable(sessionId)
|
|
366
|
+
await git.revert(cwd, requireString(payload, 'hash'))
|
|
367
|
+
return { ok: true }
|
|
368
|
+
},
|
|
369
|
+
'git.cherry-pick': async (payload) => {
|
|
370
|
+
const { sessionId, cwd } = cwdOf(payload)
|
|
371
|
+
requireWritable(sessionId)
|
|
372
|
+
await git.cherryPick(cwd, requireString(payload, 'hash'))
|
|
373
|
+
return { ok: true }
|
|
374
|
+
},
|
|
375
|
+
'git.show': async (payload) => {
|
|
376
|
+
const { cwd } = cwdOf(payload)
|
|
377
|
+
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
|
|
378
|
+
const rev = requireString(payload, 'rev')
|
|
379
|
+
return { content: await git.show(cwd, rev, path) }
|
|
380
|
+
},
|
|
381
|
+
// Release a terminal immediately. The WebSocket close frame already does
|
|
382
|
+
// this while the socket is open; this route covers the tab-close that
|
|
383
|
+
// happens while the socket is down (reconnect loop), so a closed tab can
|
|
384
|
+
// never hold the per-session quota until the reconnect grace expires.
|
|
385
|
+
'pty.close': (payload) => {
|
|
386
|
+
const sessionId = requireString(payload, 'sessionId')
|
|
387
|
+
const tab = requireString(payload, 'tab')
|
|
388
|
+
// Degraded mode (node-pty unavailable): no live pty can exist, so a
|
|
389
|
+
// no-op ok is the honest answer — never an error the client must show.
|
|
390
|
+
ptyManager?.close(`${sessionId}:${tab}`)
|
|
391
|
+
return { ok: true }
|
|
392
|
+
},
|
|
393
|
+
// Release an agent terminal by uuid. The WS close frame already does
|
|
394
|
+
// this while the socket is open; this route covers the tab-close that
|
|
395
|
+
// happens while the socket is down (reconnect loop) so a closed agent
|
|
396
|
+
// tab never leaves a zombie pty behind. Idempotent.
|
|
397
|
+
'agent-pty.close': (payload) => {
|
|
398
|
+
const uuid = requireString(payload, 'uuid')
|
|
399
|
+
agentPtyRegistry?.close(uuid)
|
|
400
|
+
return { ok: true }
|
|
401
|
+
},
|
|
402
|
+
// Terminal dependency status (issue #140): after a WS close 1011 with
|
|
403
|
+
// reason `pty-deps-missing` the client fetches the full repair details
|
|
404
|
+
// here — the close reason itself is capped at 123 bytes, too small for
|
|
405
|
+
// the pasteable command.
|
|
406
|
+
'terminal.deps': () => depsStatus(),
|
|
407
|
+
// Background jobs: read one job's output (a REPLAY of what the model
|
|
408
|
+
// has read so far, from the owner session's event log — the model's
|
|
409
|
+
// job_output cursor is never touched, so the human pane can never steal
|
|
410
|
+
// the agent's bytes), and kill one job. The job LIST itself arrives
|
|
411
|
+
// through the harness's session/jobs push mirror, so no list route
|
|
412
|
+
// exists. Kill is fenced to the owning session by the jobs registry.
|
|
413
|
+
'jobs.output': (payload) => jobsApi.output(payload),
|
|
414
|
+
'jobs.kill': (payload) => jobsApi.kill(payload),
|
|
415
|
+
// Subagent live previews: one batch request per refresh; the route folds
|
|
416
|
+
// the newest text/tool activity of every running child in the tree.
|
|
417
|
+
'subagents.live': (payload) => subagentLiveApi.live(payload),
|
|
418
|
+
// The effective terminal shell and its display name. The client uses
|
|
419
|
+
// this to title terminal tabs with the shell name instead of a numbered
|
|
420
|
+
// "Terminal N" label; the shell itself is configured through
|
|
421
|
+
// `cordis.patch.yml` (`config.shell`) or resolved by the host default.
|
|
422
|
+
'shell.get': () => ({ shell: terminalShell, name: shellDisplayName(terminalShell) }),
|
|
423
|
+
// The session's write permission (read-only by default). The client
|
|
424
|
+
// mirror (src/client/editable-state.ts) caches this per session; the
|
|
425
|
+
// host remains the source of truth so every write route can enforce it.
|
|
426
|
+
'config.get': (payload) => {
|
|
427
|
+
const { sessionId } = cwdOf(payload)
|
|
428
|
+
return { editable: editableSessions.has(sessionId) }
|
|
429
|
+
},
|
|
430
|
+
'config.set-editable': (payload) => {
|
|
431
|
+
const { sessionId } = cwdOf(payload)
|
|
432
|
+
const record = payload as { editable?: unknown } | null
|
|
433
|
+
const editable = record?.editable === true
|
|
434
|
+
if (editable) editableSessions.add(sessionId)
|
|
435
|
+
else editableSessions.delete(sessionId)
|
|
436
|
+
return { ok: true }
|
|
437
|
+
},
|
|
438
|
+
// The side card preferences. The settings service is optional in the
|
|
439
|
+
// composition; while absent the routes report undefined and the client
|
|
440
|
+
// keeps the schema defaults. Writes are revision-guarded: a stale editor
|
|
441
|
+
// is refused with settings-conflict so a concurrent change is never
|
|
442
|
+
// silently overwritten (mirror of the settings seam's own guard).
|
|
443
|
+
'settings.get': () => {
|
|
444
|
+
const settings = getSettings()
|
|
445
|
+
return settings === undefined
|
|
446
|
+
? { value: undefined, revision: undefined, externalDisable: false }
|
|
447
|
+
: { ...settings.get(), externalDisable: settings.externalDisable() }
|
|
448
|
+
},
|
|
449
|
+
'settings.update': async (payload) => {
|
|
450
|
+
const settings = getSettings()
|
|
451
|
+
if (settings === undefined) {
|
|
452
|
+
throw new SidebarError('settings-rejected', 'the settings service is not mounted in this deployment', 503)
|
|
453
|
+
}
|
|
454
|
+
const record = payload as { patch?: unknown; expectedRevision?: unknown } | null
|
|
455
|
+
const patch = record?.patch
|
|
456
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
457
|
+
throw new SidebarError('bad-request', 'patch must be a plain object')
|
|
458
|
+
}
|
|
459
|
+
const expectedRevision = typeof record?.expectedRevision === 'number' ? record.expectedRevision : undefined
|
|
460
|
+
try {
|
|
461
|
+
return await settings.update(patch as Record<string, unknown>, expectedRevision)
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (error instanceof SettingsConflictError) {
|
|
464
|
+
throw new SidebarError('settings-conflict', error.message, 409)
|
|
465
|
+
}
|
|
466
|
+
throw new SidebarError('settings-rejected', error instanceof Error ? error.message : String(error), 400)
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
// Probe a URL's RESPONSE HEADERS so the sidebar browser can explain an
|
|
470
|
+
// iframe refusal: X-Frame-Options / CSP frame-ancestors are exactly the
|
|
471
|
+
// signals the browser enforces when it refuses to embed a site. The
|
|
472
|
+
// probe is display-only (headers back to the caller), restricted to
|
|
473
|
+
// http(s) non-loopback URLs with a hard timeout, and gated by the same
|
|
474
|
+
// trust fence as every other route — a cross-site page cannot reach it.
|
|
475
|
+
'browser.probe': async (payload) => {
|
|
476
|
+
const raw = requireString(payload, 'url')
|
|
477
|
+
let parsed: URL
|
|
478
|
+
try {
|
|
479
|
+
parsed = new URL(raw)
|
|
480
|
+
} catch {
|
|
481
|
+
throw new SidebarError('bad-request', 'invalid url', 400)
|
|
482
|
+
}
|
|
483
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
484
|
+
throw new SidebarError('bad-request', 'only http/https urls can be probed', 400)
|
|
485
|
+
}
|
|
486
|
+
// Mirror the browser tab's address-bar policy: loopback stays unreachable
|
|
487
|
+
// from the sidebar, so probing it would leak nothing the tab could use.
|
|
488
|
+
if (isLoopbackHostname(parsed.hostname)) {
|
|
489
|
+
throw new SidebarError('bad-request', 'local addresses are not probed', 400)
|
|
490
|
+
}
|
|
491
|
+
const controller = new AbortController()
|
|
492
|
+
const timer = setTimeout(() => controller.abort(), 8000)
|
|
493
|
+
try {
|
|
494
|
+
let response = await fetch(parsed, { method: 'HEAD', redirect: 'follow', signal: controller.signal })
|
|
495
|
+
// Some servers answer HEAD with 405/501; retry once as GET (the
|
|
496
|
+
// body is discarded — only the headers matter).
|
|
497
|
+
if (response.status === 405 || response.status === 501) {
|
|
498
|
+
response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal })
|
|
499
|
+
}
|
|
500
|
+
const csp = response.headers.get('content-security-policy')
|
|
501
|
+
const frameAncestors = extractFrameAncestors(csp)
|
|
502
|
+
const xFrameOptions = response.headers.get('x-frame-options')
|
|
503
|
+
return {
|
|
504
|
+
reachable: true,
|
|
505
|
+
url: response.url,
|
|
506
|
+
status: response.status,
|
|
507
|
+
...(xFrameOptions !== null ? { xFrameOptions } : {}),
|
|
508
|
+
...(frameAncestors !== undefined ? { frameAncestors } : {}),
|
|
509
|
+
}
|
|
510
|
+
} catch {
|
|
511
|
+
// DNS / TLS / connection / timeout: nothing to judge — the client
|
|
512
|
+
// keeps the plain iframe.
|
|
513
|
+
return { reachable: false }
|
|
514
|
+
} finally {
|
|
515
|
+
clearTimeout(timer)
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
// External open for the file tree's "open with" menu: reveal a path in
|
|
519
|
+
// the OS file manager, or hand a custom-scheme URL (vscode://,
|
|
520
|
+
// cursor://, zed://, custom editors) to its registered handler. The
|
|
521
|
+
// client is a browser renderer where raw scheme navigation is
|
|
522
|
+
// unreliable, so the launch always goes through the host — the same
|
|
523
|
+
// fence as every other route, argv-only (no shell interpolation).
|
|
524
|
+
'open.external': (payload) => {
|
|
525
|
+
const record = payload as { action?: unknown } | null
|
|
526
|
+
const action = record?.action
|
|
527
|
+
if (action === 'reveal') return launchExternal('reveal', requireString(payload, 'path'))
|
|
528
|
+
if (action === 'url') return launchExternal('url', requireString(payload, 'url'))
|
|
529
|
+
throw new SidebarError('bad-request', 'action must be "reveal" or "url"')
|
|
530
|
+
},
|
|
531
|
+
// Side Chat: create a side-thread child seeded with the parent's full
|
|
532
|
+
// log up to now, deliver follow-ups (cold-resuming when the thread's
|
|
533
|
+
// agent is gone), abort a running thread, and release a thread's agent.
|
|
534
|
+
// Every operation runs through these routes because subagent-origin
|
|
535
|
+
// identities are fenced from the generic session RPCs (agent-lookup
|
|
536
|
+
// ownership), and the thread is created with a CUSTOM seed the stock
|
|
537
|
+
// fork APIs cannot express.
|
|
538
|
+
...buildSidechatApi(ctx),
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Plugin body: mount the fenced routes and the pty lifecycle.
|
|
544
|
+
* @param ctx - host plugin context (webServer, sessions, webRuntime).
|
|
545
|
+
* @param config - deployment-provided limits; the Loader validates against
|
|
546
|
+
* {@link Config} and fills defaults, direct callers get them from
|
|
547
|
+
* {@link resolveSidebarConfig}.
|
|
548
|
+
*/
|
|
549
|
+
export function apply(ctx: Context, config?: SidebarConfig): void {
|
|
550
|
+
// pnpm strips the executable bit from node-pty's prebuilt spawn-helper;
|
|
551
|
+
// restore it before any terminal can spawn (idempotent).
|
|
552
|
+
ensureSpawnHelper()
|
|
553
|
+
const resolved = resolveSidebarConfig(config)
|
|
554
|
+
// One shell resolution feeds BOTH terminal surfaces: the UI tabs and the
|
|
555
|
+
// model-facing terminal_* tools. They must stay in lockstep, otherwise a
|
|
556
|
+
// configured shell fixes one surface and silently leaves the other on the
|
|
557
|
+
// platform default.
|
|
558
|
+
const terminalShell = defaultShell({ explicit: resolved.shell })
|
|
559
|
+
// The web runtime's bind-derived trust list (boot-sampled LAN literals
|
|
560
|
+
// plus --trusted-host authorities) — the authoritative source the /api
|
|
561
|
+
// gateway fence derives its list from. Read per request from the live
|
|
562
|
+
// service value; a replaced list takes effect without a plugin restart.
|
|
563
|
+
const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts)
|
|
564
|
+
// node-pty is loaded lazily, never at module top level (issue #140): a
|
|
565
|
+
// missing or broken install must degrade THIS plugin — terminal tab shows
|
|
566
|
+
// a repair command, agent terminal tools stay unregistered — instead of
|
|
567
|
+
// failing the plugin load and taking the whole `dsh web` server down.
|
|
568
|
+
const nodePty = loadNodePty()
|
|
569
|
+
if (nodePty === null) {
|
|
570
|
+
const status = depsStatus()
|
|
571
|
+
const detail = status.ok
|
|
572
|
+
? 'unknown cause'
|
|
573
|
+
: `${status.cause}. Repair: ${status.command}`
|
|
574
|
+
ctx.logger?.warn(`[dsh-better-sidebar] node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${detail}`)
|
|
575
|
+
}
|
|
576
|
+
const ptyManager = nodePty !== null
|
|
577
|
+
? new PtyManager(terminalShell, resolved.terminalsPerSession, resolved.shellArgs, nodePty)
|
|
578
|
+
: null
|
|
579
|
+
// The agent-owned terminal registry: parallel to the UI-tab ptyManager,
|
|
580
|
+
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
|
|
581
|
+
// uncapped, and torn down with the plugin. The model creates terminals here
|
|
582
|
+
// through the terminal_create tool; the sidebar view attaches through the
|
|
583
|
+
// same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
|
|
584
|
+
const agentPtyRegistry = nodePty !== null
|
|
585
|
+
? new AgentPtyRegistry(terminalShell, resolved.shellArgs, nodePty)
|
|
586
|
+
: null
|
|
587
|
+
|
|
588
|
+
// ── User-facing "Side card" preferences ──────────────────────────────────
|
|
589
|
+
// Register the namespace with the settings provider so the Settings page
|
|
590
|
+
// (client half) can render and persist the new-conversation defaults. The
|
|
591
|
+
// DSH settings RPC domain (api-proxy) only serves allowlisted namespaces to
|
|
592
|
+
// configuration clients, so the client reaches this namespace through the
|
|
593
|
+
// plugin's own fenced routes below ('settings.get'/'settings.update'),
|
|
594
|
+
// which call the seam in-process. Deployments without a settings service
|
|
595
|
+
// simply never fill the face and the client falls back to the defaults.
|
|
596
|
+
let settingsFace: SidebarSettingsFace | undefined
|
|
597
|
+
// The model-facing terminal tools are gated on the side-card setting
|
|
598
|
+
// `agentTerminalTools` (default off): nothing is injected until the user
|
|
599
|
+
// turns the feature on, and turning it off mid-session unregisters the
|
|
600
|
+
// tools and releases the agent terminals they created.
|
|
601
|
+
let toolsDisposers: (() => void) | null = null
|
|
602
|
+
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
|
|
603
|
+
if (scope.get().agentTerminalTools) {
|
|
604
|
+
if (toolsDisposers === null) {
|
|
605
|
+
// Degraded mode (node-pty unavailable): never register the terminal
|
|
606
|
+
// tools — every one of them would fail at spawn time.
|
|
607
|
+
if (agentPtyRegistry === null) return
|
|
608
|
+
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId), () => shellOverridesOf(() => settingsFace))
|
|
609
|
+
}
|
|
610
|
+
} else if (toolsDisposers !== null) {
|
|
611
|
+
toolsDisposers()
|
|
612
|
+
toolsDisposers = null
|
|
613
|
+
// The feature is off: release every agent terminal the model created
|
|
614
|
+
// while it was on (they are only reachable through the tools). The
|
|
615
|
+
// registry change fires the push, so the sidebar reconciles them away.
|
|
616
|
+
agentPtyRegistry?.disposeAll()
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
ctx.inject(['settings'], (sctx) => {
|
|
620
|
+
const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS)
|
|
621
|
+
// The structural settings mirror types `schema` as unknown, so the
|
|
622
|
+
// generic is not inferred here; the real service resolves it from the
|
|
623
|
+
// schemastery schema (PrefsSchema) — narrow the owner scope explicitly.
|
|
624
|
+
const scope = sctx.settings.register(ns, PrefsSchema) as {
|
|
625
|
+
get(): SidebarPrefs
|
|
626
|
+
watch(callback: (next: SidebarPrefs, prev: SidebarPrefs) => void): () => void
|
|
627
|
+
}
|
|
628
|
+
const viewOf = (): { value?: unknown; revision?: number } => {
|
|
629
|
+
const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
|
|
630
|
+
return descriptor === undefined
|
|
631
|
+
? { value: undefined, revision: undefined }
|
|
632
|
+
: { value: descriptor.value, revision: descriptor.revision }
|
|
633
|
+
}
|
|
634
|
+
// Mutual exclusion with the dsh-web-ui family right panel: the aionui
|
|
635
|
+
// panel's provider choice (`aionui-panel.rightPanel`) is the authority.
|
|
636
|
+
// While it resolves to 'aionui-panel', this sidebar must not mount. The
|
|
637
|
+
// namespace is read through the settings seam like any other registered
|
|
638
|
+
// section; absent namespace (no aionui installed) = not disabled.
|
|
639
|
+
const externalDisable = (): boolean => {
|
|
640
|
+
const descriptor = sctx.settings.describe({ redactSecrets: true })
|
|
641
|
+
.find(candidate => candidate.ns === 'aionui-panel')
|
|
642
|
+
const value = descriptor?.value as { rightPanel?: unknown } | undefined
|
|
643
|
+
return value?.rightPanel === 'aionui-panel'
|
|
644
|
+
}
|
|
645
|
+
settingsFace = {
|
|
646
|
+
get: viewOf,
|
|
647
|
+
externalDisable,
|
|
648
|
+
update: async (patch, expectedRevision) => {
|
|
649
|
+
await sctx.settings.update(ns, patch, expectedRevision)
|
|
650
|
+
return viewOf()
|
|
651
|
+
},
|
|
652
|
+
}
|
|
653
|
+
// Register (or unregister) the terminal tools from the current setting,
|
|
654
|
+
// and keep them in sync with every settings commit.
|
|
655
|
+
syncToolsGate(scope)
|
|
656
|
+
scope.watch(() => { syncToolsGate(scope) })
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
// ── JSON API ────────────────────────────────────────────────────────────
|
|
660
|
+
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, () => settingsFace)
|
|
661
|
+
ctx.effect(() => ctx.webServer.register({
|
|
662
|
+
kind: 'prefix',
|
|
663
|
+
path: '/sidebar/api',
|
|
664
|
+
handler: async (req, res) => {
|
|
665
|
+
if (!fence(req)) {
|
|
666
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
if (req.method !== 'POST') {
|
|
670
|
+
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
674
|
+
const method = pathname.startsWith('/sidebar/api/') ? pathname.slice('/sidebar/api/'.length) : undefined
|
|
675
|
+
if (method === undefined || method.includes('/')) {
|
|
676
|
+
writeError(res, new SidebarError('not-found', 'unknown sidebar API method', 404))
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
const payload = await readJsonBody(req)
|
|
681
|
+
const handler = api[method]
|
|
682
|
+
if (handler === undefined) {
|
|
683
|
+
throw new SidebarError('not-found', `unknown sidebar API method "${method}"`, 404)
|
|
684
|
+
}
|
|
685
|
+
writeOk(res, await handler(payload))
|
|
686
|
+
} catch (error) {
|
|
687
|
+
writeError(res, error)
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
}), 'dsh-better-sidebar: /sidebar/api routes')
|
|
691
|
+
|
|
692
|
+
// ── Raw upload route ───────────────────────────────────────────────────
|
|
693
|
+
// One request writes one file without JSON/base64 inflation. Folder uploads
|
|
694
|
+
// send each file with a relativePath, preserving the selected directory
|
|
695
|
+
// tree. Bytes stream to a temp sibling and are renamed into place, so a
|
|
696
|
+
// failed or oversized upload never leaves a partial file (see
|
|
697
|
+
// fs-operations.ts for the containment and shape rules).
|
|
698
|
+
ctx.effect(() => ctx.webServer.register({
|
|
699
|
+
kind: 'exact',
|
|
700
|
+
path: '/sidebar/upload',
|
|
701
|
+
handler: async (req, res) => {
|
|
702
|
+
if (!fence(req)) {
|
|
703
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
if (req.method !== 'POST') {
|
|
707
|
+
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
|
|
708
|
+
return
|
|
709
|
+
}
|
|
710
|
+
try {
|
|
711
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
712
|
+
const sessionId = url.searchParams.get('sessionId')
|
|
713
|
+
const dir = url.searchParams.get('dir')
|
|
714
|
+
const relativePath = url.searchParams.get('relativePath')
|
|
715
|
+
if (sessionId === null || dir === null || relativePath === null || relativePath.trim() === '') {
|
|
716
|
+
throw new SidebarError('bad-request', 'sessionId, dir, and relativePath are required')
|
|
717
|
+
}
|
|
718
|
+
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
|
719
|
+
const { path, size } = await writeWorkspaceUpload({
|
|
720
|
+
cwd,
|
|
721
|
+
dir,
|
|
722
|
+
relativePath,
|
|
723
|
+
chunks: req,
|
|
724
|
+
limit: resolved.uploadLimit,
|
|
725
|
+
})
|
|
726
|
+
writeOk(res, { path, size })
|
|
727
|
+
} catch (error) {
|
|
728
|
+
writeError(res, error)
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
}), 'dsh-better-sidebar: /sidebar/upload route')
|
|
732
|
+
|
|
733
|
+
// ── Lazy chunk route (client bundle splits) ─────────────────────────────
|
|
734
|
+
// Serves the client half's split bundles (lib/client-<name>.js) so the
|
|
735
|
+
// heavy preview/terminal libraries load on first use, not at page start
|
|
736
|
+
// (see bundle-route.ts / src/client/chunk-loader.ts).
|
|
737
|
+
ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route')
|
|
738
|
+
|
|
739
|
+
// ── Media route (images for the editor) ─────────────────────────────────
|
|
740
|
+
ctx.effect(() => ctx.webServer.register({
|
|
741
|
+
kind: 'prefix',
|
|
742
|
+
path: '/sidebar/file',
|
|
743
|
+
handler: async (req, res) => {
|
|
744
|
+
if (!fence(req)) {
|
|
745
|
+
res.writeHead(403)
|
|
746
|
+
res.end('forbidden')
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
if (req.method !== 'GET') {
|
|
750
|
+
res.writeHead(405)
|
|
751
|
+
res.end()
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
try {
|
|
755
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
756
|
+
const sessionId = url.searchParams.get('sessionId')
|
|
757
|
+
const raw = url.searchParams.get('path')
|
|
758
|
+
if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
|
|
759
|
+
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
|
760
|
+
const path = requireAbsolute(raw)
|
|
761
|
+
if (!isWithin(cwd, path)) {
|
|
762
|
+
// Only files under the session cwd are served as media (the editor
|
|
763
|
+
// opens images from the explorer; produced files go through read).
|
|
764
|
+
// isWithin (not a raw startsWith) so case-mismatched Windows paths
|
|
765
|
+
// and mixed separators cannot be misclassified.
|
|
766
|
+
throw new SidebarError('fs-error', 'media path outside the session working directory', 403)
|
|
767
|
+
}
|
|
768
|
+
const info = await stat(path)
|
|
769
|
+
if (!info.isFile() || info.size > resolved.mediaLimit) {
|
|
770
|
+
throw new SidebarError('fs-error', 'not a file or too large', 400)
|
|
771
|
+
}
|
|
772
|
+
const type = mediaTypeForPath(path)
|
|
773
|
+
const body = await readFile(path)
|
|
774
|
+
// Raw bytes either way (binary-safe); ?download=1 switches the
|
|
775
|
+
// disposition so the browser saves the file instead of showing it.
|
|
776
|
+
const headers: Record<string, string> = { 'content-type': type, 'cache-control': 'no-cache' }
|
|
777
|
+
if (url.searchParams.get('download') === '1') {
|
|
778
|
+
headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}`
|
|
779
|
+
}
|
|
780
|
+
res.writeHead(200, headers)
|
|
781
|
+
res.end(body)
|
|
782
|
+
} catch (error) {
|
|
783
|
+
writeError(res, error)
|
|
784
|
+
}
|
|
785
|
+
},
|
|
786
|
+
}), 'dsh-better-sidebar: /sidebar/file media route')
|
|
787
|
+
|
|
788
|
+
// ── HTML preview route (sandboxed HTML + its relative assets) ───────────
|
|
789
|
+
// Serves files under the session cwd for the built-in HTML previewer. The
|
|
790
|
+
// URL is path-encoded (see html-route.ts) so the previewed page's relative
|
|
791
|
+
// assets (./style.css, img/x.png) resolve back into this route with the
|
|
792
|
+
// session scope intact — a query-encoded URL would drop the scope when the
|
|
793
|
+
// browser resolves relatives. Every response carries the CSP `sandbox`
|
|
794
|
+
// directive: inside the editor's iframe the sandbox ATTRIBUTE is the
|
|
795
|
+
// boundary, this header is defense-in-depth so even a top-level load of
|
|
796
|
+
// the URL (e.g. a popup opened by a previewed page) stays in an opaque
|
|
797
|
+
// origin with no same-origin access to the GUI.
|
|
798
|
+
ctx.effect(() => ctx.webServer.register({
|
|
799
|
+
kind: 'prefix',
|
|
800
|
+
path: '/sidebar/html',
|
|
801
|
+
handler: async (req, res) => {
|
|
802
|
+
if (!fence(req)) {
|
|
803
|
+
res.writeHead(403)
|
|
804
|
+
res.end('forbidden')
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
if (req.method !== 'GET') {
|
|
808
|
+
res.writeHead(405)
|
|
809
|
+
res.end()
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
814
|
+
const decoded = decodeHtmlUrl(url.pathname)
|
|
815
|
+
if (!decoded.ok) {
|
|
816
|
+
writeError(res, new SidebarError('bad-request', decoded.message, decoded.status))
|
|
817
|
+
return
|
|
818
|
+
}
|
|
819
|
+
const { sessionId, path } = decoded.ref
|
|
820
|
+
// The session's authoritative cwd (client cwd cannot ride in the URL
|
|
821
|
+
// — the path encoding has no query; a detached first request falls
|
|
822
|
+
// back to the process cwd and is normally refused by isWithin, same
|
|
823
|
+
// semantics as the media route's fallback).
|
|
824
|
+
const cwd = sessionCwdOf(ctx, sessionId)
|
|
825
|
+
const absolute = requireAbsolute(path)
|
|
826
|
+
if (!isWithin(cwd, absolute)) {
|
|
827
|
+
throw new SidebarError('fs-error', 'html path outside the session working directory', 403)
|
|
828
|
+
}
|
|
829
|
+
const info = await stat(absolute)
|
|
830
|
+
if (!info.isFile() || info.size > resolved.mediaLimit) {
|
|
831
|
+
throw new SidebarError('fs-error', 'not a file or too large', 400)
|
|
832
|
+
}
|
|
833
|
+
const type = mediaTypeForPath(absolute)
|
|
834
|
+
const body = await readFile(absolute)
|
|
835
|
+
res.writeHead(200, {
|
|
836
|
+
'content-type': type,
|
|
837
|
+
'cache-control': 'no-cache',
|
|
838
|
+
'x-content-type-options': 'nosniff',
|
|
839
|
+
'referrer-policy': 'no-referrer',
|
|
840
|
+
// The sandbox directive (no allow-same-origin → opaque origin) is
|
|
841
|
+
// the previewer's security boundary even for top-level loads;
|
|
842
|
+
// object-src 'none' blocks plugin embeds.
|
|
843
|
+
'content-security-policy': "sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'",
|
|
844
|
+
})
|
|
845
|
+
res.end(body)
|
|
846
|
+
} catch (error) {
|
|
847
|
+
writeError(res, error)
|
|
848
|
+
}
|
|
849
|
+
},
|
|
850
|
+
}), 'dsh-better-sidebar: /sidebar/html preview route')
|
|
851
|
+
|
|
852
|
+
// ── Terminal WebSocket ──────────────────────────────────────────────────
|
|
853
|
+
// One upgrade endpoint serves both UI-tab terminals (?tab=...) and
|
|
854
|
+
// agent-owned terminals (?uuid=...). The two paths attach to different
|
|
855
|
+
// registries but share the wire protocol: input frames are raw text,
|
|
856
|
+
// resize frames are JSON `{type:'resize',cols,rows}`, and a close frame
|
|
857
|
+
// `{type:'close'}` releases the underlying pty (immediate for agent
|
|
858
|
+
// terminals, scheduled-0 for UI tabs which keep the same reconnect grace
|
|
859
|
+
// contract the host has always had).
|
|
860
|
+
const wss = new WebSocketServer({ noServer: true })
|
|
861
|
+
ctx.effect(() => ctx.webServer.registerUpgrade({
|
|
862
|
+
path: '/sidebar/ws/terminal',
|
|
863
|
+
handler: (req, socket, head) => {
|
|
864
|
+
if (!fence(req)) {
|
|
865
|
+
socket.destroy()
|
|
866
|
+
return
|
|
867
|
+
}
|
|
868
|
+
// The structural request/socket/head faces satisfy the shared fence;
|
|
869
|
+
// the `ws` package wants the real Node types — cast at this boundary.
|
|
870
|
+
wss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
|
|
871
|
+
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved, () => settingsFace)
|
|
872
|
+
})
|
|
873
|
+
},
|
|
874
|
+
}), 'dsh-better-sidebar: terminal WebSocket')
|
|
875
|
+
|
|
876
|
+
// ── Agent terminals push WebSocket ──────────────────────────────────────
|
|
877
|
+
// Pushes the live list of agent terminals for one session to the sidebar
|
|
878
|
+
// view: the client mirrors the list into tabs (id `agent:<uuid>`,
|
|
879
|
+
// title from the agent's `terminal_create` call). The host fires on every
|
|
880
|
+
// create / close / exit; the client reconciles by adding tabs for new
|
|
881
|
+
// uuids and dropping tabs whose uuids disappeared (the user closing a tab
|
|
882
|
+
// sends `{type:'close'}` on the terminal WS, which kills the pty, which
|
|
883
|
+
// fires a change here, which converges the view).
|
|
884
|
+
const agentListWss = new WebSocketServer({ noServer: true })
|
|
885
|
+
ctx.effect(() => ctx.webServer.registerUpgrade({
|
|
886
|
+
path: '/sidebar/ws/agent-terminals',
|
|
887
|
+
handler: (req, socket, head) => {
|
|
888
|
+
if (!fence(req)) {
|
|
889
|
+
socket.destroy()
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
agentListWss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
|
|
893
|
+
void attachAgentList(agentPtyRegistry, ws, req)
|
|
894
|
+
})
|
|
895
|
+
},
|
|
896
|
+
}), 'dsh-better-sidebar: agent-terminals push WebSocket')
|
|
897
|
+
|
|
898
|
+
ctx.effect(() => () => {
|
|
899
|
+
toolsDisposers?.()
|
|
900
|
+
ptyManager?.disposeAll()
|
|
901
|
+
agentPtyRegistry?.disposeAll()
|
|
902
|
+
wss.close()
|
|
903
|
+
agentListWss.close()
|
|
904
|
+
}, 'dsh-better-sidebar: teardown')
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Push the live agent-terminal list for one session to a connected sidebar view. */
|
|
908
|
+
async function attachAgentList(
|
|
909
|
+
registry: AgentPtyRegistry | null,
|
|
910
|
+
ws: WebSocket,
|
|
911
|
+
req: SidebarHttpRequest,
|
|
912
|
+
): Promise<void> {
|
|
913
|
+
try {
|
|
914
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
915
|
+
const sessionId = url.searchParams.get('sessionId')
|
|
916
|
+
if (sessionId === null) {
|
|
917
|
+
ws.close(1008, 'sessionId is required')
|
|
918
|
+
return
|
|
919
|
+
}
|
|
920
|
+
const send = (): void => {
|
|
921
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
922
|
+
// Degraded mode (node-pty unavailable): no agent terminal can exist,
|
|
923
|
+
// so the honest push is the empty list.
|
|
924
|
+
ws.send(JSON.stringify(registry?.list(sessionId) ?? []))
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
send()
|
|
928
|
+
const unsubscribe = registry?.subscribe(send)
|
|
929
|
+
ws.on('close', () => { unsubscribe?.() })
|
|
930
|
+
ws.on('error', () => { unsubscribe?.() })
|
|
931
|
+
} catch (error) {
|
|
932
|
+
ws.close(1011, error instanceof Error ? error.message : String(error))
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Wire one terminal socket to its pty: replay transcript, pump both ways.
|
|
938
|
+
* Two attach modes share the wire protocol:
|
|
939
|
+
* - `?uuid=...` attaches to an agent-owned terminal (created by the
|
|
940
|
+
* `terminal_create` tool). The close frame kills the pty immediately
|
|
941
|
+
* (the agent's terminal closes when the user closes the sidebar tab); a
|
|
942
|
+
* bare socket drop (refresh, tab switch) leaves the pty alive for the
|
|
943
|
+
* reconnect grace, exactly like UI-tab terminals.
|
|
944
|
+
* - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user
|
|
945
|
+
* created it from the + menu). The close frame schedules a 0-ms close
|
|
946
|
+
* (the host's reconnect grace keeps the shell alive across a refresh).
|
|
947
|
+
* The park frame (sent when the user switches to another conversation)
|
|
948
|
+
* marks the pty as parked so the upcoming bare socket drop does NOT start
|
|
949
|
+
* the grace countdown — the tab is still open in its session's state, so
|
|
950
|
+
* the shell must survive until the user switches back or closes the tab.
|
|
951
|
+
*/
|
|
952
|
+
async function attachTerminal(
|
|
953
|
+
ctx: Context,
|
|
954
|
+
ptyManager: PtyManager | null,
|
|
955
|
+
agentPtyRegistry: AgentPtyRegistry | null,
|
|
956
|
+
ws: WebSocket,
|
|
957
|
+
req: SidebarHttpRequest,
|
|
958
|
+
resolved: ResolvedSidebarConfig,
|
|
959
|
+
getSettings: () => SidebarSettingsFace | undefined,
|
|
960
|
+
): Promise<void> {
|
|
961
|
+
try {
|
|
962
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
963
|
+
const uuid = url.searchParams.get('uuid')
|
|
964
|
+
if (uuid !== null) {
|
|
965
|
+
// Degraded mode (node-pty unavailable): no agent terminal can exist,
|
|
966
|
+
// so the lookup behaves exactly like a missing uuid.
|
|
967
|
+
if (agentPtyRegistry === null) {
|
|
968
|
+
ws.close(1011, `agent terminal "${uuid}" not found`)
|
|
969
|
+
return
|
|
970
|
+
}
|
|
971
|
+
const handle = agentPtyRegistry.get(uuid)
|
|
972
|
+
if (handle === undefined) {
|
|
973
|
+
ws.close(1011, `agent terminal "${uuid}" not found`)
|
|
974
|
+
return
|
|
975
|
+
}
|
|
976
|
+
pumpAgentTerminal(agentPtyRegistry, handle, ws)
|
|
977
|
+
return
|
|
978
|
+
}
|
|
979
|
+
const sessionId = url.searchParams.get('sessionId')
|
|
980
|
+
const tabId = url.searchParams.get('tab')
|
|
981
|
+
if (sessionId === null || tabId === null) {
|
|
982
|
+
ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
|
|
983
|
+
return
|
|
984
|
+
}
|
|
985
|
+
if (ptyManager === null) {
|
|
986
|
+
// Degraded mode (issue #140): node-pty unavailable. The close reason
|
|
987
|
+
// is a SHORT marker — a WS close reason is capped at 123 bytes, so the
|
|
988
|
+
// client fetches the full repair command from /sidebar/api/terminal.deps.
|
|
989
|
+
ws.close(1011, PTY_DEPS_MISSING)
|
|
990
|
+
return
|
|
991
|
+
}
|
|
992
|
+
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
|
993
|
+
// Settings-page shell overrides win over the yaml/auto shell for
|
|
994
|
+
// terminals opened from now on (existing pty handles keep their shell).
|
|
995
|
+
const overrides = shellOverridesOf(getSettings)
|
|
996
|
+
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs)
|
|
997
|
+
// Replay the transcript, then follow live output.
|
|
998
|
+
if (handle.transcript !== '') ws.send(handle.transcript)
|
|
999
|
+
const onData = (data: string): void => {
|
|
1000
|
+
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
|
|
1001
|
+
ws.send(data)
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
|
|
1005
|
+
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
|
|
1006
|
+
}
|
|
1007
|
+
const dataSub = handle.pty.onData(onData)
|
|
1008
|
+
const exitSub = handle.pty.onExit(onExit)
|
|
1009
|
+
ws.on('message', (data) => {
|
|
1010
|
+
const text = data.toString('utf8')
|
|
1011
|
+
// Control frames are JSON with a known shape; anything else (including
|
|
1012
|
+
// JSON that is not a recognized control) is terminal input, verbatim.
|
|
1013
|
+
let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
|
|
1014
|
+
try {
|
|
1015
|
+
const parsed: unknown = JSON.parse(text)
|
|
1016
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
1017
|
+
control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
|
|
1018
|
+
}
|
|
1019
|
+
} catch {
|
|
1020
|
+
// Not JSON: terminal input.
|
|
1021
|
+
}
|
|
1022
|
+
if (control !== null && control.type === 'close') {
|
|
1023
|
+
// The owning tab was closed: release the quota immediately.
|
|
1024
|
+
ptyManager.scheduleClose(handle.key, 0)
|
|
1025
|
+
return
|
|
1026
|
+
}
|
|
1027
|
+
if (control !== null && control.type === 'park') {
|
|
1028
|
+
// The user switched to another conversation: the tab is still open in
|
|
1029
|
+
// its session's persisted state, but its view unmounted. Park the pty
|
|
1030
|
+
// so the upcoming bare socket drop does NOT start the reconnect-grace
|
|
1031
|
+
// countdown — the pty stays alive until the user switches back (a
|
|
1032
|
+
// reconnecting view clears the parked state) or explicitly closes the
|
|
1033
|
+
// tab (a close frame's scheduleClose clears it).
|
|
1034
|
+
ptyManager.park(handle.key)
|
|
1035
|
+
return
|
|
1036
|
+
}
|
|
1037
|
+
if (handle.exited) return
|
|
1038
|
+
if (
|
|
1039
|
+
control !== null
|
|
1040
|
+
&& control.type === 'resize'
|
|
1041
|
+
&& typeof control.cols === 'number' && typeof control.rows === 'number'
|
|
1042
|
+
) {
|
|
1043
|
+
const dims = clampDims(control.cols, control.rows)
|
|
1044
|
+
handle.pty.resize(dims.cols, dims.rows)
|
|
1045
|
+
} else {
|
|
1046
|
+
handle.pty.write(text)
|
|
1047
|
+
}
|
|
1048
|
+
})
|
|
1049
|
+
ws.on('close', () => {
|
|
1050
|
+
dataSub.dispose()
|
|
1051
|
+
exitSub.dispose()
|
|
1052
|
+
// A parked pty (the user switched conversations and sent `{type:'park'}`)
|
|
1053
|
+
// stays alive indefinitely — do NOT start the grace countdown. A bare
|
|
1054
|
+
// socket drop without a prior park (refresh, crash) starts the grace
|
|
1055
|
+
// period so a quick reconnect keeps the process; the reconnect's open()
|
|
1056
|
+
// cancels the pending close.
|
|
1057
|
+
if (!ptyManager.isParked(handle.key)) {
|
|
1058
|
+
ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs)
|
|
1059
|
+
}
|
|
1060
|
+
})
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
ws.close(1011, error instanceof Error ? error.message : String(error))
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Pump one agent terminal's pty to a connected view. The close frame kills
|
|
1068
|
+
* the pty immediately (the agent's terminal closes when the user closes the
|
|
1069
|
+
* sidebar tab); a bare socket drop leaves the pty alive — the agent owns
|
|
1070
|
+
* the lifetime, and only `terminal_close`, a `{type:'close'}` frame, or
|
|
1071
|
+
* plugin teardown kills it.
|
|
1072
|
+
*/
|
|
1073
|
+
function pumpAgentTerminal(
|
|
1074
|
+
registry: AgentPtyRegistry,
|
|
1075
|
+
handle: AgentTerminalHandle,
|
|
1076
|
+
ws: WebSocket,
|
|
1077
|
+
): void {
|
|
1078
|
+
if (handle.transcript !== '') ws.send(handle.transcript)
|
|
1079
|
+
const onData = (data: string): void => {
|
|
1080
|
+
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
|
|
1081
|
+
ws.send(data)
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
|
|
1085
|
+
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
|
|
1086
|
+
}
|
|
1087
|
+
const dataSub = handle.pty.onData(onData)
|
|
1088
|
+
const exitSub = handle.pty.onExit(onExit)
|
|
1089
|
+
ws.on('message', (data) => {
|
|
1090
|
+
if (handle.exited) return
|
|
1091
|
+
const text = data.toString('utf8')
|
|
1092
|
+
let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
|
|
1093
|
+
try {
|
|
1094
|
+
const parsed: unknown = JSON.parse(text)
|
|
1095
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
1096
|
+
control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
|
|
1097
|
+
}
|
|
1098
|
+
} catch {
|
|
1099
|
+
// Not JSON: terminal input.
|
|
1100
|
+
}
|
|
1101
|
+
if (control !== null && control.type === 'close') {
|
|
1102
|
+
// The user closed the sidebar tab: kill the pty immediately. The
|
|
1103
|
+
// agent's next terminal_list / terminal_send will see it gone.
|
|
1104
|
+
registry.close(handle.uuid)
|
|
1105
|
+
return
|
|
1106
|
+
}
|
|
1107
|
+
if (
|
|
1108
|
+
control !== null
|
|
1109
|
+
&& control.type === 'resize'
|
|
1110
|
+
&& typeof control.cols === 'number' && typeof control.rows === 'number'
|
|
1111
|
+
) {
|
|
1112
|
+
const dims = clampDims(control.cols, control.rows)
|
|
1113
|
+
handle.pty.resize(dims.cols, dims.rows)
|
|
1114
|
+
} else if (control === null) {
|
|
1115
|
+
// Raw text input (a JSON-looking string the pty would have received
|
|
1116
|
+
// verbatim is reachable in theory but is exotic for an agent terminal;
|
|
1117
|
+
// preserve the UI-tab semantics and forward as input).
|
|
1118
|
+
handle.pty.write(text)
|
|
1119
|
+
}
|
|
1120
|
+
// An unrecognized JSON control frame is dropped (the UI-tab path also
|
|
1121
|
+
// treats non-resize JSON controls as input, but for an agent terminal
|
|
1122
|
+
// there is no realistic input that is also valid JSON).
|
|
1123
|
+
})
|
|
1124
|
+
ws.on('close', () => {
|
|
1125
|
+
dataSub.dispose()
|
|
1126
|
+
exitSub.dispose()
|
|
1127
|
+
// A bare socket drop (refresh, tab switch) leaves the agent's pty alive.
|
|
1128
|
+
// The agent owns the lifetime: only `terminal_close`, a `{type:'close'}`
|
|
1129
|
+
// frame, or plugin teardown kills it. A reconnecting view reattaches the
|
|
1130
|
+
// same shell and gets the full transcript replayed.
|
|
1131
|
+
})
|
|
1132
|
+
}
|