typeclaw 0.3.0 → 0.4.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.
Files changed (101) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +2 -1
  4. package/scripts/dump-system-prompt.ts +401 -0
  5. package/secrets.schema.json +113 -0
  6. package/src/agent/index.ts +149 -30
  7. package/src/agent/provider-error.ts +44 -0
  8. package/src/agent/session-meta.ts +43 -0
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/subagents.ts +8 -0
  11. package/src/agent/system-prompt.ts +70 -35
  12. package/src/bundled-plugins/security/index.ts +3 -2
  13. package/src/channels/adapters/github/auth-app.ts +120 -0
  14. package/src/channels/adapters/github/auth-pat.ts +50 -0
  15. package/src/channels/adapters/github/auth.ts +33 -0
  16. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  17. package/src/channels/adapters/github/dedup.ts +26 -0
  18. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  19. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  20. package/src/channels/adapters/github/history.ts +63 -0
  21. package/src/channels/adapters/github/inbound.ts +286 -0
  22. package/src/channels/adapters/github/index.ts +286 -0
  23. package/src/channels/adapters/github/managed-path.ts +54 -0
  24. package/src/channels/adapters/github/membership.ts +35 -0
  25. package/src/channels/adapters/github/outbound.ts +145 -0
  26. package/src/channels/adapters/github/webhook-register.ts +349 -0
  27. package/src/channels/manager.ts +94 -9
  28. package/src/channels/router.ts +28 -2
  29. package/src/channels/schema.ts +31 -1
  30. package/src/channels/tunnel-bridge.ts +51 -0
  31. package/src/cli/builtins.ts +28 -0
  32. package/src/cli/channel.ts +511 -25
  33. package/src/cli/container-command-client.ts +244 -0
  34. package/src/cli/cron.ts +173 -0
  35. package/src/cli/host-command-runner.ts +150 -0
  36. package/src/cli/index.ts +42 -1
  37. package/src/cli/init.ts +256 -27
  38. package/src/cli/model.ts +4 -2
  39. package/src/cli/plugin-command-help.ts +49 -0
  40. package/src/cli/plugin-commands-dispatch.ts +112 -0
  41. package/src/cli/plugin-commands.ts +118 -0
  42. package/src/cli/tui.ts +10 -2
  43. package/src/cli/tunnel.ts +533 -0
  44. package/src/cli/ui.ts +8 -3
  45. package/src/cli/usage.ts +30 -2
  46. package/src/config/config.ts +90 -4
  47. package/src/config/reloadable.ts +22 -4
  48. package/src/container/start.ts +30 -3
  49. package/src/cron/bridge.ts +136 -0
  50. package/src/cron/consumer.ts +62 -6
  51. package/src/cron/index.ts +19 -2
  52. package/src/cron/list.ts +105 -0
  53. package/src/cron/scheduler.ts +12 -3
  54. package/src/cron/schema.ts +11 -3
  55. package/src/doctor/checks.ts +0 -50
  56. package/src/init/dockerfile.ts +59 -13
  57. package/src/init/ensure-deps.ts +15 -4
  58. package/src/init/github-webhook-install.ts +109 -0
  59. package/src/init/index.ts +505 -9
  60. package/src/init/run-bun-install.ts +17 -3
  61. package/src/init/run-owner-claim.ts +11 -2
  62. package/src/permissions/builtins.ts +6 -1
  63. package/src/permissions/match-rule.ts +24 -2
  64. package/src/permissions/resolve.ts +1 -0
  65. package/src/plugin/define.ts +42 -1
  66. package/src/plugin/index.ts +18 -3
  67. package/src/plugin/manager.ts +2 -0
  68. package/src/plugin/registry.ts +85 -3
  69. package/src/plugin/types.ts +138 -1
  70. package/src/plugin/zod-introspect.ts +100 -0
  71. package/src/role-claim/match-rule.ts +2 -1
  72. package/src/run/index.ts +119 -4
  73. package/src/secrets/index.ts +1 -1
  74. package/src/secrets/schema.ts +21 -0
  75. package/src/server/command-runner.ts +476 -0
  76. package/src/server/index.ts +393 -15
  77. package/src/shared/index.ts +8 -0
  78. package/src/shared/protocol.ts +80 -1
  79. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  80. package/src/skills/typeclaw-config/SKILL.md +27 -26
  81. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  82. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  83. package/src/skills/typeclaw-permissions/SKILL.md +5 -4
  84. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  85. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  86. package/src/test-helpers/wait-for.ts +50 -0
  87. package/src/tui/index.ts +35 -4
  88. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  89. package/src/tunnels/events.ts +14 -0
  90. package/src/tunnels/index.ts +12 -0
  91. package/src/tunnels/log-ring.ts +54 -0
  92. package/src/tunnels/manager.ts +139 -0
  93. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  94. package/src/tunnels/providers/external.ts +53 -0
  95. package/src/tunnels/quick-url-parser.ts +5 -0
  96. package/src/tunnels/types.ts +43 -0
  97. package/src/usage/aggregate.ts +30 -1
  98. package/src/usage/index.ts +3 -2
  99. package/src/usage/report.ts +103 -3
  100. package/src/usage/scan.ts +59 -4
  101. package/typeclaw.schema.json +254 -1
