@fonlan/dsh-quick-commands 0.1.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.en.md +149 -0
- package/README.md +108 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1036 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +904 -0
- package/lib/types/index.d.ts +19 -0
- package/lib/types/server/remote.d.ts +117 -0
- package/lib/types/server/rpc.d.ts +4 -0
- package/lib/types/server/runner.d.ts +114 -0
- package/lib/types/server/settings.d.ts +24 -0
- package/lib/types/shared/contract.d.ts +87 -0
- package/package.json +108 -0
- package/src/client/api.ts +76 -0
- package/src/client/header.tsx +534 -0
- package/src/client/index.tsx +50 -0
- package/src/client/locales.ts +106 -0
- package/src/client/quick-commands.css +580 -0
- package/src/client/run-state.ts +125 -0
- package/src/client/settings-card.tsx +209 -0
- package/src/client/slot-augment.d.ts +14 -0
- package/src/index.ts +61 -0
- package/src/server/remote.ts +304 -0
- package/src/server/rpc.ts +286 -0
- package/src/server/runner.ts +471 -0
- package/src/server/settings.ts +106 -0
- package/src/shared/contract.ts +92 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quick-command execution engine.
|
|
3
|
+
*
|
|
4
|
+
* Owns one subprocess per run (serial per workspace id), keeps bounded
|
|
5
|
+
* in-memory output tails with offset-based readers for incremental client
|
|
6
|
+
* polls, resolves the {workspace}/{cwd}/{title} placeholders against the
|
|
7
|
+
* session-bound execution context, and terminates the whole process tree on
|
|
8
|
+
* kill / popup close.
|
|
9
|
+
*
|
|
10
|
+
* Two backends:
|
|
11
|
+
* - local: DSH's `subprocess` service (`bash -c`/`pwsh -c`, workspace cwd).
|
|
12
|
+
* - remote: when the workspace path is a dsh-ssh placeholder directory and
|
|
13
|
+
* the `sshPool` service is present, the command streams over SSH with the
|
|
14
|
+
* workspace's remote path as cwd. The client-visible behaviour (tail +
|
|
15
|
+
* offset polls) is identical for both backends.
|
|
16
|
+
*/
|
|
17
|
+
import { randomUUID } from 'node:crypto'
|
|
18
|
+
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
|
19
|
+
import type { QuickCommandsSettings } from '../shared/contract.js'
|
|
20
|
+
import {
|
|
21
|
+
localPathToRemoteRef,
|
|
22
|
+
resolveRemoteCwd,
|
|
23
|
+
RemoteStreamExecutor,
|
|
24
|
+
type SshPoolLike,
|
|
25
|
+
} from './remote.js'
|
|
26
|
+
|
|
27
|
+
/** Bounded in-memory tail window per output stream (bytes). */
|
|
28
|
+
const TAIL_MAX_BYTES = 256 * 1024
|
|
29
|
+
|
|
30
|
+
/** Optional SSH backend (duck-typed over the @dsh-ssh/dsh-ssh plugin). */
|
|
31
|
+
export interface RemoteBackend {
|
|
32
|
+
pool: SshPoolLike
|
|
33
|
+
/** Resolve one hostId to its connection config (settings `dsh-ssh-hosts`). */
|
|
34
|
+
resolveHost: (hostId: string) => { id: string; [key: string]: unknown } | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Lazily-resolved SSH backend: resolved per run, so load order is irrelevant. */
|
|
38
|
+
export type RemoteBackendResolver = () => RemoteBackend | undefined
|
|
39
|
+
|
|
40
|
+
/** One live process entry owned by the runner. */
|
|
41
|
+
interface LiveRun {
|
|
42
|
+
runId: string
|
|
43
|
+
workspaceId: string
|
|
44
|
+
commandName: string
|
|
45
|
+
/** Command line after placeholder substitution (display currency). */
|
|
46
|
+
resolvedCommand: string
|
|
47
|
+
/** Execution kind (also selects the kill strategy). */
|
|
48
|
+
kind: 'local' | 'remote'
|
|
49
|
+
handle: SubprocessHandle | RemoteStreamExecutor
|
|
50
|
+
/** Whole-stream byte counters (offset currency for delta reads). */
|
|
51
|
+
stdoutBytes: number
|
|
52
|
+
stderrBytes: number
|
|
53
|
+
/** In-memory tail windows (keep the TAIL on overflow). */
|
|
54
|
+
stdoutTail: string
|
|
55
|
+
stderrTail: string
|
|
56
|
+
/** Thrown when the tail was clipped (lossy reads report it). */
|
|
57
|
+
stdoutClipped: boolean
|
|
58
|
+
stderrClipped: boolean
|
|
59
|
+
/** Terminal exit facts; present once the process closed. */
|
|
60
|
+
outcome: { exitCode: number | null; signal: string | null } | null
|
|
61
|
+
/** True when kill was requested but the stream has not settled yet. */
|
|
62
|
+
killRequested: boolean
|
|
63
|
+
startedAt: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A snapshot reader over one run's captured output (delta + offsets). */
|
|
67
|
+
export interface RunPollResult {
|
|
68
|
+
kind: 'poll'
|
|
69
|
+
status: 'running' | 'exited'
|
|
70
|
+
exitCode: number | null
|
|
71
|
+
signal: string | null
|
|
72
|
+
stdoutEnd: number
|
|
73
|
+
stderrEnd: number
|
|
74
|
+
stdoutDelta: string
|
|
75
|
+
stderrDelta: string
|
|
76
|
+
stdoutLossy: boolean
|
|
77
|
+
stderrLossy: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The runner's exported operations. */
|
|
81
|
+
export interface QuickRunner {
|
|
82
|
+
/**
|
|
83
|
+
* Start one command for a workspace (serial: rejects when one is live).
|
|
84
|
+
* Async: a remote run acquires its SSH connection before returning.
|
|
85
|
+
*/
|
|
86
|
+
start(input: {
|
|
87
|
+
workspaceId: string
|
|
88
|
+
workspacePath: string
|
|
89
|
+
workspaceTitle: string
|
|
90
|
+
commandName: string
|
|
91
|
+
command: string
|
|
92
|
+
sessionCwd: string
|
|
93
|
+
popupAnchor: QuickCommandsSettings['popupAnchor']
|
|
94
|
+
}): Promise<{ ok: true; runId: string; remoteHost?: string; resolvedCommand: string } | { ok: false; error: string }>
|
|
95
|
+
/** Read output deltas since the caller's offsets. */
|
|
96
|
+
poll(runId: string, stdoutFrom: number, stderrFrom: number): RunPollResult | null
|
|
97
|
+
/** Terminate the live process tree (idempotent). */
|
|
98
|
+
kill(runId: string): 'ok' | 'not-found' | 'already-exited'
|
|
99
|
+
/** List live runs for the workspace (for reattach / run list). */
|
|
100
|
+
forWorkspace(workspaceId: string): Array<{ runId: string; commandName: string; startedAt: number; status: 'running' | 'exited' }>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Substitute supported placeholders in the command string. */
|
|
104
|
+
export function resolvePlaceholders(command: string, vars: {
|
|
105
|
+
workspace: string
|
|
106
|
+
cwd: string
|
|
107
|
+
title: string
|
|
108
|
+
}): string {
|
|
109
|
+
return command
|
|
110
|
+
.replaceAll('{workspace}', vars.workspace)
|
|
111
|
+
.replaceAll('{cwd}', vars.cwd)
|
|
112
|
+
.replaceAll('{title}', vars.title)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Pick the shell executable for the current platform. */
|
|
116
|
+
export function shellCmd(): string {
|
|
117
|
+
return process.platform === 'win32' ? 'pwsh' : 'bash'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Pick the `-c` inclusive switch for the current platform. */
|
|
121
|
+
export function shellFlag(): '-c' {
|
|
122
|
+
return '-c'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface SubprocessServiceLike {
|
|
126
|
+
spawn(spec: SubprocessSpawnSpec): SubprocessHandle
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class QuickCommandRunner implements QuickRunner {
|
|
130
|
+
private readonly runs = new Map<string, LiveRun>()
|
|
131
|
+
private readonly liveByWorkspace = new Map<string, string>()
|
|
132
|
+
|
|
133
|
+
constructor(
|
|
134
|
+
private readonly subprocess: SubprocessServiceLike,
|
|
135
|
+
private readonly resolveRemote?: RemoteBackendResolver,
|
|
136
|
+
) {}
|
|
137
|
+
|
|
138
|
+
/** Resolve the remote reference lazily so SSH availability is re-checked per run. */
|
|
139
|
+
private resolveRemoteBackend(): RemoteBackend | undefined {
|
|
140
|
+
try {
|
|
141
|
+
return this.resolveRemote?.()
|
|
142
|
+
} catch {
|
|
143
|
+
return undefined
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async start(input: {
|
|
148
|
+
workspaceId: string
|
|
149
|
+
workspacePath: string
|
|
150
|
+
workspaceTitle: string
|
|
151
|
+
commandName: string
|
|
152
|
+
command: string
|
|
153
|
+
sessionCwd: string
|
|
154
|
+
popupAnchor: QuickCommandsSettings['popupAnchor']
|
|
155
|
+
}): Promise<{ ok: true; runId: string; remoteHost?: string; resolvedCommand: string } | { ok: false; error: string }> {
|
|
156
|
+
const liveId = this.liveByWorkspace.get(input.workspaceId)
|
|
157
|
+
if (liveId !== undefined) {
|
|
158
|
+
const live = this.runs.get(liveId)
|
|
159
|
+
if (live !== undefined && live.outcome === null) {
|
|
160
|
+
return { ok: false, error: `a command is already running for this workspace (${live.commandName})` }
|
|
161
|
+
}
|
|
162
|
+
this.liveByWorkspace.delete(input.workspaceId)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Workspace path is a dsh-ssh placeholder → remote execution.
|
|
166
|
+
const ref = localPathToRemoteRef(input.workspacePath)
|
|
167
|
+
if (ref !== null) {
|
|
168
|
+
const backend = this.resolveRemoteBackend()
|
|
169
|
+
if (backend === undefined) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
error: `workspace ${input.workspaceId} is an SSH remote workspace (host ${ref.hostId}), but the @dsh-ssh/dsh-ssh plugin (sshPool service) is not loaded`,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return this.startRemote(input, ref.hostId, ref.remotePath, backend)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return this.startLocal(input)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async startLocal(input: {
|
|
182
|
+
workspaceId: string
|
|
183
|
+
workspacePath: string
|
|
184
|
+
workspaceTitle: string
|
|
185
|
+
commandName: string
|
|
186
|
+
command: string
|
|
187
|
+
sessionCwd: string
|
|
188
|
+
popupAnchor: QuickCommandsSettings['popupAnchor']
|
|
189
|
+
}): Promise<{ ok: true; runId: string; resolvedCommand: string }> {
|
|
190
|
+
const resolved = resolvePlaceholders(input.command, {
|
|
191
|
+
workspace: input.workspacePath,
|
|
192
|
+
cwd: input.sessionCwd,
|
|
193
|
+
title: input.workspaceTitle,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const runId = randomUUID()
|
|
197
|
+
const handle = this.subprocess.spawn({
|
|
198
|
+
argv: [shellCmd(), shellFlag(), resolved],
|
|
199
|
+
cwd: input.workspacePath,
|
|
200
|
+
graceMs: 3000,
|
|
201
|
+
stdio: {
|
|
202
|
+
stdin: 'ignore',
|
|
203
|
+
stdout: { maxBytes: TAIL_MAX_BYTES },
|
|
204
|
+
stderr: { maxBytes: TAIL_MAX_BYTES },
|
|
205
|
+
},
|
|
206
|
+
env: {
|
|
207
|
+
...process.env,
|
|
208
|
+
DSH_WORKSPACE: input.workspacePath,
|
|
209
|
+
DSH_WORKSPACE_ID: input.workspaceId,
|
|
210
|
+
DSH_WORKSPACE_TITLE: input.workspaceTitle,
|
|
211
|
+
},
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
const live: LiveRun = {
|
|
215
|
+
runId,
|
|
216
|
+
workspaceId: input.workspaceId,
|
|
217
|
+
commandName: input.commandName,
|
|
218
|
+
resolvedCommand: resolved,
|
|
219
|
+
kind: 'local',
|
|
220
|
+
handle,
|
|
221
|
+
stdoutBytes: 0,
|
|
222
|
+
stderrBytes: 0,
|
|
223
|
+
stdoutTail: '',
|
|
224
|
+
stderrTail: '',
|
|
225
|
+
stdoutClipped: false,
|
|
226
|
+
stderrClipped: false,
|
|
227
|
+
outcome: null,
|
|
228
|
+
killRequested: false,
|
|
229
|
+
startedAt: Date.now(),
|
|
230
|
+
}
|
|
231
|
+
this.runs.set(runId, live)
|
|
232
|
+
this.liveByWorkspace.set(input.workspaceId, runId)
|
|
233
|
+
|
|
234
|
+
// Collect stdout deltas as they arrive (offset-based; reader sees the tail).
|
|
235
|
+
const stdoutReader = handle.collected?.stdout
|
|
236
|
+
const stderrReader = handle.collected?.stderr
|
|
237
|
+
const drain = (): void => {
|
|
238
|
+
if (stdoutReader !== undefined) {
|
|
239
|
+
const read = stdoutReader.readFrom(live.stdoutBytes)
|
|
240
|
+
live.stdoutBytes = read.nextOffset
|
|
241
|
+
live.stdoutTail += read.text
|
|
242
|
+
if (live.stdoutTail.length > TAIL_MAX_BYTES) {
|
|
243
|
+
live.stdoutTail = live.stdoutTail.slice(-TAIL_MAX_BYTES)
|
|
244
|
+
live.stdoutClipped = true
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (stderrReader !== undefined) {
|
|
248
|
+
const read = stderrReader.readFrom(live.stderrBytes)
|
|
249
|
+
live.stderrBytes = read.nextOffset
|
|
250
|
+
live.stderrTail += read.text
|
|
251
|
+
if (live.stderrTail.length > TAIL_MAX_BYTES) {
|
|
252
|
+
live.stderrTail = live.stderrTail.slice(-TAIL_MAX_BYTES)
|
|
253
|
+
live.stderrClipped = true
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Read on a short cadence while live; final drain at exit.
|
|
258
|
+
const interval = setInterval(drain, 150)
|
|
259
|
+
void handle.done.then((outcome) => {
|
|
260
|
+
clearInterval(interval)
|
|
261
|
+
drain()
|
|
262
|
+
live.outcome = { exitCode: outcome.exitCode, signal: outcome.signal }
|
|
263
|
+
}).catch(() => {
|
|
264
|
+
clearInterval(interval)
|
|
265
|
+
live.outcome = { exitCode: null, signal: null }
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
return { ok: true, runId, resolvedCommand: resolved }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private async startRemote(
|
|
272
|
+
input: {
|
|
273
|
+
workspaceId: string
|
|
274
|
+
workspacePath: string
|
|
275
|
+
workspaceTitle: string
|
|
276
|
+
commandName: string
|
|
277
|
+
command: string
|
|
278
|
+
sessionCwd: string
|
|
279
|
+
popupAnchor: QuickCommandsSettings['popupAnchor']
|
|
280
|
+
},
|
|
281
|
+
hostId: string,
|
|
282
|
+
remotePath: string,
|
|
283
|
+
backend: RemoteBackend,
|
|
284
|
+
): Promise<{ ok: true; runId: string; remoteHost?: string; resolvedCommand: string } | { ok: false; error: string }> {
|
|
285
|
+
const cfg = backend.resolveHost(hostId)
|
|
286
|
+
if (cfg === undefined) {
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
error: `remote host ${hostId} is not configured in dsh-ssh-hosts — add it in Settings → SSH hosts`,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let conn
|
|
294
|
+
try {
|
|
295
|
+
conn = await backend.pool.acquire(cfg)
|
|
296
|
+
} catch (error) {
|
|
297
|
+
const e = error as { stage?: string; message?: string }
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
error: `ssh connect to host ${hostId} failed: ${e?.message ?? String(error)}`
|
|
301
|
+
+ (e?.stage ? ` (stage: ${e.stage})` : ''),
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Remote placeholder substitution: {workspace}/{cwd} are remote paths; the
|
|
306
|
+
// session cwd is re-anchored onto the remote filesystem when it sits inside
|
|
307
|
+
// the placeholder workspace.
|
|
308
|
+
const remoteCwd = resolveRemoteCwd(input.sessionCwd, input.workspacePath, remotePath)
|
|
309
|
+
const resolved = resolvePlaceholders(input.command, {
|
|
310
|
+
workspace: remotePath,
|
|
311
|
+
cwd: remoteCwd,
|
|
312
|
+
title: input.workspaceTitle,
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const runId = randomUUID()
|
|
316
|
+
const executor = new RemoteStreamExecutor(conn, backend.pool)
|
|
317
|
+
const live: LiveRun = {
|
|
318
|
+
runId,
|
|
319
|
+
workspaceId: input.workspaceId,
|
|
320
|
+
commandName: input.commandName,
|
|
321
|
+
resolvedCommand: resolved,
|
|
322
|
+
kind: 'remote',
|
|
323
|
+
handle: executor,
|
|
324
|
+
stdoutBytes: 0,
|
|
325
|
+
stderrBytes: 0,
|
|
326
|
+
stdoutTail: '',
|
|
327
|
+
stderrTail: '',
|
|
328
|
+
stdoutClipped: false,
|
|
329
|
+
stderrClipped: false,
|
|
330
|
+
outcome: null,
|
|
331
|
+
killRequested: false,
|
|
332
|
+
startedAt: Date.now(),
|
|
333
|
+
}
|
|
334
|
+
this.runs.set(runId, live)
|
|
335
|
+
this.liveByWorkspace.set(input.workspaceId, runId)
|
|
336
|
+
|
|
337
|
+
const append = (stream: 'stdout' | 'stderr', text: string): void => {
|
|
338
|
+
if (stream === 'stdout') {
|
|
339
|
+
live.stdoutBytes += Buffer.byteLength(text, 'utf8')
|
|
340
|
+
live.stdoutTail += text
|
|
341
|
+
if (live.stdoutTail.length > TAIL_MAX_BYTES) {
|
|
342
|
+
live.stdoutTail = live.stdoutTail.slice(-TAIL_MAX_BYTES)
|
|
343
|
+
live.stdoutClipped = true
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
live.stderrBytes += Buffer.byteLength(text, 'utf8')
|
|
347
|
+
live.stderrTail += text
|
|
348
|
+
if (live.stderrTail.length > TAIL_MAX_BYTES) {
|
|
349
|
+
live.stderrTail = live.stderrTail.slice(-TAIL_MAX_BYTES)
|
|
350
|
+
live.stderrClipped = true
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
void executor.start(
|
|
356
|
+
resolved,
|
|
357
|
+
remoteCwd,
|
|
358
|
+
append,
|
|
359
|
+
(exit) => {
|
|
360
|
+
if (exit.error !== undefined && exit.error !== '') {
|
|
361
|
+
append('stderr', `\n[ssh] ${exit.error}\n`)
|
|
362
|
+
}
|
|
363
|
+
live.outcome = {
|
|
364
|
+
exitCode: exit.exitCode,
|
|
365
|
+
signal: live.killRequested ? 'TERM' : exit.signal,
|
|
366
|
+
}
|
|
367
|
+
backend.pool.release()
|
|
368
|
+
},
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
return { ok: true, runId, remoteHost: hostId, resolvedCommand: resolved }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
poll(runId: string, stdoutFrom: number, stderrFrom: number): RunPollResult | null {
|
|
375
|
+
const live = this.runs.get(runId)
|
|
376
|
+
if (live === undefined) return null
|
|
377
|
+
|
|
378
|
+
// Drain pending tail first so the poll sees the freshest state.
|
|
379
|
+
if (live.kind === 'local') {
|
|
380
|
+
const handle = live.handle as SubprocessHandle
|
|
381
|
+
const stdoutReader = handle.collected?.stdout
|
|
382
|
+
const stderrReader = handle.collected?.stderr
|
|
383
|
+
if (stdoutReader !== undefined) {
|
|
384
|
+
const read = stdoutReader.readFrom(live.stdoutBytes)
|
|
385
|
+
live.stdoutBytes = read.nextOffset
|
|
386
|
+
live.stdoutTail += read.text
|
|
387
|
+
if (live.stdoutTail.length > TAIL_MAX_BYTES) {
|
|
388
|
+
live.stdoutTail = live.stdoutTail.slice(-TAIL_MAX_BYTES)
|
|
389
|
+
live.stdoutClipped = true
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (stderrReader !== undefined) {
|
|
393
|
+
const read = stderrReader.readFrom(live.stderrBytes)
|
|
394
|
+
live.stderrBytes = read.nextOffset
|
|
395
|
+
live.stderrTail += read.text
|
|
396
|
+
if (live.stderrTail.length > TAIL_MAX_BYTES) {
|
|
397
|
+
live.stderrTail = live.stderrTail.slice(-TAIL_MAX_BYTES)
|
|
398
|
+
live.stderrClipped = true
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Delta from the caller's offset; lossy when the offset slid out of the tail.
|
|
404
|
+
let stdoutDelta = live.stdoutTail
|
|
405
|
+
let stdoutLossy = live.stdoutClipped
|
|
406
|
+
if (stdoutFrom > 0 && stdoutFrom <= live.stdoutBytes) {
|
|
407
|
+
const tailStart = live.stdoutBytes - live.stdoutTail.length
|
|
408
|
+
if (stdoutFrom < tailStart) {
|
|
409
|
+
stdoutLossy = true
|
|
410
|
+
} else {
|
|
411
|
+
stdoutDelta = live.stdoutTail.slice(stdoutFrom - tailStart)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
let stderrDelta = live.stderrTail
|
|
415
|
+
let stderrLossy = live.stderrClipped
|
|
416
|
+
if (stderrFrom > 0 && stderrFrom <= live.stderrBytes) {
|
|
417
|
+
const tailStart = live.stderrBytes - live.stderrTail.length
|
|
418
|
+
if (stderrFrom < tailStart) {
|
|
419
|
+
stderrLossy = true
|
|
420
|
+
} else {
|
|
421
|
+
stderrDelta = live.stderrTail.slice(stderrFrom - tailStart)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const exited = live.outcome !== null
|
|
426
|
+
return {
|
|
427
|
+
kind: 'poll',
|
|
428
|
+
status: exited ? 'exited' : 'running',
|
|
429
|
+
exitCode: exited ? live.outcome!.exitCode : null,
|
|
430
|
+
signal: exited ? live.outcome!.signal : null,
|
|
431
|
+
stdoutEnd: live.stdoutBytes,
|
|
432
|
+
stderrEnd: live.stderrBytes,
|
|
433
|
+
stdoutDelta,
|
|
434
|
+
stderrDelta,
|
|
435
|
+
stdoutLossy,
|
|
436
|
+
stderrLossy,
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
kill(runId: string): 'ok' | 'not-found' | 'already-exited' {
|
|
441
|
+
const live = this.runs.get(runId)
|
|
442
|
+
if (live === undefined) return 'not-found'
|
|
443
|
+
if (live.outcome !== null) return 'already-exited'
|
|
444
|
+
if (live.kind === 'local') {
|
|
445
|
+
// Tree-scoped termination: SIGTERM → grace → SIGKILL escalation.
|
|
446
|
+
void (live.handle as SubprocessHandle).terminate()
|
|
447
|
+
} else {
|
|
448
|
+
live.killRequested = true
|
|
449
|
+
void (live.handle as RemoteStreamExecutor).kill()
|
|
450
|
+
}
|
|
451
|
+
return 'ok'
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
forWorkspace(workspaceId: string): Array<{ runId: string; commandName: string; startedAt: number; status: 'running' | 'exited' }> {
|
|
455
|
+
const ids = this.liveByWorkspace.get(workspaceId)
|
|
456
|
+
if (ids === undefined) return []
|
|
457
|
+
const live = this.runs.get(ids)
|
|
458
|
+
if (live === undefined) return []
|
|
459
|
+
return [{
|
|
460
|
+
runId: live.runId,
|
|
461
|
+
commandName: live.commandName,
|
|
462
|
+
startedAt: live.startedAt,
|
|
463
|
+
status: live.outcome === null ? 'running' : 'exited',
|
|
464
|
+
}]
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Every live run id (terminal and running) for fiber cleanup. */
|
|
468
|
+
listAll(): string[] {
|
|
469
|
+
return [...this.runs.keys()]
|
|
470
|
+
}
|
|
471
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-facing plugin settings (per-workspace command sets + popup anchor),
|
|
3
|
+
* persisted through the settings service namespace `quick-commands`.
|
|
4
|
+
* The DSH settings service requires a lowercase kebab-case namespace
|
|
5
|
+
* (/^[a-z][a-z0-9-]*$/), so the scoped package name cannot be used here.
|
|
6
|
+
*/
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
8
|
+
import z from '@deepseek-ai/schemastery'
|
|
9
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
10
|
+
import type { QuickCommandsSettings, QuickCommandEntry, QuickPopupSize, WorkspaceCommands } from '../shared/contract.js'
|
|
11
|
+
|
|
12
|
+
export const QUICK_COMMANDS_NS = settingsNamespace('quick-commands')
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One command entry schema. name/command may be EMPTY: the settings card edits
|
|
16
|
+
* rows field-by-field (instant save per keystroke), so a partially-typed row
|
|
17
|
+
* is a legitimate persisted state. Runnable validation happens at run.start.
|
|
18
|
+
*/
|
|
19
|
+
const CommandSchema = z.object({
|
|
20
|
+
name: z.string(),
|
|
21
|
+
command: z.string(),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/** One workspace command set. */
|
|
25
|
+
const WorkspaceCommandsSchema = z.object({
|
|
26
|
+
workspaceId: z.string().min(1),
|
|
27
|
+
commands: z.array(CommandSchema).max(200),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const QuickCommandsSettingsSchema: z<QuickCommandsSettings> = z.object({
|
|
31
|
+
workspaces: z.array(WorkspaceCommandsSchema).max(500),
|
|
32
|
+
popupAnchor: z.union([z.const('corner'), z.const('button')]).default('corner'),
|
|
33
|
+
// No default on purpose: a missing popupSize stays ABSENT in the resolved
|
|
34
|
+
// document, so the client can distinguish "never resized" (per-anchor
|
|
35
|
+
// default size) from a user-chosen size.
|
|
36
|
+
popupSize: z.object({
|
|
37
|
+
width: z.number().min(320).max(8192),
|
|
38
|
+
height: z.number().min(200).max(8192),
|
|
39
|
+
}),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const DEFAULT_SETTINGS: QuickCommandsSettings = {
|
|
43
|
+
workspaces: [],
|
|
44
|
+
popupAnchor: 'corner',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Structural settings-service subset; the real service resolves the schema. */
|
|
48
|
+
interface SettingsServiceLike {
|
|
49
|
+
register(ns: unknown, schema: unknown): unknown
|
|
50
|
+
update(ns: unknown, patch: Record<string, unknown>, expectedRevision?: number): Promise<unknown>
|
|
51
|
+
mutate?(ns: unknown, ops: readonly unknown[], expectedRevision?: number): Promise<unknown>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Settings service face for the quick-commands engine. */
|
|
55
|
+
export interface QuickCommandsSettingsFace {
|
|
56
|
+
get(): QuickCommandsSettings
|
|
57
|
+
/** Read the command set bound to one workspace id. */
|
|
58
|
+
commandsOf(workspaceId: string): QuickCommandEntry[]
|
|
59
|
+
/** Replace the whole command set for one workspace id (instant-save). */
|
|
60
|
+
setCommands(workspaceId: string, commands: QuickCommandEntry[]): Promise<void>
|
|
61
|
+
/** Set the popup anchor preference. */
|
|
62
|
+
setAnchor(anchor: QuickCommandsSettings['popupAnchor']): Promise<void>
|
|
63
|
+
/** Persist the output popup size (drag-resize). */
|
|
64
|
+
setPopupSize(size: QuickPopupSize): Promise<void>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function registerSettings(ctx: Context): QuickCommandsSettingsFace {
|
|
68
|
+
let current: QuickCommandsSettings = { ...DEFAULT_SETTINGS, workspaces: [] }
|
|
69
|
+
let service: SettingsServiceLike | undefined
|
|
70
|
+
|
|
71
|
+
ctx.inject(['settings'], (sctx) => {
|
|
72
|
+
service = sctx.settings as unknown as SettingsServiceLike
|
|
73
|
+
const scope = service.register(QUICK_COMMANDS_NS, QuickCommandsSettingsSchema) as {
|
|
74
|
+
get(): QuickCommandsSettings
|
|
75
|
+
watch(callback: (next: QuickCommandsSettings) => void): () => void
|
|
76
|
+
}
|
|
77
|
+
current = scope.get()
|
|
78
|
+
scope.watch((next) => { current = next })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
async function persist(patch: Record<string, unknown>): Promise<void> {
|
|
82
|
+
if (service === undefined) {
|
|
83
|
+
throw new Error('@fonlan/dsh-quick-commands: settings service is not available in this profile')
|
|
84
|
+
}
|
|
85
|
+
await service.update(QUICK_COMMANDS_NS, patch)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
get: () => current,
|
|
90
|
+
commandsOf: (workspaceId) => {
|
|
91
|
+
const set = current.workspaces.find((w) => w.workspaceId === workspaceId)
|
|
92
|
+
return set === undefined ? [] : set.commands
|
|
93
|
+
},
|
|
94
|
+
setCommands: async (workspaceId, commands) => {
|
|
95
|
+
const next: WorkspaceCommands[] = current.workspaces.filter((w) => w.workspaceId !== workspaceId)
|
|
96
|
+
if (commands.length > 0) next.push({ workspaceId, commands })
|
|
97
|
+
await persist({ workspaces: next })
|
|
98
|
+
},
|
|
99
|
+
setAnchor: async (anchor) => {
|
|
100
|
+
await persist({ popupAnchor: anchor })
|
|
101
|
+
},
|
|
102
|
+
setPopupSize: async (size) => {
|
|
103
|
+
await persist({ popupSize: size })
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared wire contracts for @fonlan/dsh-quick-commands.
|
|
3
|
+
*
|
|
4
|
+
* Browser-safe: no host-only imports live here. The client bundle imports
|
|
5
|
+
* these shapes (type-only + runtime constants), so anything that must cross
|
|
6
|
+
* the fenced JSON API keeps its plain-Object shape here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One configured quick command inside a workspace. */
|
|
10
|
+
export interface QuickCommandEntry {
|
|
11
|
+
/** Human-facing command name (unique within the workspace). */
|
|
12
|
+
name: string
|
|
13
|
+
/** Shell command line, executed through bash -c / pwsh -c. */
|
|
14
|
+
command: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Config slice bound to one workspace (keyed by workspaceId). */
|
|
18
|
+
export interface WorkspaceCommands {
|
|
19
|
+
/** DSH workspace registry id this command set belongs to. */
|
|
20
|
+
workspaceId: string
|
|
21
|
+
/** Ordered command entries. */
|
|
22
|
+
commands: QuickCommandEntry[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Output popup dimensions in CSS pixels (user drag-resize state). */
|
|
26
|
+
export interface QuickPopupSize {
|
|
27
|
+
width: number
|
|
28
|
+
height: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Full user-facing plugin settings document (settings namespace value). */
|
|
32
|
+
export interface QuickCommandsSettings {
|
|
33
|
+
/** Command sets; a workspace may be absent (no commands configured). */
|
|
34
|
+
workspaces: WorkspaceCommands[]
|
|
35
|
+
/** Output popup anchor preference: fixed bottom-right or anchored at the button. */
|
|
36
|
+
popupAnchor: 'corner' | 'button'
|
|
37
|
+
/** Output popup size; absent until the user drags the resize handle (then per-anchor defaults apply). */
|
|
38
|
+
popupSize?: QuickPopupSize
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Placeholder substitution values resolved for one execution. */
|
|
42
|
+
export interface QuickRunPlaceholders {
|
|
43
|
+
/** Workspace root path (also the execution cwd). */
|
|
44
|
+
workspace: string
|
|
45
|
+
/** Session actual cwd (may be a subdirectory of the workspace). */
|
|
46
|
+
cwd: string
|
|
47
|
+
/** Workspace display title. */
|
|
48
|
+
title: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One live run view returned by `run.start`. */
|
|
52
|
+
export interface QuickRunView {
|
|
53
|
+
runId: string
|
|
54
|
+
workspaceId: string
|
|
55
|
+
commandName: string
|
|
56
|
+
/** Command line after placeholder substitution (display only). */
|
|
57
|
+
resolvedCommand: string
|
|
58
|
+
/** Non-empty when the command executes over SSH on a remote workspace host. */
|
|
59
|
+
remoteHost?: string
|
|
60
|
+
status: 'running' | 'exited'
|
|
61
|
+
exitCode: number | null
|
|
62
|
+
signal: string | null
|
|
63
|
+
startedAt: number
|
|
64
|
+
/** Total stdout bytes captured so far (offset currency). */
|
|
65
|
+
stdoutEnd: number
|
|
66
|
+
/** Total stderr bytes captured so far (offset currency). */
|
|
67
|
+
stderrEnd: number
|
|
68
|
+
/** stdout tail appended since `stdoutFrom` (whole tail when lossy). */
|
|
69
|
+
stdoutDelta: string
|
|
70
|
+
/** stderr tail appended since `stderrFrom` (whole tail when lossy). */
|
|
71
|
+
stderrDelta: string
|
|
72
|
+
/** True when the in-memory tail lost its head for this stream. */
|
|
73
|
+
stdoutLossy: boolean
|
|
74
|
+
stderrLossy: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** API response envelope (mirrors task-kanban's fenced fetch wrapper). */
|
|
78
|
+
export interface ApiEnvelope<T> {
|
|
79
|
+
ok: boolean
|
|
80
|
+
value?: T
|
|
81
|
+
error?: { code: string; message: string }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Settings card view: command sets plus the workspace roster for display. */
|
|
85
|
+
export interface QuickRosterEntry {
|
|
86
|
+
workspaceId: string
|
|
87
|
+
path: string
|
|
88
|
+
title: string
|
|
89
|
+
/** SSH host id when the workspace runs remotely through dsh-ssh (else absent). */
|
|
90
|
+
remoteHost?: string
|
|
91
|
+
commands: QuickCommandEntry[]
|
|
92
|
+
}
|