@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,209 @@
1
+ /**
2
+ * Quick-commands settings card (设置 → 插件配置): an expandable plugin card —
3
+ * header (title + description + chevron) collapsed by default, body reveals
4
+ * the workspace × command-set editor and the popup anchor preference. Body
5
+ * edits instant-save per change; partial rows (empty name/command) persist
6
+ * while the user types.
7
+ *
8
+ * Mirrors the official PluginCard disclosure pattern (header button with
9
+ * aria-expanded, rotate-180 chevron, body separated by a top border).
10
+ */
11
+ import { useEffect, useRef, useState } from 'react'
12
+ import {
13
+ IconChevronDownOutline14,
14
+ IconPlusOutline16,
15
+ IconTrashOutline16,
16
+ } from '@deepseek-ai/dsh-client-ui-primitives'
17
+ import { quickApi } from './api'
18
+ import type { QuickRosterEntry, QuickCommandsSettings } from '../shared/contract'
19
+
20
+ interface Props {
21
+ t: (key: string) => string
22
+ }
23
+
24
+ interface RosterState {
25
+ loaded: boolean
26
+ entries: QuickRosterEntry[]
27
+ anchor: QuickCommandsSettings['popupAnchor']
28
+ }
29
+
30
+ type CommandDraft = { name: string; command: string }
31
+
32
+ export function QuickCommandsSettingsCard({ t }: Props): JSX.Element {
33
+ // Card-local disclosure state (a reading gesture, like the official card).
34
+ const [open, setOpen] = useState(false)
35
+ const [state, setState] = useState<RosterState>({ loaded: false, entries: [], anchor: 'corner' })
36
+ const [error, setError] = useState<string | null>(null)
37
+ // Guard concurrent saves: the last write wins via a monotonically bumped token.
38
+ const saveToken = useRef(0)
39
+
40
+ const load = async (): Promise<void> => {
41
+ try {
42
+ const [entries, settings] = await Promise.all([quickApi.workspacesList(), quickApi.settingsGet()])
43
+ setState({ loaded: true, entries, anchor: settings.popupAnchor })
44
+ setError(null)
45
+ } catch (e) {
46
+ setState((s) => ({ ...s, loaded: true }))
47
+ setError(e instanceof Error ? e.message : String(e))
48
+ }
49
+ }
50
+
51
+ useEffect(() => {
52
+ void load()
53
+ }, [])
54
+
55
+ /** Instant-save the workspace's whole command set; optimistic UI. */
56
+ const saveCommands = async (workspaceId: string, commands: CommandDraft[]): Promise<void> => {
57
+ const token = ++saveToken.current
58
+ try {
59
+ const next = await quickApi.settingsSetCommands(workspaceId, commands)
60
+ if (token !== saveToken.current) return
61
+ setState((s) => ({
62
+ ...s,
63
+ anchor: next.popupAnchor,
64
+ entries: s.entries.map((e) => (e.workspaceId === workspaceId ? { ...e, commands } : e)),
65
+ }))
66
+ setError(null)
67
+ } catch (e) {
68
+ setError(e instanceof Error ? e.message : String(e))
69
+ // Re-sync to server truth on failure.
70
+ void load()
71
+ }
72
+ }
73
+
74
+ /** Compute the next command list for a workspace, THEN save + update state. */
75
+ const mutateCommands = (workspaceId: string, next: (current: CommandDraft[]) => CommandDraft[]): void => {
76
+ const entry = state.entries.find((e) => e.workspaceId === workspaceId)
77
+ if (entry === undefined) return
78
+ const commands = next(entry.commands)
79
+ // Optimistic update first (row stays visible immediately), then persist.
80
+ setState((s) => ({
81
+ ...s,
82
+ entries: s.entries.map((e) => (e.workspaceId === workspaceId ? { ...e, commands } : e)),
83
+ }))
84
+ void saveCommands(workspaceId, commands)
85
+ }
86
+
87
+ const patchEntry = (workspaceId: string, index: number, field: 'name' | 'command', value: string): void => {
88
+ mutateCommands(workspaceId, (current) =>
89
+ current.map((c, i) => (i === index ? { ...c, [field]: value } : c)),
90
+ )
91
+ }
92
+
93
+ const addCommand = (workspaceId: string): void => {
94
+ mutateCommands(workspaceId, (current) => [...current, { name: '', command: '' }])
95
+ }
96
+
97
+ const removeCommand = (workspaceId: string, index: number): void => {
98
+ mutateCommands(workspaceId, (current) => current.filter((_, i) => i !== index))
99
+ }
100
+
101
+ const setAnchor = async (anchor: QuickCommandsSettings['popupAnchor']): Promise<void> => {
102
+ setState((s) => ({ ...s, anchor }))
103
+ try {
104
+ const next = await quickApi.settingsSetAnchor(anchor)
105
+ setState((s) => ({ ...s, anchor: next.popupAnchor }))
106
+ } catch (e) {
107
+ setError(e instanceof Error ? e.message : String(e))
108
+ void load()
109
+ }
110
+ }
111
+
112
+ const title = t('cardTitle')
113
+ const description = t('cardDescription')
114
+
115
+ return (
116
+ <li className={'qc-settings-card' + (open ? ' qc-settings-card-open' : '')}>
117
+ <button
118
+ type="button"
119
+ className="qc-settings-header"
120
+ aria-expanded={open}
121
+ aria-label={`${t('collapseOrExpand')}: ${title}`}
122
+ onClick={() => setOpen((v) => !v)}
123
+ >
124
+ <span className="qc-settings-head-text">
125
+ <span className="qc-settings-name">{title}</span>
126
+ <span className="qc-settings-description">{description}</span>
127
+ </span>
128
+ <IconChevronDownOutline14 className={'qc-settings-chevron' + (open ? ' qc-settings-chevron-open' : '')} />
129
+ </button>
130
+
131
+ {open && (
132
+ <div className="qc-settings-body">
133
+ <p className="qc-settings-intro">{t('cardIntro')}</p>
134
+
135
+ <div className="qc-settings-anchor">
136
+ <span className="qc-settings-anchor-label">{t('cardAnchor')}</span>
137
+ <label className="qc-settings-radio">
138
+ <input
139
+ type="radio"
140
+ name="qc-anchor"
141
+ checked={state.anchor === 'corner'}
142
+ onChange={() => void setAnchor('corner')}
143
+ />
144
+ {t('cardAnchorCorner')}
145
+ </label>
146
+ <label className="qc-settings-radio">
147
+ <input
148
+ type="radio"
149
+ name="qc-anchor"
150
+ checked={state.anchor === 'button'}
151
+ onChange={() => void setAnchor('button')}
152
+ />
153
+ {t('cardAnchorButton')}
154
+ </label>
155
+ </div>
156
+
157
+ {error !== null && <div className="qc-settings-error">{t('cardSaveError')}: {error}</div>}
158
+
159
+ {state.loaded && state.entries.length === 0 && (
160
+ <p className="qc-settings-empty">{t('cardNoWorkspaces')}</p>
161
+ )}
162
+
163
+ {state.entries.map((entry) => (
164
+ <section key={entry.workspaceId} className="qc-settings-workspace">
165
+ <header className="qc-settings-workspace-head">
166
+ <span className="qc-settings-workspace-title">{entry.title || entry.path || entry.workspaceId}</span>
167
+ <span className="qc-settings-workspace-path">{entry.path}</span>
168
+ </header>
169
+ {entry.commands.length === 0 && (
170
+ <p className="qc-settings-no-commands">{t('cardNoCommands')}</p>
171
+ )}
172
+ {entry.commands.map((command, index) => (
173
+ <div key={`${entry.workspaceId}-${index}`} className="qc-settings-command">
174
+ <input
175
+ className="qc-settings-field qc-settings-field-name"
176
+ placeholder={t('cardName')}
177
+ value={command.name}
178
+ aria-label={t('cardName')}
179
+ onChange={(e) => patchEntry(entry.workspaceId, index, 'name', e.target.value)}
180
+ />
181
+ <input
182
+ className="qc-settings-field qc-settings-field-command"
183
+ placeholder={t('cardCommand')}
184
+ value={command.command}
185
+ aria-label={t('cardCommand')}
186
+ onChange={(e) => patchEntry(entry.workspaceId, index, 'command', e.target.value)}
187
+ />
188
+ <button
189
+ type="button"
190
+ className="qc-settings-icon-btn"
191
+ aria-label={t('cardRemove')}
192
+ title={t('cardRemove')}
193
+ onClick={() => removeCommand(entry.workspaceId, index)}
194
+ >
195
+ <IconTrashOutline16 size={14} />
196
+ </button>
197
+ </div>
198
+ ))}
199
+ <button type="button" className="qc-settings-add-btn" onClick={() => addCommand(entry.workspaceId)}>
200
+ <IconPlusOutline16 size={14} />
201
+ {t('cardAddCommand')}
202
+ </button>
203
+ </section>
204
+ ))}
205
+ </div>
206
+ )}
207
+ </li>
208
+ )
209
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Declare this plugin's locale namespace so slot registrations can type-check
3
+ * the `locale` seat. zh is the key source of truth; en must carry the exact
4
+ * same key set (checked at runtime by the locale registry).
5
+ */
6
+ import type { zh } from './locales'
7
+
8
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
9
+ interface LocaleNamespaceMap {
10
+ 'quick-commands': keyof typeof zh
11
+ }
12
+ }
13
+
14
+ export {}
package/src/index.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @fonlan/dsh-quick-commands host half: per-workspace quick commands.
3
+ *
4
+ * Registers the `quick-commands` settings namespace (workspaceId → command
5
+ * sets + popup anchor), owns the subprocess runner that executes commands with
6
+ * a fixed workspace cwd, and serves the fenced JSON API the client calls for
7
+ * config CRUD and live command output.
8
+ *
9
+ * Remote workspaces: when the workspace path is a @dsh-ssh/dsh-ssh placeholder
10
+ * directory and the `sshPool` service is present, commands stream over SSH on
11
+ * the remote host (same tail/offset semantics as local runs). The SSH backend
12
+ * is resolved lazily per run, so plugin load order is irrelevant and the
13
+ * absence of dsh-ssh degrades cleanly to local-only behaviour.
14
+ */
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import { QuickCommandRunner, type RemoteBackend } from './server/runner.js'
17
+ import { readHostConfig } from './server/remote.js'
18
+ import { registerSettings } from './server/settings.js'
19
+ import { registerApiRoutes } from './server/rpc.js'
20
+
21
+ export const name = '@fonlan/dsh-quick-commands'
22
+
23
+ export const inject = ['settings']
24
+
25
+ /** No plugin-level config; all configuration lives in the settings namespace. */
26
+ export function apply(ctx: Context): void {
27
+ const settings = registerSettings(ctx)
28
+ const subprocess = ctx.get('subprocess')
29
+ if (subprocess === undefined) {
30
+ ctx.logger?.warn?.('@fonlan/dsh-quick-commands: subprocess service unavailable; commands cannot run')
31
+ return
32
+ }
33
+ // Remote backend (duck-typed over @dsh-ssh/dsh-ssh): resolved lazily so the
34
+ // sshPool service is picked up whenever the SSH plugin loads.
35
+ const resolveRemote = (): RemoteBackend | undefined => {
36
+ const sshPool = ctx.get('sshPool') as
37
+ | { acquire(cfg: { id: string; [key: string]: unknown }): Promise<unknown>; release(): void }
38
+ | undefined
39
+ if (sshPool === undefined || typeof sshPool.acquire !== 'function' || typeof sshPool.release !== 'function') {
40
+ return undefined
41
+ }
42
+ return {
43
+ pool: sshPool as never,
44
+ resolveHost: (hostId) => readHostConfig((ns) => {
45
+ try {
46
+ return (ctx.get('settings') as { get(ns: string): unknown } | undefined)?.get(ns)
47
+ } catch {
48
+ return undefined
49
+ }
50
+ }, hostId),
51
+ }
52
+ }
53
+ const runner = new QuickCommandRunner(subprocess as never, () => resolveRemote())
54
+ ctx.effect(() => registerApiRoutes(ctx, runner, settings), 'quick-commands: api routes')
55
+ ctx.effect(() => () => {
56
+ // Terminate every live process when the plugin fiber stops.
57
+ for (const id of runner.listAll()) {
58
+ runner.kill(id)
59
+ }
60
+ }, 'quick-commands: process cleanup')
61
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Remote (SSH) execution support: detect that a workspace path is a dsh-ssh
3
+ * placeholder directory, and stream a quick command over an acquired SSH
4
+ * connection.
5
+ *
6
+ * Deliberately dependency-free: the @dsh-ssh/dsh-ssh host plugin is consumed
7
+ * only through duck-typed services (`sshPool` service + the `dsh-ssh-hosts`
8
+ * settings namespace). When that plugin is absent everything degrades to the
9
+ * local path (the runner keeps its subprocess backend untouched).
10
+ *
11
+ * The placeholder-path contract mirrors dsh-ssh/src/router.js exactly (it is
12
+ * that plugin's public wire format):
13
+ * root = $DSH_SSH_REMOTE_ROOT | $DSH_HOME/remote | ~/.dsh/remote
14
+ * layout = <root>/<hostId>/<base64url(remote absolute path)>
15
+ * The encoded segment must decode to an absolute POSIX path (starts with
16
+ * '/'); anything else is never misdetected as remote.
17
+ */
18
+ import os from 'node:os'
19
+ import path from 'node:path'
20
+
21
+ /** Remote host id + remote working directory decoded from a placeholder path. */
22
+ export interface RemoteWorkspaceRef {
23
+ hostId: string
24
+ /** Remote absolute path of the workspace root. */
25
+ remotePath: string
26
+ }
27
+
28
+ // ── placeholder path detection (mirrors dsh-ssh/src/router.js) ──────────────
29
+
30
+ export function remoteRoot(env: NodeJS.ProcessEnv = process.env): string {
31
+ if (env.DSH_SSH_REMOTE_ROOT !== undefined && env.DSH_SSH_REMOTE_ROOT !== '') {
32
+ return path.resolve(String(env.DSH_SSH_REMOTE_ROOT))
33
+ }
34
+ const dshHome =
35
+ env.DSH_HOME !== undefined && env.DSH_HOME !== ''
36
+ ? String(env.DSH_HOME)
37
+ : path.join(os.homedir(), '.dsh')
38
+ return path.join(dshHome, 'remote')
39
+ }
40
+
41
+ function isValidHostId(hostId: string): boolean {
42
+ if (hostId.length === 0) return false
43
+ if (hostId === '.' || hostId === '..') return false
44
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(hostId)
45
+ }
46
+
47
+ function encodeRemotePath(remotePath: string): string {
48
+ return Buffer.from(String(remotePath), 'utf8').toString('base64url')
49
+ }
50
+
51
+ function decodeRemotePath(encoded: string): string | null {
52
+ if (typeof encoded !== 'string' || encoded.length === 0) return null
53
+ if (!/^[A-Za-z0-9_-]+$/.test(encoded)) return null
54
+ let text: string
55
+ try {
56
+ text = Buffer.from(encoded, 'base64url').toString('utf8')
57
+ } catch {
58
+ return null
59
+ }
60
+ if (encodeRemotePath(text) !== encoded) return null
61
+ return text
62
+ }
63
+
64
+ /**
65
+ * Local absolute path → { hostId, remotePath } when it is a dsh-ssh
66
+ * placeholder (`<root>/<hostId>/<encoded>`), otherwise null. A coincidentally
67
+ * matching real directory is never misdetected: the encoded segment must
68
+ * round-trip to an absolute POSIX path.
69
+ */
70
+ export function localPathToRemoteRef(localPath: string, env: NodeJS.ProcessEnv = process.env): RemoteWorkspaceRef | null {
71
+ if (typeof localPath !== 'string' || localPath.length === 0) return null
72
+ const root = remoteRoot(env)
73
+ const rel = path.relative(root, localPath)
74
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null
75
+ const segments = rel.split(path.sep)
76
+ if (segments.length !== 2) return null
77
+ const hostId = segments[0]
78
+ const encoded = segments[1]
79
+ if (!isValidHostId(hostId)) return null
80
+ const remotePath = decodeRemotePath(encoded)
81
+ if (remotePath === null) return null
82
+ if (!remotePath.startsWith('/')) return null
83
+ return { hostId, remotePath }
84
+ }
85
+
86
+ /**
87
+ * Map a session cwd (which may be inside the placeholder workspace, e.g. a
88
+ * subdirectory of the workspace root) onto the remote filesystem. Only paths
89
+ * strictly inside the placeholder are re-anchored; anything else falls back to
90
+ * the workspace's remote root.
91
+ */
92
+ export function resolveRemoteCwd(sessionCwd: string, workspaceLocalPath: string, remotePath: string): string {
93
+ const rel = path.relative(workspaceLocalPath, sessionCwd)
94
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) {
95
+ return path.posix.resolve(remotePath, rel.split(path.sep).join('/'))
96
+ }
97
+ return remotePath
98
+ }
99
+
100
+ // ── duck-typed SSH surface (provided by @dsh-ssh/dsh-ssh at runtime) ─────────
101
+
102
+ /** Streaming chunk the remote exec yields before the final exit-code item. */
103
+ export interface RemoteExecChunk {
104
+ stream: 'stdout' | 'stderr'
105
+ chunk: Buffer
106
+ }
107
+
108
+ /** The terminal item yielded by execStream: remote channel exit code. */
109
+ export interface RemoteExecExit {
110
+ exitCode: number
111
+ }
112
+
113
+ export type RemoteExecEvent = RemoteExecChunk | RemoteExecExit
114
+
115
+ /** SshConn minimal surface (see dsh-ssh ssh-core SshConn). */
116
+ export interface SshConnLike {
117
+ execStream(cmd: string, opts?: { cwd?: string; timeoutMs?: number }): AsyncGenerator<RemoteExecEvent>
118
+ exec(cmd: string, opts?: { cwd?: string; timeoutMs?: number }): Promise<{ code: number; signal: string | null; stdout: string; stderr: string }>
119
+ }
120
+
121
+ /** SshPool minimal surface (see dsh-ssh ssh-core SshPool). */
122
+ export interface SshPoolLike {
123
+ acquire(cfg: { id: string; [key: string]: unknown }): Promise<SshConnLike>
124
+ release(): void
125
+ }
126
+
127
+ /** Error carrying a SSH-stage tag (mirrors dsh-ssh SshError.stage). */
128
+ export interface SshStageError {
129
+ stage?: string
130
+ message?: string
131
+ }
132
+
133
+ /**
134
+ * Read one host's connection config from the `dsh-ssh-hosts` settings
135
+ * namespace (id → HostConfig dict, as written by the @dsh-ssh/dsh-ssh
136
+ * settings page). Returns `{ ...host, id }` for pool.acquire, or undefined
137
+ * when the host is not configured. `get` is the settings.get(ns) reader.
138
+ */
139
+ export function readHostConfig(
140
+ get: (ns: string) => unknown,
141
+ hostId: string,
142
+ ): { id: string; [key: string]: unknown } | undefined {
143
+ let doc: unknown
144
+ try {
145
+ doc = get('dsh-ssh-hosts')
146
+ } catch {
147
+ return undefined
148
+ }
149
+ if (typeof doc !== 'object' || doc === null) return undefined
150
+ const hosts = (doc as { hosts?: Record<string, unknown> }).hosts
151
+ if (typeof hosts !== 'object' || hosts === null) return undefined
152
+ const host = hosts[hostId]
153
+ if (typeof host !== 'object' || host === null) return undefined
154
+ return { ...(host as Record<string, unknown>), id: hostId }
155
+ }
156
+
157
+ function isChunk(item: RemoteExecEvent): item is RemoteExecChunk {
158
+ return typeof item === 'object' && item !== null && 'stream' in item
159
+ }
160
+
161
+ /** Single-quote one string for a POSIX shell (' → '\''). */
162
+ function shellQuoteSingle(s: string): string {
163
+ return "'" + String(s).replace(/'/g, "'\\''") + "'"
164
+ }
165
+
166
+ /** stderr marker line: the remote process-group leader's pid ($$ of the sshd shell). */
167
+ const PID_MARKER = '__QC_PID__'
168
+
169
+ // ── remote quick-command executor ────────────────────────────────────────────
170
+
171
+ /**
172
+ * One remote execution stream. Consumes conn.execStream in the background,
173
+ * appends decoded chunks into the caller-provided tail buffers, and records the
174
+ * terminal outcome.
175
+ *
176
+ * The command runs on the remote shell that sshd spawns for the channel (that
177
+ * shell is the session/process-group leader; `$$` is its pid AND its pgid). The
178
+ * leader pid is probed by prefixing `echo <marker> $$ >&2` to the command, so
179
+ * `kill` can terminate the WHOLE process group (`kill -TERM -- -<pid>`), which
180
+ * `pkill -f` alone cannot do for a child like `sleep 30` whose cmdline does not
181
+ * match the original command text. The probe line is filtered out of the stderr
182
+ * stream the user sees (a short buffer handles chunk-cut lines).
183
+ */
184
+ export class RemoteStreamExecutor {
185
+ private cmd = ''
186
+ private cwd = ''
187
+ private started = false
188
+ /** Remote process-group leader pid (parse the marker line; null until seen). */
189
+ private leaderPid: number | null = null
190
+ /** Raw stderr head buffer for the (possibly chunk-cut) marker line. */
191
+ private markerBuf = ''
192
+
193
+ constructor(
194
+ private readonly conn: SshConnLike,
195
+ private readonly pool: SshPoolLike,
196
+ ) {}
197
+
198
+ /**
199
+ * Start consuming the remote stream, settling the returned promise when the
200
+ * stream ends. `onData` sees decoded text chunks, `onExit` the terminal
201
+ * outcome (called exactly once, also on connection failure).
202
+ */
203
+ async start(
204
+ cmd: string,
205
+ cwd: string,
206
+ onData: (stream: 'stdout' | 'stderr', text: string) => void,
207
+ onExit: (exit: { exitCode: number | null; signal: string | null; error?: string }) => void,
208
+ ): Promise<void> {
209
+ this.cmd = cmd
210
+ this.cwd = cwd
211
+ this.started = true
212
+ // Probe the group leader pid on stderr, then run the user command in a
213
+ // subshell so a stray `exit` cannot end our session shell early.
214
+ const wrapped = `echo ${PID_MARKER} $$ >&2; cd ${shellQuoteSingle(cwd)} && ( ${cmd} )`
215
+ let exitCode: number | null = null
216
+ let signal: string | null = null
217
+ try {
218
+ for await (const raw of this.conn.execStream(wrapped)) {
219
+ const item = raw as RemoteExecEvent
220
+ if (isChunk(item)) {
221
+ if (item.stream === 'stdout') {
222
+ onData('stdout', item.chunk.toString('utf8'))
223
+ } else {
224
+ this.emitStderr(item.chunk.toString('utf8'), onData)
225
+ }
226
+ } else {
227
+ exitCode = item.exitCode
228
+ }
229
+ }
230
+ } catch (error) {
231
+ const e = error as SshStageError
232
+ onExit({
233
+ exitCode: null,
234
+ signal: null,
235
+ error: (e?.message ?? String(error)) + (e?.stage ? ` (stage: ${e.stage})` : ''),
236
+ })
237
+ return
238
+ }
239
+ onExit({ exitCode, signal })
240
+ }
241
+
242
+ /** Route stderr text, swallowing the (possibly split) marker line. */
243
+ private emitStderr(text: string, onData: (stream: 'stdout' | 'stderr', text: string) => void): void {
244
+ if (this.leaderPid !== null) {
245
+ onData('stderr', text)
246
+ return
247
+ }
248
+ this.markerBuf += text
249
+ const nl = this.markerBuf.indexOf('\n')
250
+ if (nl === -1) {
251
+ // No newline yet: still potentially the marker line. Flush only when the
252
+ // buffer grows absurdly (marker lines are tiny).
253
+ if (this.markerBuf.length > 4096) {
254
+ onData('stderr', this.markerBuf)
255
+ this.markerBuf = ''
256
+ }
257
+ return
258
+ }
259
+ const head = this.markerBuf.slice(0, nl)
260
+ this.markerBuf = this.markerBuf.slice(nl + 1)
261
+ const m = /^__QC_PID__\s+(\d+)\s*$/.exec(head)
262
+ if (m !== null) {
263
+ this.leaderPid = Number(m[1])
264
+ } else {
265
+ onData('stderr', head + '\n')
266
+ }
267
+ if (this.markerBuf !== '') {
268
+ const rest = this.markerBuf
269
+ this.markerBuf = ''
270
+ onData('stderr', rest)
271
+ }
272
+ }
273
+
274
+ /** The probed group-leader pid, if the marker line has been seen. */
275
+ get pid(): number | null {
276
+ return this.leaderPid
277
+ }
278
+
279
+ /**
280
+ * Terminate the remote command's whole process group (best-effort). With the
281
+ * probed leader pid this is a true group kill (covers children like sleep);
282
+ * before the probe arrives (or when it never does) it falls back to a
283
+ * `pkill -f` on the exact `cd <cwd> && <cmd>` channel command line. The
284
+ * original stream then closes; the caller records signal 'TERM'.
285
+ */
286
+ async kill(): Promise<void> {
287
+ if (!this.started) return
288
+ if (this.leaderPid !== null) {
289
+ try {
290
+ await this.conn.exec(`kill -TERM -- -${this.leaderPid} 2>/dev/null; pkill -TERM -P ${this.leaderPid} 2>/dev/null; true`, { timeoutMs: 10_000 })
291
+ return
292
+ } catch {
293
+ // Fall through to pkill.
294
+ }
295
+ }
296
+ const pattern = 'cd ' + (this.cwd ? this.cwd + ' && ' : '') + this.cmd
297
+ const escaped = pattern.replace(/[.*+?^$[\]{}()|/\\]/g, '\\$&').replace(/'/g, "'\\''")
298
+ try {
299
+ await this.conn.exec(`pkill -TERM -f '${escaped}' 2>/dev/null; true`, { timeoutMs: 10_000 })
300
+ } catch {
301
+ // Best-effort: the stream may already have settled.
302
+ }
303
+ }
304
+ }