@@ -0,0 +1,476 @@
1
+ import { createSessionWithDispose, type SessionOrigin } from '@/agent'
2
+ import type { PermissionService } from '@/permissions'
3
+ import type {
4
+ CommandExecResult,
5
+ ContainerCommand,
6
+ ContainerCommandContext,
7
+ EitherCommand,
8
+ EitherCommandContext,
9
+ PluginLogger,
10
+ RegisteredCommand,
11
+ SpawnSubagentOptions,
12
+ } from '@/plugin'
13
+ import type { PluginRuntime } from '@/run/plugin-runtime'
14
+
15
+ export type CommandSpawnSubagent = (name: string, payload?: unknown, options?: SpawnSubagentOptions) => Promise<void>
16
+
17
+ export type CommandOutbound = {
18
+ stdout: (callId: string, chunk: Uint8Array) => void
19
+ stderr: (callId: string, chunk: Uint8Array) => void
20
+ exit: (callId: string, code: number) => void
21
+ error: (callId: string, message: string) => void
22
+ }
23
+
24
+ export type CommandRunnerOptions = {
25
+ pluginRuntime: PluginRuntime
26
+ permissions: PermissionService
27
+ spawnSubagent: CommandSpawnSubagent
28
+ agentDir: string
29
+ runtimeVersion: string | undefined
30
+ containerName: string | undefined
31
+ outbound: CommandOutbound
32
+ }
33
+
34
+ type CommandHandle = {
35
+ callId: string
36
+ abortController: AbortController
37
+ stdinQueue: StdinQueue
38
+ ownerKey: WsOwnerKey
39
+ done: Promise<void>
40
+ }
41
+
42
+ export type WsOwnerKey = object | null
43
+
44
+ export type CommandRunner = {
45
+ start: (
46
+ msg: {
47
+ callId: string
48
+ name: string
49
+ args: unknown
50
+ isolated?: boolean
51
+ parentOrigin?: SessionOrigin
52
+ },
53
+ ownerKey: WsOwnerKey,
54
+ ) => void
55
+ feedStdin: (callId: string, chunkBase64: string) => void
56
+ endStdin: (callId: string) => void
57
+ abort: (callId: string, reason: string) => void
58
+ abortForOwner: (ownerKey: WsOwnerKey) => void
59
+ inFlightCount: () => number
60
+ }
61
+
62
+ export function createCommandRunner(opts: CommandRunnerOptions): CommandRunner {
63
+ const inFlight = new Map<string, CommandHandle>()
64
+
65
+ function lookup(name: string): RegisteredCommand | undefined {
66
+ const snapshot = opts.pluginRuntime.get()
67
+ return snapshot.registry.commands.find((c) => c.commandName === name)
68
+ }
69
+
70
+ function start(
71
+ msg: { callId: string; name: string; args: unknown; isolated?: boolean; parentOrigin?: SessionOrigin },
72
+ ownerKey: WsOwnerKey,
73
+ ): void {
74
+ const { callId, name, args } = msg
75
+ if (inFlight.has(callId)) {
76
+ opts.outbound.error(callId, `callId "${callId}" is already in flight`)
77
+ return
78
+ }
79
+
80
+ const registered = lookup(name)
81
+ if (registered === undefined) {
82
+ opts.outbound.error(callId, `command "${name}" is not registered`)
83
+ opts.outbound.exit(callId, 1)
84
+ return
85
+ }
86
+
87
+ const command = registered.command
88
+ if (command.surface === 'host') {
89
+ opts.outbound.error(callId, `command "${name}" is host-only; cannot run inside the container`)
90
+ opts.outbound.exit(callId, 1)
91
+ return
92
+ }
93
+
94
+ const argsParse = parseArgs(command, args)
95
+ if (!argsParse.ok) {
96
+ opts.outbound.error(callId, argsParse.message)
97
+ opts.outbound.exit(callId, 2)
98
+ return
99
+ }
100
+
101
+ const abortController = new AbortController()
102
+ const stdinQueue = createStdinQueue(abortController.signal)
103
+
104
+ // Subagent-shaped (NOT TUI) so the prompt session this command may spawn
105
+ // via ctx.prompt resolves to the `slim` system prompt mode, saving ~2000
106
+ // tokens per LLM call.
107
+ //
108
+ // spawnedByOrigin carries the caller's provenance. By default we stamp a
109
+ // synthetic TUI origin (host CLI operator → owner role). When the caller
110
+ // forwarded a parentOrigin (e.g. a cron exec job reading
111
+ // TYPECLAW_PARENT_ORIGIN_JSON), we use that verbatim so permission
112
+ // resolution chases through to the cron's scheduledByRole instead of
113
+ // silently elevating to owner.
114
+ const syntheticTuiOrigin: SessionOrigin = { kind: 'tui', sessionId: `command:${name}:${callId}` }
115
+ const spawnedByOrigin: SessionOrigin = msg.parentOrigin ?? syntheticTuiOrigin
116
+ const origin: SessionOrigin = {
117
+ kind: 'subagent',
118
+ subagent: `plugin-command:${name}`,
119
+ parentSessionId: syntheticTuiOrigin.sessionId,
120
+ spawnedByOrigin,
121
+ }
122
+
123
+ const stdoutSink = makeWritable((chunk) => opts.outbound.stdout(callId, chunk))
124
+ const stderrSink = makeWritable((chunk) => opts.outbound.stderr(callId, chunk))
125
+
126
+ const logger: PluginLogger = {
127
+ info: (m) => writeLine(stderrSink, `[command:${registered.pluginName}] info: ${m}`),
128
+ warn: (m) => writeLine(stderrSink, `[command:${registered.pluginName}] warn: ${m}`),
129
+ error: (m) => writeLine(stderrSink, `[command:${registered.pluginName}] error: ${m}`),
130
+ }
131
+
132
+ // Emit the isolated-fallback warning through the per-command stderr
133
+ // stream so the invoking CLI sees it. The plugin's boot-time logger
134
+ // (registered.logger) writes to container logs which the caller never
135
+ // reads.
136
+ if (msg.isolated === true) {
137
+ logger.warn(
138
+ `command "${name}" requested isolated=true; this build does not yet implement subprocess isolation, falling back to in-process`,
139
+ )
140
+ }
141
+
142
+ const sharedCtx = {
143
+ name: registered.pluginName,
144
+ version: registered.command.surface === 'container' ? undefined : undefined,
145
+ agentDir: opts.agentDir,
146
+ logger,
147
+ signal: abortController.signal,
148
+ stdin: stdinQueue.readable,
149
+ stdout: stdoutSink,
150
+ stderr: stderrSink,
151
+ }
152
+
153
+ const ctxPromise = (async (): Promise<number> => {
154
+ if (command.surface === 'container') {
155
+ const ctx: ContainerCommandContext = {
156
+ ...sharedCtx,
157
+ permissions: opts.permissions,
158
+ origin,
159
+ prompt: (text) =>
160
+ runPromptForCommand({
161
+ text,
162
+ origin,
163
+ runtime: opts.pluginRuntime,
164
+ agentDir: opts.agentDir,
165
+ runtimeVersion: opts.runtimeVersion,
166
+ containerName: opts.containerName,
167
+ permissions: opts.permissions,
168
+ signal: abortController.signal,
169
+ }),
170
+ subagent: (subName, payload) =>
171
+ opts.spawnSubagent(subName, payload, {
172
+ spawnedByOrigin: origin,
173
+ parentSessionId: syntheticTuiOrigin.sessionId,
174
+ }),
175
+ exec: (strings, ...values) =>
176
+ runExecForCommand(strings, values, { cwd: opts.agentDir, signal: abortController.signal }),
177
+ }
178
+ return (command as ContainerCommand<unknown>).run(ctx, argsParse.value)
179
+ }
180
+ const ctx: EitherCommandContext = sharedCtx
181
+ return (command as EitherCommand<unknown>).run(ctx, argsParse.value)
182
+ })()
183
+
184
+ const done = ctxPromise
185
+ .then((code) => {
186
+ if (typeof code !== 'number' || !Number.isFinite(code)) {
187
+ opts.outbound.error(callId, `command "${name}" returned a non-numeric exit code`)
188
+ opts.outbound.exit(callId, 1)
189
+ return
190
+ }
191
+ opts.outbound.exit(callId, code)
192
+ })
193
+ .catch((err: unknown) => {
194
+ const detail = err instanceof Error ? err.message : String(err)
195
+ opts.outbound.error(callId, detail)
196
+ opts.outbound.exit(callId, 1)
197
+ })
198
+ .finally(() => {
199
+ inFlight.delete(callId)
200
+ })
201
+
202
+ inFlight.set(callId, { callId, abortController, stdinQueue, ownerKey, done })
203
+ }
204
+
205
+ function feedStdin(callId: string, chunkBase64: string): void {
206
+ const handle = inFlight.get(callId)
207
+ if (handle === undefined) return
208
+ try {
209
+ const bytes = Uint8Array.from(atob(chunkBase64), (c) => c.charCodeAt(0))
210
+ handle.stdinQueue.push(bytes)
211
+ } catch (err) {
212
+ const detail = err instanceof Error ? err.message : String(err)
213
+ opts.outbound.error(callId, `command_stdin decode failed: ${detail}`)
214
+ }
215
+ }
216
+
217
+ function endStdin(callId: string): void {
218
+ const handle = inFlight.get(callId)
219
+ if (handle === undefined) return
220
+ handle.stdinQueue.close()
221
+ }
222
+
223
+ function abort(callId: string, reason: string): void {
224
+ const handle = inFlight.get(callId)
225
+ if (handle === undefined) return
226
+ handle.abortController.abort(reason)
227
+ handle.stdinQueue.close()
228
+ }
229
+
230
+ function abortForOwner(ownerKey: WsOwnerKey): void {
231
+ for (const handle of inFlight.values()) {
232
+ if (handle.ownerKey === ownerKey) {
233
+ handle.abortController.abort('ws closed')
234
+ handle.stdinQueue.close()
235
+ }
236
+ }
237
+ }
238
+
239
+ function inFlightCount(): number {
240
+ return inFlight.size
241
+ }
242
+
243
+ return { start, feedStdin, endStdin, abort, abortForOwner, inFlightCount }
244
+ }
245
+
246
+ type ArgsParseResult = { ok: true; value: unknown } | { ok: false; message: string }
247
+
248
+ function parseArgs(command: { args?: { safeParse?: (input: unknown) => unknown } }, args: unknown): ArgsParseResult {
249
+ if (command.args === undefined) return { ok: true, value: undefined }
250
+ const safe = (
251
+ command.args as {
252
+ safeParse: (input: unknown) => {
253
+ success: boolean
254
+ data?: unknown
255
+ error?: { issues: { path: (string | number)[]; message: string }[] }
256
+ }
257
+ }
258
+ ).safeParse(args)
259
+ if (safe.success === true) return { ok: true, value: safe.data }
260
+ const issues = safe.error?.issues ?? []
261
+ const message =
262
+ issues.length === 0
263
+ ? 'args validation failed'
264
+ : issues.map((i) => `${i.path.length > 0 ? i.path.join('.') : '<root>'}: ${i.message}`).join('; ')
265
+ return { ok: false, message }
266
+ }
267
+
268
+ type StdinQueue = {
269
+ readable: ReadableStream<Uint8Array>
270
+ push: (chunk: Uint8Array) => void
271
+ close: () => void
272
+ }
273
+
274
+ function createStdinQueue(signal: AbortSignal): StdinQueue {
275
+ let controller: ReadableStreamDefaultController<Uint8Array> | null = null
276
+ let closed = false
277
+ const buffered: Uint8Array[] = []
278
+
279
+ const readable = new ReadableStream<Uint8Array>({
280
+ start(c) {
281
+ controller = c
282
+ for (const chunk of buffered) c.enqueue(chunk)
283
+ buffered.length = 0
284
+ if (closed) c.close()
285
+ signal.addEventListener('abort', () => {
286
+ if (!closed) {
287
+ closed = true
288
+ try {
289
+ c.close()
290
+ } catch {
291
+ // already closed
292
+ }
293
+ }
294
+ })
295
+ },
296
+ })
297
+
298
+ function push(chunk: Uint8Array): void {
299
+ if (closed) return
300
+ if (controller === null) {
301
+ buffered.push(chunk)
302
+ return
303
+ }
304
+ controller.enqueue(chunk)
305
+ }
306
+
307
+ function close(): void {
308
+ if (closed) return
309
+ closed = true
310
+ if (controller === null) return
311
+ try {
312
+ controller.close()
313
+ } catch {
314
+ // already closed
315
+ }
316
+ }
317
+
318
+ return { readable, push, close }
319
+ }
320
+
321
+ function makeWritable(onChunk: (chunk: Uint8Array) => void): WritableStream<Uint8Array> {
322
+ return new WritableStream<Uint8Array>({
323
+ write(chunk) {
324
+ onChunk(chunk)
325
+ },
326
+ })
327
+ }
328
+
329
+ function writeLine(stream: WritableStream<Uint8Array>, line: string): void {
330
+ const writer = stream.getWriter()
331
+ void writer.write(new TextEncoder().encode(`${line}\n`)).then(() => writer.releaseLock())
332
+ }
333
+
334
+ export async function runPromptForCommand(args: {
335
+ text: string
336
+ origin: SessionOrigin
337
+ runtime: PluginRuntime
338
+ agentDir: string
339
+ runtimeVersion: string | undefined
340
+ containerName: string | undefined
341
+ permissions: PermissionService
342
+ signal: AbortSignal
343
+ }): Promise<string> {
344
+ // Mirrors src/agent/multimodal/look-at.ts: spawn a session, prompt, capture
345
+ // the final assistant text, dispose. Unlike look-at we want the FULL agent
346
+ // toolset (no `tools: []` / `customTools: []` overrides) so the model can
347
+ // call channel_send, websearch, etc. The system prompt is composed from
348
+ // the agent folder's IDENTITY/SOUL/MEMORY files via the default resource
349
+ // loader (no `systemPromptOverride`).
350
+ const snapshot = args.runtime.get()
351
+ const sessionId = resolveSessionIdForOrigin(args.origin)
352
+ const { session, dispose } = await createSessionWithDispose({
353
+ origin: args.origin,
354
+ permissions: args.permissions,
355
+ plugins: {
356
+ registry: snapshot.registry,
357
+ hooks: snapshot.hooks,
358
+ sessionId,
359
+ agentDir: args.agentDir,
360
+ },
361
+ ...(args.runtimeVersion !== undefined ? { runtimeVersion: args.runtimeVersion } : {}),
362
+ ...(args.containerName !== undefined ? { containerName: args.containerName } : {}),
363
+ })
364
+ const detachAbort = bindSignalToSession(args.signal, session)
365
+ try {
366
+ await session.prompt(args.text)
367
+ return session.getLastAssistantText() ?? ''
368
+ } finally {
369
+ detachAbort()
370
+ session.dispose()
371
+ await dispose()
372
+ }
373
+ }
374
+
375
+ // Propagate abort into the live session: without this, abort flips the
376
+ // signal but session.prompt() keeps waiting on the provider until the LLM
377
+ // call completes naturally — which for a long generation means the whole
378
+ // command, its dispose() chain, and command_exit hang for minutes.
379
+ // Returns a detach function the caller must invoke (in finally) so the
380
+ // listener doesn't leak after a clean prompt completion.
381
+ export function bindSignalToSession(signal: AbortSignal, session: { abort: () => Promise<void> }): () => void {
382
+ const onAbort = (): void => {
383
+ void session.abort()
384
+ }
385
+ if (signal.aborted) {
386
+ onAbort()
387
+ return () => {}
388
+ }
389
+ signal.addEventListener('abort', onAbort, { once: true })
390
+ return () => signal.removeEventListener('abort', onAbort)
391
+ }
392
+
393
+ function resolveSessionIdForOrigin(origin: SessionOrigin): string {
394
+ if (origin.kind === 'tui') return origin.sessionId
395
+ if (origin.kind === 'subagent') return origin.parentSessionId
396
+ return crypto.randomUUID()
397
+ }
398
+
399
+ // Grace period before escalating SIGTERM to SIGKILL on aborted ctx.exec.
400
+ // Long enough for an interactive child to flush stdout and exit cleanly;
401
+ // short enough that a wedged shell wrapper doesn't keep command_exit
402
+ // hanging visibly past user expectations.
403
+ const EXEC_ABORT_GRACE_MS = 5_000
404
+
405
+ export async function runExecForCommand(
406
+ strings: TemplateStringsArray,
407
+ values: readonly unknown[],
408
+ opts: { cwd: string; signal: AbortSignal },
409
+ ): Promise<CommandExecResult> {
410
+ // Construct the shell command by interpolating template values verbatim.
411
+ // The command author is trusted (their plugin runs in-process anyway), so
412
+ // we do not add shell-quoting; if they need it, they format the string
413
+ // themselves.
414
+ let cmd = strings[0] ?? ''
415
+ for (let i = 0; i < values.length; i++) {
416
+ cmd += String(values[i])
417
+ cmd += strings[i + 1] ?? ''
418
+ }
419
+
420
+ // Spawn detached so the child is the leader of its own process group.
421
+ // We kill via -pid (the process group) on abort, which targets sh AND
422
+ // every grandchild it spawned. Without detached:true, Bun's signal hook
423
+ // sends SIGTERM only to sh; orphaned grandchildren (e.g. a long-running
424
+ // server started by sh -c "node server.js") would keep stdout pipes open
425
+ // for minutes, masking the abort. See src/agent/tools/ddg.ts for the
426
+ // same pattern applied to curl-impersonate wrappers.
427
+ const proc = Bun.spawn({
428
+ cmd: ['sh', '-c', cmd],
429
+ cwd: opts.cwd,
430
+ stdout: 'pipe',
431
+ stderr: 'pipe',
432
+ detached: true,
433
+ })
434
+
435
+ let escalationTimer: ReturnType<typeof setTimeout> | null = null
436
+ const onAbort = (): void => {
437
+ killProcessGroup(proc.pid, 'SIGTERM')
438
+ escalationTimer = setTimeout(() => {
439
+ killProcessGroup(proc.pid, 'SIGKILL')
440
+ }, EXEC_ABORT_GRACE_MS)
441
+ }
442
+ if (opts.signal.aborted) {
443
+ onAbort()
444
+ } else {
445
+ opts.signal.addEventListener('abort', onAbort, { once: true })
446
+ }
447
+
448
+ try {
449
+ const [exitCode, stdoutText, stderrText] = await Promise.all([
450
+ proc.exited,
451
+ new Response(proc.stdout as unknown as ReadableStream<Uint8Array>).text(),
452
+ new Response(proc.stderr as unknown as ReadableStream<Uint8Array>).text(),
453
+ ])
454
+ return { stdout: stdoutText, stderr: stderrText, exitCode }
455
+ } finally {
456
+ opts.signal.removeEventListener('abort', onAbort)
457
+ if (escalationTimer !== null) clearTimeout(escalationTimer)
458
+ }
459
+ }
460
+
461
+ // Kills the leader-pgid'd process and every member of its group. Falls
462
+ // back to the single-pid kill if the pgid kill fails (e.g. the process
463
+ // already exited and the negative pid is invalid). Errors during cleanup
464
+ // are swallowed; the only consumer is the abort path where the process is
465
+ // almost certainly gone by the time we observe the error.
466
+ function killProcessGroup(pid: number, sig: 'SIGTERM' | 'SIGKILL'): void {
467
+ try {
468
+ process.kill(-pid, sig)
469
+ } catch {
470
+ try {
471
+ process.kill(pid, sig)
472
+ } catch {
473
+ // Already exited; nothing to clean up.
474
+ }
475
+ }
476
+ }