opencode-overclock 0.3.0 → 0.5.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 (82) hide show
  1. package/README.md +252 -111
  2. package/package.json +6 -4
  3. package/skills/codebase-design/DEEPENING.md +35 -0
  4. package/skills/codebase-design/DESIGN-IT-TWICE.md +34 -0
  5. package/skills/codebase-design/SKILL.md +93 -0
  6. package/skills/diagnosing-bugs/SKILL.md +123 -0
  7. package/skills/domain-modeling/ADR-FORMAT.md +55 -0
  8. package/skills/domain-modeling/CONTEXT-FORMAT.md +32 -0
  9. package/skills/domain-modeling/SKILL.md +102 -0
  10. package/skills/doubt/SKILL.md +80 -0
  11. package/skills/grilling/SKILL.md +96 -0
  12. package/skills/source-discipline/SKILL.md +78 -0
  13. package/skills/tdd/SKILL.md +87 -0
  14. package/skills/to-spec/SKILL.md +69 -0
  15. package/skills/to-spec/SPEC-TEMPLATE.md +50 -0
  16. package/skills/to-tickets/SKILL.md +74 -0
  17. package/skills/to-tickets/TICKET-TEMPLATE.md +41 -0
  18. package/src/bridge.ts +1 -0
  19. package/src/buddy/companion.ts +104 -5
  20. package/src/buddy/sprites.ts +4 -4
  21. package/src/buddy/tui.ts +175 -65
  22. package/src/core/bridge.ts +34 -0
  23. package/src/core/lifecycle.ts +67 -0
  24. package/src/core/policy.ts +128 -0
  25. package/src/core/summary.ts +33 -0
  26. package/src/core/types.ts +193 -0
  27. package/src/features/buddy.ts +1 -2
  28. package/src/features/guard.ts +421 -37
  29. package/src/features/index.ts +18 -4
  30. package/src/features/recovery.ts +153 -0
  31. package/src/features/safety.ts +147 -0
  32. package/src/features/sched.ts +183 -89
  33. package/src/features/tasks.ts +134 -33
  34. package/src/features/truncator.ts +116 -0
  35. package/src/features/usage.ts +46 -65
  36. package/src/features/workflow.ts +256 -0
  37. package/src/index.ts +96 -67
  38. package/src/lib/busy.ts +1 -25
  39. package/src/lib/exec.ts +13 -0
  40. package/src/lib/inject.ts +10 -56
  41. package/src/lib/mirror.ts +13 -0
  42. package/src/lib/probe.ts +1 -15
  43. package/src/lib/state.ts +10 -39
  44. package/src/lib/tmux.ts +1 -0
  45. package/src/lib/ui.ts +208 -0
  46. package/src/merge.ts +2 -66
  47. package/src/platform/probe.ts +25 -0
  48. package/src/platform/process/exec.ts +317 -0
  49. package/src/platform/process/tmux.ts +60 -0
  50. package/src/platform/session/busy.ts +33 -0
  51. package/src/platform/session/inject.ts +89 -0
  52. package/src/platform/session/notify.ts +20 -0
  53. package/src/platform/storage/state.ts +99 -0
  54. package/src/platform/storage/store.ts +61 -0
  55. package/src/summary.ts +1 -0
  56. package/src/tools.ts +8 -244
  57. package/src/tui.ts +57 -186
  58. package/src/types.ts +1 -73
  59. package/src/v2/context.ts +470 -0
  60. package/src/v2/host.ts +120 -0
  61. package/src/v2/loader.ts +150 -0
  62. package/src/workflow/agents/codebase-researcher.ts +27 -0
  63. package/src/workflow/agents/design-explorer.ts +33 -0
  64. package/src/workflow/agents/doubt-reviewer.ts +26 -0
  65. package/src/workflow/agents/engineering-coach.ts +23 -0
  66. package/src/workflow/agents/performance-auditor.ts +29 -0
  67. package/src/workflow/agents/security-auditor.ts +23 -0
  68. package/src/workflow/agents/spec-reviewer.ts +15 -0
  69. package/src/workflow/agents/standards-reviewer.ts +24 -0
  70. package/src/workflow/agents/test-engineer.ts +28 -0
  71. package/src/workflow/catalog.ts +210 -0
  72. package/src/workflow/templates/build.ts +47 -0
  73. package/src/workflow/templates/define.ts +45 -0
  74. package/src/workflow/templates/diagnose.ts +58 -0
  75. package/src/workflow/templates/plan.ts +52 -0
  76. package/src/workflow/templates/ship.ts +64 -0
  77. package/src/buddy/reactions.ts +0 -41
  78. package/src/buddy/types.ts +0 -30
  79. package/src/config.ts +0 -19
  80. package/src/features/checkpoints.ts +0 -128
  81. package/src/features/sandbox.ts +0 -104
  82. package/src/validate.ts +0 -197
