@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.
@@ -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
+ }