dsh-better-sidebar 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/README_EN.md +139 -0
- package/lib/client-docx.js +7649 -0
- package/lib/client-editor.js +32677 -0
- package/lib/client-pptx.js +63438 -0
- package/lib/client-registry.js +8021 -0
- package/lib/client-terminal.js +8060 -0
- package/lib/client-xlsx.js +359755 -0
- package/lib/client.js +8021 -0
- package/lib/index.js +2604 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +220 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +13 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +10 -0
- package/lib/types/client/ExplorerView.d.ts +9 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/PptxView.d.ts +6 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +27 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +38 -0
- package/lib/types/client/TerminalView.d.ts +8 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/api.d.ts +170 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +17 -0
- package/lib/types/client/builtins/tabs.d.ts +6 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +74 -0
- package/lib/types/client/chunks/docx.d.ts +8 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/pptx.d.ts +8 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/chunks/xlsx.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/docx-view.d.ts +8 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/icons.d.ts +47 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +10 -0
- package/lib/types/client/intercept.d.ts +20 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +35 -0
- package/lib/types/client/locales.d.ts +237 -0
- package/lib/types/client/office-shared.d.ts +33 -0
- package/lib/types/client/office-types.d.ts +36 -0
- package/lib/types/client/office-view.d.ts +12 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +19 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/prefs.d.ts +30 -0
- package/lib/types/client/produced-files.d.ts +27 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +214 -0
- package/lib/types/client/split-pane.d.ts +29 -0
- package/lib/types/client/state.d.ts +322 -0
- package/lib/types/client/subagent-activity.d.ts +35 -0
- package/lib/types/client/subagent-detect.d.ts +47 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/theme.d.ts +28 -0
- package/lib/types/client/xlsx-to-univer.d.ts +33 -0
- package/lib/types/client/xlsx-view.d.ts +10 -0
- package/lib/types/config.d.ts +41 -0
- package/lib/types/context-types.d.ts +431 -0
- package/lib/types/fs-tree.d.ts +41 -0
- package/lib/types/git.d.ts +75 -0
- package/lib/types/html-route.d.ts +48 -0
- package/lib/types/index.d.ts +39 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/prefs-shared.d.ts +106 -0
- package/lib/types/pty-manager.d.ts +76 -0
- package/lib/types/tools.d.ts +28 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +168 -0
- package/src/agent-pty.ts +519 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +130 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +266 -0
- package/src/client/EditorHost.tsx +101 -0
- package/src/client/ExplorerView.tsx +283 -0
- package/src/client/GitView.tsx +540 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/PptxView.tsx +131 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +271 -0
- package/src/client/SideCardSection.tsx +426 -0
- package/src/client/Sidebar.tsx +861 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +866 -0
- package/src/client/TabBar.tsx +188 -0
- package/src/client/TerminalView.tsx +233 -0
- package/src/client/TextEditor.tsx +382 -0
- package/src/client/api.ts +215 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +33 -0
- package/src/client/builtins/tabs.tsx +211 -0
- package/src/client/builtins/viewers.tsx +151 -0
- package/src/client/chunk-loader.ts +196 -0
- package/src/client/chunks/docx.tsx +8 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/pptx.tsx +8 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/chunks/xlsx.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/docx-view.tsx +113 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/icons.tsx +137 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +238 -0
- package/src/client/intercept.tsx +94 -0
- package/src/client/lang.ts +95 -0
- package/src/client/layout.css +54 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +66 -0
- package/src/client/locales.ts +476 -0
- package/src/client/office-shared.tsx +40 -0
- package/src/client/office-types.ts +48 -0
- package/src/client/office-view.tsx +12 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +28 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/prefs.ts +100 -0
- package/src/client/produced-files.ts +87 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +434 -0
- package/src/client/sidebar.module.css +1917 -0
- package/src/client/split-pane.tsx +292 -0
- package/src/client/state.ts +1048 -0
- package/src/client/subagent-activity.ts +67 -0
- package/src/client/subagent-detect.ts +125 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/theme.ts +44 -0
- package/src/client/xlsx-to-univer.ts +260 -0
- package/src/client/xlsx-view.tsx +116 -0
- package/src/config.ts +95 -0
- package/src/context-types.ts +445 -0
- package/src/fs-tree.ts +115 -0
- package/src/git.ts +236 -0
- package/src/html-route.ts +70 -0
- package/src/index.ts +897 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/prefs-shared.ts +127 -0
- package/src/pty-manager.ts +199 -0
- package/src/tools.ts +477 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +94 -0
package/src/invariant.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `dsh-better-sidebar`.
|
|
3
|
+
* @module dsh-better-sidebar/invariant
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* jscpd:ignore-start */
|
|
7
|
+
import type { Context } from './context-types.ts'
|
|
8
|
+
|
|
9
|
+
const PACKAGE_NAME = 'dsh-better-sidebar'
|
|
10
|
+
|
|
11
|
+
/** Cordis companion plugin name. */
|
|
12
|
+
export const name = 'dsh-better-sidebar-invariant'
|
|
13
|
+
/** Service required before the companion can reserve package ownership. */
|
|
14
|
+
export const inject = ['invariants']
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* No runtime invariant: the sidebar owns no service state or event protocol
|
|
18
|
+
* of its own — every route is mounted under the host's webServer fence, the
|
|
19
|
+
* pty lifecycle is exercised by the smoke spec, and the panel's store is a
|
|
20
|
+
* plain snapshot registry asserted by the behavior specs. The route fence,
|
|
21
|
+
* pty quota, and store semantics are each observed through their seams.
|
|
22
|
+
*/
|
|
23
|
+
const install: () => void = () => {}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Register this package's invariant companion.
|
|
27
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
28
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
29
|
+
*/
|
|
30
|
+
export const apply = (ctx: Context): Promise<() => void> =>
|
|
31
|
+
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
|
32
|
+
/* jscpd:ignore-end */
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background-job routes of the /sidebar JSON API ('jobs.output' /
|
|
3
|
+
* 'jobs.kill'). The job LIST needs no route: it arrives through the
|
|
4
|
+
* harness's `session/jobs` push mirror (`jobsBySession` in the sessions
|
|
5
|
+
* list feed). The routes:
|
|
6
|
+
*
|
|
7
|
+
* - 'jobs.output' — REPLAYS the output the MODEL has read so far for one
|
|
8
|
+
* job. The source is the owner session's own event log: `tool/call` rows
|
|
9
|
+
* of `job_output` name the job via `arguments.job_id`, and the paired
|
|
10
|
+
* `tool/result` rows carry the finalized content the model received.
|
|
11
|
+
* Because the session store's in-memory log can lag the live append feed
|
|
12
|
+
* after a host restart (the store session stays frozen at its
|
|
13
|
+
* rehydration boundary), the plugin ALSO mirrors job_output events from
|
|
14
|
+
* the live `session/event` feed and merges both sources (deduped by seq).
|
|
15
|
+
* This touches NO DSH source: the model's `job_output` cursor is never
|
|
16
|
+
* consumed, and the pane stays empty until the agent reads the job.
|
|
17
|
+
* - 'jobs.kill' — the registry's stock `kill` (a pristine DSH API),
|
|
18
|
+
* fenced by the owning session via the live agent caller. Absent registry
|
|
19
|
+
* → 503, mirroring the settings routes' optional-service downgrade.
|
|
20
|
+
*/
|
|
21
|
+
import type { Context, SidebarSessionEvent } from './context-types.ts'
|
|
22
|
+
import { requireString, SidebarError } from './wire.ts'
|
|
23
|
+
|
|
24
|
+
/** The two background-job routes of the sidebar API. */
|
|
25
|
+
export interface SidebarJobsRoutes {
|
|
26
|
+
/** The output the model has read so far for one job (event replay, capped). */
|
|
27
|
+
output(payload: unknown): { text: string; truncated: boolean; read: boolean }
|
|
28
|
+
/** Request cancellation of one job (live jobs flip to stopping). */
|
|
29
|
+
kill(payload: unknown): { ok: true; outcome: 'requested' | 'already-finished' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The 'tool/result' message envelope inside a session event's data. */
|
|
33
|
+
interface ToolResultMessageLike {
|
|
34
|
+
source?: { kind?: unknown; callId?: unknown }
|
|
35
|
+
content?: unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One 'tool-result' content block (the inner blocks carry the text). */
|
|
39
|
+
interface ToolResultBlockLike {
|
|
40
|
+
type?: unknown
|
|
41
|
+
content?: unknown
|
|
42
|
+
isError?: unknown
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extract the plain text of a finalized tool result: the text blocks inside
|
|
47
|
+
* the 'tool-result' block, joined with newlines. Error results and
|
|
48
|
+
* non-text blocks contribute nothing.
|
|
49
|
+
*/
|
|
50
|
+
function resultText(message: ToolResultMessageLike): string | undefined {
|
|
51
|
+
if (!Array.isArray(message.content)) return undefined
|
|
52
|
+
const parts: string[] = []
|
|
53
|
+
for (const block of message.content) {
|
|
54
|
+
if (block === null || typeof block !== 'object') continue
|
|
55
|
+
const candidate = block as ToolResultBlockLike
|
|
56
|
+
if (candidate.type !== 'tool-result') continue
|
|
57
|
+
const inner = candidate.content
|
|
58
|
+
if (!Array.isArray(inner)) continue
|
|
59
|
+
for (const item of inner) {
|
|
60
|
+
if (item === null || typeof item !== 'object') continue
|
|
61
|
+
const textItem = item as { type?: unknown; text?: unknown }
|
|
62
|
+
if (textItem.type === 'text' && typeof textItem.text === 'string') {
|
|
63
|
+
parts.push(textItem.text)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return parts.length > 0 ? parts.join('\n') : undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether a tool/result is an error result (the inner block's isError flag). */
|
|
71
|
+
function resultIsError(message: ToolResultMessageLike): boolean {
|
|
72
|
+
if (!Array.isArray(message.content)) return false
|
|
73
|
+
return message.content.some((block) => {
|
|
74
|
+
if (block === null || typeof block !== 'object') return false
|
|
75
|
+
return (block as ToolResultBlockLike).type === 'tool-result'
|
|
76
|
+
&& (block as ToolResultBlockLike).isError === true
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether a job_output result carries no new output — the controller's
|
|
81
|
+
* model-facing "(no new output)" body, noise for the human pane. */
|
|
82
|
+
function isNoNewOutput(text: string): boolean {
|
|
83
|
+
return text.startsWith('(no new output)')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One compact job_output trace (a tool/call or its paired tool/result). */
|
|
87
|
+
interface JobOutputTrace {
|
|
88
|
+
seq: number
|
|
89
|
+
kind: 'call' | 'result'
|
|
90
|
+
/** The tool call identity pairing the two rows. */
|
|
91
|
+
callId: string
|
|
92
|
+
/** tool/call: the job id parsed from the model arguments. */
|
|
93
|
+
jobId?: string
|
|
94
|
+
/** tool/result: the finalized text the model received. */
|
|
95
|
+
text?: string
|
|
96
|
+
/** tool/result: whether the result was an error (read counts, text skipped). */
|
|
97
|
+
isError?: boolean
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Extract the job_output trace of one raw session event (undefined = unrelated). */
|
|
101
|
+
function traceOf(event: SidebarSessionEvent): JobOutputTrace | undefined {
|
|
102
|
+
if (event.type === 'tool/call') {
|
|
103
|
+
const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown }
|
|
104
|
+
if (data.name !== 'job_output' || typeof data.callId !== 'string') return undefined
|
|
105
|
+
let jobId: string | undefined
|
|
106
|
+
try {
|
|
107
|
+
const args = JSON.parse(typeof data.arguments === 'string' ? data.arguments : '') as { job_id?: unknown }
|
|
108
|
+
if (typeof args.job_id === 'string') jobId = args.job_id
|
|
109
|
+
} catch {
|
|
110
|
+
// Malformed model arguments: not a job_output pair.
|
|
111
|
+
}
|
|
112
|
+
if (jobId === undefined) return undefined
|
|
113
|
+
return { seq: event.seq, kind: 'call', callId: data.callId, jobId }
|
|
114
|
+
}
|
|
115
|
+
if (event.type === 'tool/result') {
|
|
116
|
+
const message = (event.data as { message?: unknown }).message as ToolResultMessageLike | undefined
|
|
117
|
+
if (message === undefined) return undefined
|
|
118
|
+
const callId = message.source?.callId
|
|
119
|
+
if (typeof callId !== 'string') return undefined
|
|
120
|
+
return {
|
|
121
|
+
seq: event.seq,
|
|
122
|
+
kind: 'result',
|
|
123
|
+
callId,
|
|
124
|
+
text: resultText(message),
|
|
125
|
+
isError: resultIsError(message),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Per-session cap of mirrored live traces (a bounded, lossy ring). */
|
|
132
|
+
const MIRROR_MAX_ENTRIES = 200
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The live job_output mirror: subscribes to the session append feed and
|
|
136
|
+
* caches the job_output traces the session store's own log can lag behind
|
|
137
|
+
* (after a host restart the store session stays frozen at its rehydration
|
|
138
|
+
* boundary, so `session.events` misses everything appended since — the very
|
|
139
|
+
* reads the pane exists to show). Zero DSH writes: the api-proxy pushes the
|
|
140
|
+
* same feed to browsers.
|
|
141
|
+
*/
|
|
142
|
+
function createJobOutputMirror(ctx: Context): { entries(sessionId: string): readonly JobOutputTrace[] } {
|
|
143
|
+
const perSession = new Map<string, JobOutputTrace[]>()
|
|
144
|
+
// tool/call identities per session, so unrelated tool/result rows are
|
|
145
|
+
// never cached (only job_output results pair with a cached call).
|
|
146
|
+
const callIds = new Map<string, Set<string>>()
|
|
147
|
+
if (typeof ctx.on !== 'function') {
|
|
148
|
+
// Test doubles without the event API degrade to seed-only replay.
|
|
149
|
+
return { entries: () => [] }
|
|
150
|
+
}
|
|
151
|
+
const dispose = ctx.on('session/event', (session, event) => {
|
|
152
|
+
const sessionId = (session as { id?: unknown } | null)?.id
|
|
153
|
+
if (typeof sessionId !== 'string') return
|
|
154
|
+
if (event.type === 'tool/call') {
|
|
155
|
+
const trace = traceOf(event)
|
|
156
|
+
if (trace?.kind !== 'call') return
|
|
157
|
+
let ids = callIds.get(sessionId)
|
|
158
|
+
if (ids === undefined) callIds.set(sessionId, ids = new Set())
|
|
159
|
+
ids.add(trace.callId)
|
|
160
|
+
push(sessionId, trace)
|
|
161
|
+
} else if (event.type === 'tool/result') {
|
|
162
|
+
const trace = traceOf(event)
|
|
163
|
+
if (trace?.kind !== 'result') return
|
|
164
|
+
if (!callIds.get(sessionId)?.has(trace.callId)) return
|
|
165
|
+
push(sessionId, trace)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
ctx.effect(() => dispose, 'dsh-better-sidebar: job-output event mirror')
|
|
169
|
+
|
|
170
|
+
const push = (sessionId: string, trace: JobOutputTrace): void => {
|
|
171
|
+
let list = perSession.get(sessionId)
|
|
172
|
+
if (list === undefined) perSession.set(sessionId, list = [])
|
|
173
|
+
list.push(trace)
|
|
174
|
+
if (list.length > MIRROR_MAX_ENTRIES) {
|
|
175
|
+
const removed = list.splice(0, list.length - MIRROR_MAX_ENTRIES)
|
|
176
|
+
const ids = callIds.get(sessionId)
|
|
177
|
+
if (ids !== undefined) {
|
|
178
|
+
for (const entry of removed) {
|
|
179
|
+
if (entry.kind === 'call') ids.delete(entry.callId)
|
|
180
|
+
}
|
|
181
|
+
if (ids.size === 0) callIds.delete(sessionId)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { entries: (sessionId) => perSession.get(sessionId) ?? [] }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build the jobs routes bound to the plugin context. `output` merges the
|
|
191
|
+
* owner session's own event log with the live job_output mirror; `kill`
|
|
192
|
+
* reads the jobs/agents services lazily and degrades to a 503 when the
|
|
193
|
+
* deployment lacks the registry.
|
|
194
|
+
* @param ctx - host plugin context.
|
|
195
|
+
* @param outputLimit - response cap for one output replay in bytes; longer
|
|
196
|
+
* texts are sliced and flagged `truncated` (mirrors the fs.read cap).
|
|
197
|
+
*/
|
|
198
|
+
export function buildJobsApi(ctx: Context, outputLimit: number): SidebarJobsRoutes {
|
|
199
|
+
const jobs = ctx.get('jobs')
|
|
200
|
+
const agents = ctx.get('agents')
|
|
201
|
+
const mirror = createJobOutputMirror(ctx)
|
|
202
|
+
/** The live caller whose session id the registry fence compares against. */
|
|
203
|
+
const callerOf = (sessionId: string) => agents?.get(sessionId)
|
|
204
|
+
/** Registry refusals become a 404 job-error; unknown and foreign ids are indistinguishable. */
|
|
205
|
+
const registryError = (error: unknown): SidebarError =>
|
|
206
|
+
new SidebarError('job-error', error instanceof Error ? error.message : String(error), 404)
|
|
207
|
+
return {
|
|
208
|
+
output(payload) {
|
|
209
|
+
const sessionId = requireString(payload, 'sessionId')
|
|
210
|
+
const id = requireString(payload, 'id')
|
|
211
|
+
// Merge the store's event log (durable seed + whatever it received)
|
|
212
|
+
// with the live mirror, deduped by seq — a trace never double-counts.
|
|
213
|
+
const bySeq = new Map<number, JobOutputTrace>()
|
|
214
|
+
for (const event of ctx.sessions.get(sessionId)?.events ?? []) {
|
|
215
|
+
const trace = traceOf(event)
|
|
216
|
+
if (trace !== undefined) bySeq.set(trace.seq, trace)
|
|
217
|
+
}
|
|
218
|
+
for (const trace of mirror.entries(sessionId)) bySeq.set(trace.seq, trace)
|
|
219
|
+
// Pair calls with results in seq order: the model's reads, oldest first.
|
|
220
|
+
const jobOf = new Map<string, string>()
|
|
221
|
+
const parts: string[] = []
|
|
222
|
+
let read = false
|
|
223
|
+
for (const trace of [...bySeq.values()].sort((left, right) => left.seq - right.seq)) {
|
|
224
|
+
if (trace.kind === 'call') {
|
|
225
|
+
if (trace.jobId !== undefined) jobOf.set(trace.callId, trace.jobId)
|
|
226
|
+
} else if (jobOf.get(trace.callId) === id) {
|
|
227
|
+
read = true
|
|
228
|
+
if (trace.isError !== true && trace.text !== undefined && !isNoNewOutput(trace.text)) {
|
|
229
|
+
parts.push(trace.text)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const text = parts.join('\n')
|
|
234
|
+
return {
|
|
235
|
+
text: text.length > outputLimit ? text.slice(0, outputLimit) : text,
|
|
236
|
+
truncated: text.length > outputLimit,
|
|
237
|
+
read,
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
kill(payload) {
|
|
241
|
+
if (jobs === undefined) {
|
|
242
|
+
throw new SidebarError('job-error', 'the background-job registry is not mounted in this deployment', 503)
|
|
243
|
+
}
|
|
244
|
+
const sessionId = requireString(payload, 'sessionId')
|
|
245
|
+
const id = requireString(payload, 'id')
|
|
246
|
+
const record = payload as { reason?: unknown } | null
|
|
247
|
+
const reason = typeof record?.reason === 'string' && record.reason !== ''
|
|
248
|
+
? record.reason
|
|
249
|
+
: 'user requested via sidebar'
|
|
250
|
+
try {
|
|
251
|
+
return { ok: true, outcome: jobs.kill(id, callerOf(sessionId), reason) }
|
|
252
|
+
} catch (error) {
|
|
253
|
+
throw registryError(error)
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "Side card" preference vocabulary (types + constants), consumed by
|
|
3
|
+
* BOTH halves: the host registers the schemastery schema over these values
|
|
4
|
+
* (config.ts) and the client reads/writes them through the settings RPC
|
|
5
|
+
* (client/prefs.ts, client/SideCardSection.tsx). Kept free of schemastery so
|
|
6
|
+
* the browser bundle never pulls the schema runtime in.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The user-settings namespace holding the side card preferences. */
|
|
10
|
+
export const SIDEBAR_PREFS_NS = 'dsh-better-sidebar'
|
|
11
|
+
|
|
12
|
+
/** User-facing side card preferences (new-conversation defaults). */
|
|
13
|
+
export interface SidebarPrefs {
|
|
14
|
+
/** Whether a brand-new conversation opens the side card by default. */
|
|
15
|
+
openByDefault: boolean
|
|
16
|
+
/** Default panel width as a percent of the window width (20–60). */
|
|
17
|
+
defaultWidthPercent: number
|
|
18
|
+
/**
|
|
19
|
+
* Whether the sidebar auto-activates (opens the panel) and expands the
|
|
20
|
+
* Subagent page when the current conversation spawns a new subagent.
|
|
21
|
+
*/
|
|
22
|
+
autoOpenSubagent: boolean
|
|
23
|
+
/**
|
|
24
|
+
* Whether the sidebar auto-activates (opens the panel) and expands the
|
|
25
|
+
* Jobs page when a NEW background job appears for the current
|
|
26
|
+
* conversation (any new job id, not just the first one).
|
|
27
|
+
*/
|
|
28
|
+
autoOpenJobs: boolean
|
|
29
|
+
/**
|
|
30
|
+
* Whether the model-facing agent terminal tools (terminal_create / list /
|
|
31
|
+
* send / read / wait_for / resize / signal / close) are injected into the
|
|
32
|
+
* model's toolset. Off by default: the feature stays dormant until the
|
|
33
|
+
* user explicitly enables it in the side card settings.
|
|
34
|
+
*/
|
|
35
|
+
agentTerminalTools: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Whether expanding the bottom panel for the FIRST time in a session tries
|
|
38
|
+
* to open a fresh terminal tab there (the terminal quota/type still gates
|
|
39
|
+
* the attempt). On by default; the switch lives under the terminal tab's
|
|
40
|
+
* row in the Side card settings.
|
|
41
|
+
*/
|
|
42
|
+
bottomPanelAutoTerminal: boolean
|
|
43
|
+
/**
|
|
44
|
+
* Whether chat-side file opens (tool-row path links, the produced-files
|
|
45
|
+
* row, prose file mentions — every path that funnels through the client
|
|
46
|
+
* runtime's `ctx.workspaces.openPath`) open in the sidebar editor instead
|
|
47
|
+
* of the Host OS's default application. On by default; the editor tab's
|
|
48
|
+
* own enable switch gates it too (both must be on for the takeover).
|
|
49
|
+
*/
|
|
50
|
+
interceptOpenPath: boolean
|
|
51
|
+
/**
|
|
52
|
+
* Whether the HTML previewer drops its sandboxed iframe. Sandbox ON (the
|
|
53
|
+
* default) renders previewed HTML in an opaque-origin iframe that cannot
|
|
54
|
+
* touch the GUI; turning it OFF runs the previewed page with the GUI's
|
|
55
|
+
* own origin — full read/write access to session files and internal
|
|
56
|
+
* APIs. Only for trusted local content; the setting copy warns.
|
|
57
|
+
*/
|
|
58
|
+
htmlViewerNoSandbox: boolean
|
|
59
|
+
/**
|
|
60
|
+
* Whether a newly opened HTML preview starts UNSANDBOXED (the per-surface
|
|
61
|
+
* temporary unlock pre-applied). Off by default: previews open sandboxed
|
|
62
|
+
* and the status row offers the one-tap unlock; when on, previews open
|
|
63
|
+
* in the red unsandboxed state and the status row offers a one-tap
|
|
64
|
+
* restore for the current file.
|
|
65
|
+
*/
|
|
66
|
+
htmlViewerDefaultUnsafe: boolean
|
|
67
|
+
/**
|
|
68
|
+
* Whether the browser tab drops its sandboxed iframe. Sandbox ON (the
|
|
69
|
+
* default) keeps browsed sites in an opaque origin with no GUI access;
|
|
70
|
+
* turning it OFF runs any visited site with the GUI's own origin — it
|
|
71
|
+
* can read session data and act as the logged-in GUI. Only for trusted
|
|
72
|
+
* sites; the setting copy warns.
|
|
73
|
+
*/
|
|
74
|
+
browserNoSandbox: boolean
|
|
75
|
+
/**
|
|
76
|
+
* Whether clicking an http(s) EXTERNAL link in the GUI (chat messages,
|
|
77
|
+
* tool rows, prose mentions) opens the sidebar browser instead of a new
|
|
78
|
+
* browser tab. On by default; gated on the browser tab's own enable
|
|
79
|
+
* switch (both must be on for the takeover). Ctrl/Cmd+click always
|
|
80
|
+
* bypasses the takeover.
|
|
81
|
+
*/
|
|
82
|
+
browserInterceptLinks: boolean
|
|
83
|
+
/**
|
|
84
|
+
* Per-tab enable switches, keyed by tab descriptor id (`'explorer'`,
|
|
85
|
+
* `'my-plugin:db'`). An ABSENT key means enabled — only an explicit
|
|
86
|
+
* `false` disables a tab type (hidden from the + menu, `openTab` refuses,
|
|
87
|
+
* and derived flows like subagent auto-open / agent-terminal tabs stop).
|
|
88
|
+
* Already-open tabs of a disabled type keep rendering (closing one
|
|
89
|
+
* prevents reopening), matching the "existing conversations keep their
|
|
90
|
+
* own layouts" rule.
|
|
91
|
+
*/
|
|
92
|
+
tabsEnabled: Record<string, boolean>
|
|
93
|
+
/**
|
|
94
|
+
* Per-viewer enable switches, keyed by file viewer descriptor id
|
|
95
|
+
* (`'image'`, `'my-plugin:csv'`). An ABSENT key means enabled; a disabled
|
|
96
|
+
* viewer is skipped by `matchFileViewer` so files fall through to the
|
|
97
|
+
* next matching viewer (or the download button when none match).
|
|
98
|
+
*/
|
|
99
|
+
viewersEnabled: Record<string, boolean>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
|
|
103
|
+
export const WIDTH_PERCENT_MIN = 20
|
|
104
|
+
export const WIDTH_PERCENT_MAX = 60
|
|
105
|
+
export const WIDTH_PERCENT_DEFAULT = 30
|
|
106
|
+
|
|
107
|
+
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
|
|
108
|
+
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
|
109
|
+
openByDefault: true,
|
|
110
|
+
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
|
|
111
|
+
autoOpenSubagent: true,
|
|
112
|
+
autoOpenJobs: true,
|
|
113
|
+
agentTerminalTools: false,
|
|
114
|
+
bottomPanelAutoTerminal: true,
|
|
115
|
+
interceptOpenPath: true,
|
|
116
|
+
htmlViewerNoSandbox: false,
|
|
117
|
+
htmlViewerDefaultUnsafe: false,
|
|
118
|
+
browserNoSandbox: false,
|
|
119
|
+
browserInterceptLinks: true,
|
|
120
|
+
tabsEnabled: {},
|
|
121
|
+
viewersEnabled: {},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Clamp one width percent into the contract range (shared by schema and client reads). */
|
|
125
|
+
export function clampWidthPercent(value: number): number {
|
|
126
|
+
return Math.min(WIDTH_PERCENT_MAX, Math.max(WIDTH_PERCENT_MIN, Math.round(value)))
|
|
127
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY session table for the sidebar terminals. One node-pty process per
|
|
3
|
+
* `${sessionId}:${tabId}` key; processes survive WebSocket disconnects
|
|
4
|
+
* (page refresh, tab switch) and reconnect to the same process by key.
|
|
5
|
+
* Output is mirrored into a bounded transcript ring (capped bytes) so a new
|
|
6
|
+
* connection replays history before live data. Sessions die only when the
|
|
7
|
+
* tab is closed or the plugin tears down.
|
|
8
|
+
*/
|
|
9
|
+
import { chmodSync, existsSync } from 'node:fs'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { createRequire } from 'node:module'
|
|
12
|
+
import * as nodePty from 'node-pty'
|
|
13
|
+
import { SidebarError } from './wire.ts'
|
|
14
|
+
|
|
15
|
+
/** Per-terminal transcript bound (bytes kept for replay). */
|
|
16
|
+
const TRANSCRIPT_LIMIT = 1 << 20
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Restore the executable bit pnpm strips from node-pty's prebuilt
|
|
20
|
+
* spawn-helper (the macOS helper that forks and sets up the pty). Without it
|
|
21
|
+
* every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
|
|
22
|
+
* @deepseek-ai/dsh-terminal-bash's ensure-spawn-helper postinstall, run at
|
|
23
|
+
* plugin activation so link-installed deployments get the fix too.
|
|
24
|
+
*/
|
|
25
|
+
export function ensureSpawnHelper(): void {
|
|
26
|
+
if (process.platform === 'win32') return
|
|
27
|
+
try {
|
|
28
|
+
const require = createRequire(import.meta.url)
|
|
29
|
+
const entry = require.resolve('node-pty')
|
|
30
|
+
const packageRoot = dirname(dirname(entry))
|
|
31
|
+
const candidates = [
|
|
32
|
+
join(packageRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
|
|
33
|
+
join(packageRoot, 'build', 'Release', 'spawn-helper'),
|
|
34
|
+
]
|
|
35
|
+
for (const helper of candidates) {
|
|
36
|
+
if (existsSync(helper)) chmodSync(helper, 0o755)
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// Resolution or chmod failure: the terminal surfaces its own spawn error.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One live terminal. */
|
|
44
|
+
export interface SidebarPty {
|
|
45
|
+
/** `${sessionId}:${tabId}` registry key. */
|
|
46
|
+
key: string
|
|
47
|
+
sessionId: string
|
|
48
|
+
tabId: string
|
|
49
|
+
/** The working directory the process was SPAWNED with (a reconnect that
|
|
50
|
+
* resolves a different authoritative cwd respawns instead of reusing —
|
|
51
|
+
* the page-load hydrate race can attach the real cwd after the first
|
|
52
|
+
* connect, and a shell in the wrong directory must not linger). */
|
|
53
|
+
cwd: string
|
|
54
|
+
pty: nodePty.IPty
|
|
55
|
+
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
|
|
56
|
+
transcript: string
|
|
57
|
+
/** Whether the top-level process exited (transcript stays replayable). */
|
|
58
|
+
exited: boolean
|
|
59
|
+
exitCode?: number | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The terminal registry. `maxPerSession` bounds concurrent processes per
|
|
64
|
+
* conversation (the client caps tabs at the same number).
|
|
65
|
+
*/
|
|
66
|
+
export class PtyManager {
|
|
67
|
+
private readonly sessions = new Map<string, SidebarPty>()
|
|
68
|
+
private readonly pendingCloses = new Map<string, ReturnType<typeof setTimeout>>()
|
|
69
|
+
|
|
70
|
+
constructor(
|
|
71
|
+
private readonly shell: string,
|
|
72
|
+
private readonly maxPerSession: number,
|
|
73
|
+
) {}
|
|
74
|
+
|
|
75
|
+
/** All live terminal keys of one session. */
|
|
76
|
+
keysOf(sessionId: string): string[] {
|
|
77
|
+
const keys: string[] = []
|
|
78
|
+
for (const handle of this.sessions.values()) {
|
|
79
|
+
if (handle.sessionId === sessionId) keys.push(handle.key)
|
|
80
|
+
}
|
|
81
|
+
return keys
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Open (or reuse) the terminal for a session/tab key. A handle whose
|
|
86
|
+
* process already exited is replaced with a fresh spawn (reconnecting a
|
|
87
|
+
* dead terminal must yield a live shell, not an input sink), and so is a
|
|
88
|
+
* live handle whose spawn cwd differs from the now-authoritative one (the
|
|
89
|
+
* first connect of a page load can arrive before the session hydrates, so
|
|
90
|
+
* it fell back to the process cwd — reconnecting with the real cwd must
|
|
91
|
+
* restart the shell in the right directory). Reopening also cancels any
|
|
92
|
+
* pending scheduled close (a reconnect within the grace window keeps the
|
|
93
|
+
* process alive).
|
|
94
|
+
* @param sessionId - conversation id.
|
|
95
|
+
* @param tabId - client tab id.
|
|
96
|
+
* @param cwd - initial working directory (the session's cwd).
|
|
97
|
+
* @param cols - initial terminal width.
|
|
98
|
+
* @param rows - initial terminal height.
|
|
99
|
+
* @returns the live handle.
|
|
100
|
+
* @throws {SidebarError} pty-error when the per-session cap is reached.
|
|
101
|
+
*/
|
|
102
|
+
open(sessionId: string, tabId: string, cwd: string, cols: number, rows: number): SidebarPty {
|
|
103
|
+
const key = `${sessionId}:${tabId}`
|
|
104
|
+
this.cancelClose(key)
|
|
105
|
+
const existing = this.sessions.get(key)
|
|
106
|
+
if (existing !== undefined && !existing.exited && existing.cwd === cwd) return existing
|
|
107
|
+
if (existing !== undefined) this.close(key)
|
|
108
|
+
// Zombie cleanup: a session's exited handles (shell closed, tab dropped
|
|
109
|
+
// on an old host without the close frame) must not eat the quota.
|
|
110
|
+
for (const [candidate, handle] of [...this.sessions]) {
|
|
111
|
+
if (handle.sessionId === sessionId && handle.exited) this.close(candidate)
|
|
112
|
+
}
|
|
113
|
+
if (this.keysOf(sessionId).length >= this.maxPerSession) {
|
|
114
|
+
throw new SidebarError('pty-error', `terminal limit reached (${this.maxPerSession}) for this session`, 400)
|
|
115
|
+
}
|
|
116
|
+
const handle: SidebarPty = {
|
|
117
|
+
key,
|
|
118
|
+
sessionId,
|
|
119
|
+
tabId,
|
|
120
|
+
cwd,
|
|
121
|
+
pty: nodePty.spawn(this.shell, [], {
|
|
122
|
+
name: 'xterm-256color',
|
|
123
|
+
cols: Math.max(2, Math.floor(cols)),
|
|
124
|
+
rows: Math.max(2, Math.floor(rows)),
|
|
125
|
+
cwd,
|
|
126
|
+
env: { ...process.env },
|
|
127
|
+
}),
|
|
128
|
+
transcript: '',
|
|
129
|
+
exited: false,
|
|
130
|
+
}
|
|
131
|
+
handle.pty.onData((data) => {
|
|
132
|
+
handle.transcript += data
|
|
133
|
+
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
|
|
134
|
+
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
handle.pty.onExit(({ exitCode }) => {
|
|
138
|
+
handle.exited = true
|
|
139
|
+
handle.exitCode = exitCode
|
|
140
|
+
})
|
|
141
|
+
this.sessions.set(key, handle)
|
|
142
|
+
return handle
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Schedule the terminal's destruction after `delayMs`. A tab close sends
|
|
147
|
+
* delay 0 (release the quota immediately); a bare socket drop (refresh,
|
|
148
|
+
* crash) uses the grace period so a quick reconnect keeps the process.
|
|
149
|
+
* `open()` cancels any pending close.
|
|
150
|
+
*/
|
|
151
|
+
scheduleClose(key: string, delayMs: number): void {
|
|
152
|
+
const handle = this.sessions.get(key)
|
|
153
|
+
if (handle === undefined) return
|
|
154
|
+
this.cancelClose(key)
|
|
155
|
+
const timer = setTimeout(() => { this.close(key) }, delayMs)
|
|
156
|
+
this.pendingCloses.set(key, timer)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Cancel a pending scheduled close (the terminal is being reopened). */
|
|
160
|
+
cancelClose(key: string): void {
|
|
161
|
+
const timer = this.pendingCloses.get(key)
|
|
162
|
+
if (timer !== undefined) {
|
|
163
|
+
clearTimeout(timer)
|
|
164
|
+
this.pendingCloses.delete(key)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Resolve a live handle by key, or undefined. */
|
|
169
|
+
get(key: string): SidebarPty | undefined {
|
|
170
|
+
return this.sessions.get(key)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Close a terminal and drop its state (the owning tab was closed). */
|
|
174
|
+
close(key: string): void {
|
|
175
|
+
this.cancelClose(key)
|
|
176
|
+
const handle = this.sessions.get(key)
|
|
177
|
+
if (handle === undefined) return
|
|
178
|
+
this.sessions.delete(key)
|
|
179
|
+
try {
|
|
180
|
+
handle.pty.kill()
|
|
181
|
+
} catch {
|
|
182
|
+
// Already exited or gone; nothing left to kill.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Close every terminal (plugin teardown). */
|
|
187
|
+
disposeAll(): void {
|
|
188
|
+
for (const timer of this.pendingCloses.values()) clearTimeout(timer)
|
|
189
|
+
this.pendingCloses.clear()
|
|
190
|
+
for (const key of [...this.sessions.keys()]) this.close(key)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The interactive shell for this platform (empty SHELL falls back). */
|
|
195
|
+
export function defaultShell(): string {
|
|
196
|
+
if (process.platform === 'win32') return 'powershell.exe'
|
|
197
|
+
const shell = process.env.SHELL
|
|
198
|
+
return shell !== undefined && shell.trim() !== '' ? shell : '/bin/bash'
|
|
199
|
+
}
|