package/src/lib/ui.ts ADDED
@@ -0,0 +1,208 @@
1
+ import type { TuiPluginApi, TuiSlotContext } from "@opencode-ai/plugin/tui"
2
+ import { missingSurfaces } from "./probe.ts"
3
+ import type { Store } from "./state.ts"
4
+
5
+ /** Raw `@opentui/solid` node factory. Returns a JSX.Element the host can render. */
6
+ type JsxFactory = (type: string, props?: Record<string, unknown> | null) => unknown
7
+
8
+ type SlotRender = (ctx: TuiSlotContext) => unknown
9
+
10
+ export interface UiCommand {
11
+ /** command palette entry */
12
+ title: string
13
+ /** slash name, without the leading "/" */
14
+ slash?: string
15
+ /** optional slash aliases */
16
+ aliases?: string[]
17
+ /** unique command id; defaults to `overclock.<slash ?? title>` */
18
+ id?: string
19
+ run(dialog?: unknown): void | Promise<void>
20
+ }
21
+
22
+ export interface UiNotify {
23
+ message: string
24
+ title?: string
25
+ /** built-in sound name; only played when the terminal is blurred */
26
+ sound?: "default" | "question" | "permission" | "error" | "done" | "subagent_done"
27
+ }
28
+
29
+ /**
30
+ * Thin, failure-tolerant facade over `TuiPluginApi`.
31
+ *
32
+ * Two things it buys us. First, *uniform degradation*: every host call is a place the TUI API can
33
+ * drift out from under us, and a throw inside plugin setup takes down every later registration in
34
+ * the same function. Each method here is individually guarded and warns with a consistent label,
35
+ * so one broken surface costs exactly one feature. Second, *disposal by construction*: the host
36
+ * hands back an unsubscribe from `event.on`, an id from `slots.register` (where cleanup is host-managed), and
37
+ * nothing from `setInterval` -- contracts that call sites previously tracked
38
+ * by hand. Listeners and intervals registered through the facade are wired to `lifecycle.onDispose` here.
39
+ */
40
+ export interface Ui {
41
+ /** escape hatch for surfaces the facade does not wrap yet */
42
+ readonly api: TuiPluginApi
43
+ /** project directory; the root every `Store` path is resolved against */
44
+ readonly directory: string
45
+ /** subscribe to a host event, auto-disposed. Returns the unsubscribe for early removal. */
46
+ readonly on: TuiPluginApi["event"]["on"]
47
+ /** register a palette/slash command, auto-disposed */
48
+ command(cmd: UiCommand): void
49
+ /** desktop notification + sound, fired only when the terminal is blurred */
50
+ notify(input: UiNotify): void
51
+ toast(message: string, variant?: "info" | "success" | "warning" | "error"): void
52
+ /** setInterval, cleared on dispose */
53
+ every(ms: number, fn: () => void): void
54
+ /** read a store defined in `platform/storage/store.ts` (or re-exported via `mirror.ts`) */
55
+ read<T>(store: Store<T>): Promise<T>
56
+ /** as `read`, but `undefined` when the server half has never written the file */
57
+ readOptional<T>(store: Store<T>): Promise<T | undefined>
58
+ /** true when every dot-path resolves to a function on the host api */
59
+ has(...paths: string[]): boolean
60
+ /**
61
+ * Load the host's JSX runtime, enabling `node()`. Separate from `createUi` because
62
+ * `@opentui/solid` is an optional peer and slot rendering is synchronous: a feature that draws
63
+ * must await this up front, and one that does not should not pay for the import.
64
+ */
65
+ enableJsx(): Promise<void>
66
+ /** create a renderable node. Throws unless `enableJsx()` resolved first. */
67
+ node(type: string, props: Record<string, unknown>): unknown
68
+ /** mount slot renderers. The host owns slot cleanup, so there is nothing to dispose. */
69
+ slots(map: Record<string, SlotRender>): void
70
+ }
71
+
72
+ function warn(label: string, e: unknown): void {
73
+ console.warn(`[overclock-tui] ${label} failed: ${e}`)
74
+ }
75
+
76
+ /** Run `fn`, swallowing and reporting any throw. Returns undefined on failure. */
77
+ function guard<T>(label: string, fn: () => T): T | undefined {
78
+ try {
79
+ return fn()
80
+ } catch (e) {
81
+ warn(label, e)
82
+ return undefined
83
+ }
84
+ }
85
+
86
+ export function createUi(api: TuiPluginApi): Ui {
87
+ const directory = api.state.path.directory
88
+ let jsx: JsxFactory | undefined
89
+
90
+ /** Best-effort disposer registration -- `lifecycle` itself is a surface that can be missing. */
91
+ const onDispose = (fn: () => void): void => {
92
+ try {
93
+ api.lifecycle.onDispose(async () => fn())
94
+ } catch (e) {
95
+ warn("lifecycle registration", e)
96
+ }
97
+ }
98
+
99
+ const noop = () => {}
100
+
101
+ // Cast: the host's `on` is generic over the event union, and reproducing that generic on an
102
+ // ordinary function expression is not expressible without naming `Event`, which
103
+ // `@opencode-ai/plugin/tui` does not re-export. The cast keeps full narrowing for callers.
104
+ const on = ((type: string, handler: (event: never) => void) => {
105
+ const unsub = guard(`subscribe ${type}`, () =>
106
+ (api.event.on as (t: string, h: (event: never) => void) => () => void)(type, handler),
107
+ )
108
+ if (!unsub) return noop
109
+ onDispose(unsub)
110
+ return unsub
111
+ }) as TuiPluginApi["event"]["on"]
112
+
113
+ return {
114
+ api,
115
+ directory,
116
+ on,
117
+
118
+ command(cmd) {
119
+ const id = cmd.id ?? `overclock.${cmd.slash ?? cmd.title}`
120
+ // `api.command` is deprecated upstream in favour of `keymap.registerLayer({commands,
121
+ // bindings})`, and optional on the api type. Isolated here so that migration is a change
122
+ // to this one method rather than to every feature that registers a command.
123
+ const unregister = guard(`command ${id}`, () =>
124
+ api.command?.register(() => [
125
+ {
126
+ title: cmd.title,
127
+ value: id,
128
+ ...(cmd.slash
129
+ ? {
130
+ slash: {
131
+ name: cmd.slash,
132
+ ...(cmd.aliases ? { aliases: cmd.aliases } : {}),
133
+ },
134
+ }
135
+ : {}),
136
+ onSelect: async (dialog) => {
137
+ try {
138
+ await cmd.run(dialog)
139
+ } catch (e) {
140
+ warn(`command ${id}`, e)
141
+ }
142
+ },
143
+ },
144
+ ]),
145
+ )
146
+ if (unregister) onDispose(unregister)
147
+ },
148
+
149
+ notify(input) {
150
+ guard("notify", () =>
151
+ api.attention.notify({
152
+ title: input.title ?? "opencode",
153
+ message: input.message,
154
+ notification: { when: "blurred" },
155
+ ...(input.sound ? { sound: { name: input.sound, when: "blurred" as const } } : {}),
156
+ }),
157
+ )
158
+ },
159
+
160
+ toast(message, variant) {
161
+ guard("toast", () => api.ui.toast({ message, ...(variant ? { variant } : {}) }))
162
+ },
163
+
164
+ every(ms, fn) {
165
+ const timer = setInterval(() => {
166
+ try {
167
+ fn()
168
+ } catch (e) {
169
+ warn("interval", e)
170
+ }
171
+ }, ms)
172
+ onDispose(() => clearInterval(timer))
173
+ },
174
+
175
+ read(store) {
176
+ return store.read(directory)
177
+ },
178
+
179
+ readOptional(store) {
180
+ return store.readMaybe(directory)
181
+ },
182
+
183
+ has(...paths) {
184
+ return missingSurfaces(api, paths).length === 0
185
+ },
186
+
187
+ async enableJsx() {
188
+ // The host must map this specifier to *its own* solid instance. If it does not, the import
189
+ // throws here -- at the caller's await, where it can degrade to "no drawing" -- rather than
190
+ // from inside a slot render where nothing can catch it.
191
+ const mod = (await import("@opentui/solid/jsx-runtime")) as unknown as { jsx: JsxFactory }
192
+ jsx = mod.jsx
193
+ },
194
+
195
+ node(type, props) {
196
+ if (!jsx) throw new Error("ui.node() requires await ui.enableJsx()")
197
+ return jsx(type, props)
198
+ },
199
+
200
+ slots(map) {
201
+ guard("slot registration", () =>
202
+ api.slots.register({
203
+ slots: map as unknown as Parameters<TuiPluginApi["slots"]["register"]>[0]["slots"],
204
+ }),
205
+ )
206
+ },
207
+ }
208
+ }
package/src/merge.ts CHANGED
@@ -1,66 +1,2 @@
1
- import type { Hooks } from "@opencode-ai/plugin"
2
- import { EMPTY_POLICY, type ToolPolicy } from "./tools.ts"
3
-
4
- /**
5
- * Rewrite declared tool names appearing inside a description ("reversible via
6
- * checkpoint_restore", "Kill it with task_kill"). Left alone, a remap leaves the model
7
- * reading instructions that name a tool it was never offered. Word-anchored so a name
8
- * that is a substring of a longer identifier is not clobbered.
9
- */
10
- export function renameInText(text: string, rename: Record<string, string>): string {
11
- let out = text
12
- for (const [from, to] of Object.entries(rename)) {
13
- if (from === to) continue
14
- out = out.replace(new RegExp(`\\b${from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), to)
15
- }
16
- return out
17
- }
18
-
19
- /**
20
- * Compose many Partial<Hooks> into one Hooks.
21
- * - fn hooks: call sequentially, module order. Each sees prior mutations of `output`.
22
- * - `tool` map: shallow merge. Name collision -> later module wins, warn.
23
- * - `policy`: declared tool name -> model-visible name, plus tools to withhold. Applied here
24
- * because every module's tool map funnels through this one merge, so one pass covers the
25
- * whole surface and a module never has to know a policy exists.
26
- */
27
- export function mergeHooks(parts: Partial<Hooks>[], policy: ToolPolicy = EMPTY_POLICY): Hooks {
28
- const merged: Record<string, unknown> = {}
29
- const tools: Record<string, unknown> = {}
30
- const { rename, withheld } = policy
31
-
32
- for (const part of parts) {
33
- for (const [key, value] of Object.entries(part)) {
34
- if (value === undefined) continue
35
- if (key === "tool") {
36
- const renaming = Object.keys(rename).length > 0
37
- for (const [declared, def] of Object.entries(value as Record<string, unknown>)) {
38
- // Withheld = no allowlist slot. Dropping it here is the point: one unlisted name
39
- // makes the gateway reject the whole request, so the rest of the plugin still works.
40
- if (withheld.has(declared)) continue
41
- const name = rename[declared] ?? declared
42
- if (tools[name]) console.warn(`[overclock] tool collision: ${name} (later module wins)`)
43
- const d = def as { description?: unknown }
44
- // Clone rather than mutate: the module owns its tool objects and may hold the
45
- // same reference elsewhere.
46
- tools[name] =
47
- renaming && typeof d.description === "string"
48
- ? { ...d, description: renameInText(d.description, rename) }
49
- : def
50
- }
51
- continue
52
- }
53
- const prev = merged[key] as ((...a: unknown[]) => Promise<void>) | undefined
54
- const next = value as (...a: unknown[]) => Promise<void>
55
- merged[key] = prev
56
- ? async (...args: unknown[]) => {
57
- await prev(...args)
58
- await next(...args)
59
- }
60
- : next
61
- }
62
- }
63
-
64
- if (Object.keys(tools).length) merged.tool = tools
65
- return merged as Hooks
66
- }
1
+ export { mergeHooks } from "./core/lifecycle.ts"
2
+ export { applyToolPolicy, EMPTY_POLICY, renameInText, type ToolPolicy } from "./core/policy.ts"
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Runtime surface probe (doc: "probe for the surfaces we depend on ... instead of
3
+ * failing silently when upstream moves"). Dot-paths resolved against the SDK client;
4
+ * leaf must be a function by default.
5
+ */
6
+ export function missingSurfaces(
7
+ client: unknown,
8
+ paths: string[],
9
+ requiredType: "function" | "object" | "defined" = "function",
10
+ ): string[] {
11
+ return paths.filter((path) => {
12
+ let node: unknown = client
13
+ for (const key of path.split(".")) {
14
+ if (node == null || typeof node !== "object") return true
15
+ try {
16
+ node = (node as Record<string, unknown>)[key]
17
+ } catch {
18
+ return true
19
+ }
20
+ }
21
+ if (requiredType === "function") return typeof node !== "function"
22
+ if (requiredType === "object") return node === null || typeof node !== "object"
23
+ return node === undefined
24
+ })
25
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * POSIX single-quote escape for safe shell argument interpolation.
3
+ */
4
+ export function shellQuote(s: string): string {
5
+ return `'${s.replace(/'/g, `'\\''`)}'`
6
+ }
7
+
8
+ export const NON_INTERACTIVE_ENV: Record<string, string> = {
9
+ GIT_PAGER: "cat",
10
+ PAGER: "cat",
11
+ GIT_EDITOR: "true",
12
+ CI: "true",
13
+ DEBIAN_FRONTEND: "noninteractive",
14
+ TERM: "dumb",
15
+ }
16
+
17
+ /**
18
+ * Pattern matching environment variable names that typically store credentials,
19
+ * secrets, authentication tokens, API keys, or private keys.
20
+ */
21
+ export const SENSITIVE_ENV_PATTERN =
22
+ /(?:KEY|SECRET|TOKEN|AUTH|PASS(?:WORD|WD)?|CREDENTIAL|PRIVATE|SIGNING|DATABASE_URL|WEBHOOK|CERT|BEARER|COOKIE)/i
23
+
24
+ /**
25
+ * Known system and transport variables that match sensitive keywords but are
26
+ * necessary for everyday development operations (SSH agent socket, TLS certificates).
27
+ */
28
+ export const DEFAULT_PRESERVED_ENV: readonly string[] = [
29
+ "SSH_AUTH_SOCK",
30
+ "SSL_CERT_FILE",
31
+ "SSL_CERT_DIR",
32
+ "NODE_EXTRA_CA_CERTS",
33
+ "GIT_SSH_COMMAND",
34
+ ]
35
+
36
+ /**
37
+ * Filter an environment map to remove sensitive keys (API keys, secrets, tokens).
38
+ * Safe system and build variables (PATH, HOME, USER, SHELL, TEMP, LANG, etc.)
39
+ * as well as essential operational credentials (SSH_AUTH_SOCK, certificates) are preserved.
40
+ */
41
+ export function sanitizeEnv(
42
+ env: Record<string, string | undefined>,
43
+ allowlist?: string[],
44
+ ): Record<string, string | undefined> {
45
+ const allowed = new Set([
46
+ ...DEFAULT_PRESERVED_ENV.map((k) => k.toUpperCase()),
47
+ ...(allowlist?.map((k) => k.toUpperCase()) ?? []),
48
+ ])
49
+ const sanitized: Record<string, string | undefined> = {}
50
+
51
+ for (const [key, value] of Object.entries(env)) {
52
+ if (value === undefined) continue
53
+ if (allowed.has(key.toUpperCase())) {
54
+ sanitized[key] = value
55
+ continue
56
+ }
57
+ if (SENSITIVE_ENV_PATTERN.test(key)) {
58
+ continue
59
+ }
60
+ sanitized[key] = value
61
+ }
62
+
63
+ return sanitized
64
+ }
65
+
66
+ /**
67
+ * Common regex patterns for tokens, API keys, private keys, and credential formats.
68
+ */
69
+ export const SENSITIVE_OUTPUT_PATTERNS: { pattern: RegExp; replacement: string }[] = [
70
+ // Private keys
71
+ {
72
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
73
+ replacement: "[REDACTED_PRIVATE_KEY]",
74
+ },
75
+ // JWT tokens
76
+ {
77
+ pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\b/g,
78
+ replacement: "[REDACTED_JWT]",
79
+ },
80
+ // Bearer tokens
81
+ {
82
+ pattern: /\bBearer\s+[A-Za-z0-9_\-\.~+/]+=*/gi,
83
+ replacement: "Bearer [REDACTED_TOKEN]",
84
+ },
85
+ // OpenAI / Anthropic / AI vendor keys
86
+ {
87
+ pattern: /\b(?:sk|ant)-[a-zA-Z0-9_\-]{20,}\b/g,
88
+ replacement: "[REDACTED_API_KEY]",
89
+ },
90
+ // GitHub tokens (classic, fine-grained PATs, OAuth)
91
+ {
92
+ pattern: /\b(?:gh[pousr]_[A-Za-z0-9_]{36,255}|github_pat_[A-Za-z0-9_]{50,255})\b/g,
93
+ replacement: "[REDACTED_GITHUB_TOKEN]",
94
+ },
95
+ // AWS Access Key ID
96
+ {
97
+ pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
98
+ replacement: "[REDACTED_AWS_KEY]",
99
+ },
100
+ // Passwords in URLs (e.g. postgres://user:pass@host)
101
+ {
102
+ pattern: /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/:]+:)[^/@\s]+(@)/g,
103
+ replacement: "$1[REDACTED_PASSWORD]$2",
104
+ },
105
+ // Authorization headers
106
+ {
107
+ pattern: /(Authorization:\s*(?:Basic|Bearer|Token)\s+)[^\r\n]+/gi,
108
+ replacement: "$1[REDACTED_AUTH]",
109
+ },
110
+ // Key / secret / token assignments in key-value output (e.g. api_key="secret", token: secret)
111
+ {
112
+ pattern:
113
+ /((?:api[_-]?key|secret|token|password|passwd)\s*[:=]\s*["']?)[A-Za-z0-9_\-\.~+/]{8,}(["']?)/gi,
114
+ replacement: "$1[REDACTED]$2",
115
+ },
116
+ ]
117
+
118
+ /**
119
+ * Redacts sensitive tokens, API keys, credentials, and known environment secrets
120
+ * from output strings to prevent data leakage.
121
+ */
122
+ export function redactSensitiveOutput(text: string, additionalSecrets?: string[]): string {
123
+ if (!text) return text
124
+
125
+ let redacted = text
126
+
127
+ // 1. Redact known secrets from process.env (values associated with sensitive keys, length >= 6)
128
+ const envSecrets: string[] = []
129
+ for (const [key, value] of Object.entries(process.env)) {
130
+ if (value && value.length >= 6 && SENSITIVE_ENV_PATTERN.test(key)) {
131
+ envSecrets.push(value)
132
+ }
133
+ }
134
+
135
+ const allSecrets = [...envSecrets, ...(additionalSecrets ?? [])]
136
+ // Sort by length descending to match longest secrets first
137
+ allSecrets.sort((a, b) => b.length - a.length)
138
+
139
+ for (const secret of allSecrets) {
140
+ if (secret && secret.length >= 6 && redacted.includes(secret)) {
141
+ redacted = redacted.replaceAll(secret, "[REDACTED_SECRET]")
142
+ }
143
+ }
144
+
145
+ // 2. Redact pattern matches
146
+ for (const { pattern, replacement } of SENSITIVE_OUTPUT_PATTERNS) {
147
+ redacted = redacted.replace(pattern, replacement)
148
+ }
149
+
150
+ return redacted
151
+ }
152
+
153
+ /**
154
+ * Discover all descendant PIDs of a given process by inspecting the process tree.
155
+ */
156
+ export async function getDescendantPids(rootPid: number): Promise<number[]> {
157
+ const pids: number[] = []
158
+ try {
159
+ const proc = Bun.spawn(["ps", "-A", "-o", "pid,ppid"], {
160
+ stdout: "pipe",
161
+ stderr: "ignore",
162
+ })
163
+ const text = await new Response(proc.stdout).text()
164
+ await proc.exited
165
+
166
+ const parentMap = new Map<number, number[]>()
167
+ for (const line of text.trim().split("\n").slice(1)) {
168
+ const parts = line.trim().split(/\s+/)
169
+ if (parts.length >= 2) {
170
+ const pid = Number(parts[0])
171
+ const ppid = Number(parts[1])
172
+ if (!isNaN(pid) && !isNaN(ppid)) {
173
+ if (!parentMap.has(ppid)) parentMap.set(ppid, [])
174
+ parentMap.get(ppid)!.push(pid)
175
+ }
176
+ }
177
+ }
178
+
179
+ const queue = [rootPid]
180
+ while (queue.length > 0) {
181
+ const curr = queue.shift()!
182
+ const children = parentMap.get(curr) ?? []
183
+ for (const child of children) {
184
+ pids.push(child)
185
+ queue.push(child)
186
+ }
187
+ }
188
+ } catch {}
189
+ return pids
190
+ }
191
+
192
+ /**
193
+ * Kill a process and its child processes recursively on POSIX systems.
194
+ */
195
+ export async function killProcessTree(
196
+ target: Bun.Subprocess | number,
197
+ signal: "SIGTERM" | "SIGKILL" = "SIGTERM",
198
+ ): Promise<void> {
199
+ const pid = typeof target === "number" ? target : target.pid
200
+ if (!pid) return
201
+
202
+ // 1. Gather all descendants in the process tree before sending signals
203
+ const descendants = await getDescendantPids(pid)
204
+
205
+ // 2. Try killing process group in case target is a process group leader
206
+ try {
207
+ process.kill(-pid, signal)
208
+ } catch {}
209
+
210
+ // 3. Kill all descendants (leaves first by iterating in reverse)
211
+ for (let i = descendants.length - 1; i >= 0; i--) {
212
+ try {
213
+ process.kill(descendants[i]!, signal)
214
+ } catch {}
215
+ }
216
+
217
+ // 4. Fallback pkill -P for any newly spawned direct children
218
+ try {
219
+ const pkill = Bun.spawn(["pkill", `-${signal === "SIGKILL" ? "KILL" : "TERM"}`, "-P", String(pid)], {
220
+ stdout: "ignore",
221
+ stderr: "ignore",
222
+ })
223
+ await pkill.exited
224
+ } catch {}
225
+
226
+ // 5. Kill the root process
227
+ try {
228
+ if (typeof target === "number") {
229
+ process.kill(pid, signal)
230
+ } else {
231
+ target.kill(signal)
232
+ }
233
+ } catch {}
234
+ }
235
+
236
+ export interface ExecBashOptions {
237
+ cwd?: string
238
+ env?: Record<string, string | undefined>
239
+ timeoutMs?: number
240
+ onSpawn?: (proc: Bun.Subprocess) => void
241
+ /**
242
+ * If true (default), sensitive environment variables (API keys, secrets, tokens)
243
+ * are scrubbed from process.env before spawning child processes.
244
+ */
245
+ sanitizeEnv?: boolean
246
+ /**
247
+ * Specific variable names to keep even if they match sensitive patterns.
248
+ */
249
+ envAllowlist?: string[]
250
+ }
251
+
252
+ export interface ExecBashResult {
253
+ code: number | null
254
+ stdout: string
255
+ stderr: string
256
+ combined: string
257
+ }
258
+
259
+ /**
260
+ * Run a command via `bash -c`, capturing stdout, stderr, and exit code.
261
+ * Ensures non-interactive environment variables with correct precedence,
262
+ * environment sanitization against secret leakage, and process termination escalation.
263
+ */
264
+ export async function execBash(command: string, options: ExecBashOptions = {}): Promise<ExecBashResult> {
265
+ const baseEnv =
266
+ options.sanitizeEnv === false ? process.env : sanitizeEnv(process.env, options.envAllowlist)
267
+
268
+ const mergedEnv: Record<string, string | undefined> = {
269
+ ...baseEnv,
270
+ ...NON_INTERACTIVE_ENV,
271
+ ...options.env,
272
+ }
273
+
274
+ const proc = Bun.spawn(["bash", "-c", command], {
275
+ cwd: options.cwd,
276
+ env: mergedEnv,
277
+ stdout: "pipe",
278
+ stderr: "pipe",
279
+ })
280
+ options.onSpawn?.(proc)
281
+
282
+ let killEscalationTimer: ReturnType<typeof setTimeout> | undefined
283
+ const killTimer = options.timeoutMs
284
+ ? setTimeout(() => {
285
+ void killProcessTree(proc, "SIGTERM")
286
+ killEscalationTimer = setTimeout(() => {
287
+ void killProcessTree(proc, "SIGKILL")
288
+ }, 2000)
289
+ killEscalationTimer.unref?.()
290
+ }, options.timeoutMs)
291
+ : undefined
292
+
293
+ // Protect against pipe leaks when background child processes keep stdout/stderr open
294
+ const streamTimeoutMs = options.timeoutMs ? options.timeoutMs + 2500 : undefined
295
+ const readStreamWithTimeout = (stream: ReadableStream, timeoutMs?: number): Promise<string> => {
296
+ const readPromise = new Response(stream).text().catch(() => "")
297
+ if (!timeoutMs) return readPromise
298
+ let timer: ReturnType<typeof setTimeout>
299
+ return Promise.race([
300
+ readPromise,
301
+ new Promise<string>((resolve) => {
302
+ timer = setTimeout(() => resolve(""), timeoutMs)
303
+ }),
304
+ ]).finally(() => clearTimeout(timer))
305
+ }
306
+
307
+ const [stdout, stderr, code] = await Promise.all([
308
+ readStreamWithTimeout(proc.stdout, streamTimeoutMs),
309
+ readStreamWithTimeout(proc.stderr, streamTimeoutMs),
310
+ proc.exited,
311
+ ])
312
+
313
+ if (killTimer) clearTimeout(killTimer)
314
+ if (killEscalationTimer) clearTimeout(killEscalationTimer)
315
+
316
+ return { code, stdout, stderr, combined: stdout + stderr }
317
+ }
@@ -0,0 +1,60 @@
1
+ export interface TmuxPane {
2
+ paneId: string
3
+ close: () => Promise<void>
4
+ }
5
+
6
+ /** Check if current process is running inside a tmux session. */
7
+ export function isInsideTmux(): boolean {
8
+ return Boolean(process.env.TMUX)
9
+ }
10
+
11
+ /**
12
+ * Spawns a background tmux split pane tailing the task log file.
13
+ * Uses `-d` to avoid stealing terminal focus.
14
+ * Uses positional parameters to prevent shell injection via title formatting.
15
+ */
16
+ export async function spawnTaskPane(logPath: string, title?: string): Promise<TmuxPane | null> {
17
+ if (!isInsideTmux()) return null
18
+ try {
19
+ const args = title
20
+ ? [
21
+ "tmux",
22
+ "split-window",
23
+ "-d",
24
+ "-P",
25
+ "-F",
26
+ "#{pane_id}",
27
+ "bash",
28
+ "-c",
29
+ 'printf "=== [%s] ===\\n" "$1" && exec tail -f "$2"',
30
+ "_",
31
+ title,
32
+ logPath,
33
+ ]
34
+ : ["tmux", "split-window", "-d", "-P", "-F", "#{pane_id}", "tail", "-f", logPath]
35
+
36
+ const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" })
37
+ const [stdout, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
38
+ if (code !== 0) return null
39
+
40
+ const paneId = stdout.trim()
41
+ if (!paneId.startsWith("%")) return null
42
+
43
+ return {
44
+ paneId,
45
+ close: async () => {
46
+ try {
47
+ const killProc = Bun.spawn(["tmux", "kill-pane", "-t", paneId], {
48
+ stdout: "ignore",
49
+ stderr: "ignore",
50
+ })
51
+ await killProc.exited
52
+ } catch {
53
+ // Pane might have been manually closed by user
54
+ }
55
+ },
56
+ }
57
+ } catch {
58
+ return null
59
+ }
60
+ }