@kata-sh/cli 0.1.0 → 0.1.2

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 (199) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -0
  3. package/dist/app-paths.d.ts +4 -0
  4. package/dist/app-paths.js +6 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +56 -0
  7. package/dist/loader.d.ts +2 -0
  8. package/dist/loader.js +95 -0
  9. package/dist/resource-loader.d.ts +18 -0
  10. package/dist/resource-loader.js +50 -0
  11. package/dist/wizard.d.ts +15 -0
  12. package/dist/wizard.js +159 -0
  13. package/package.json +50 -21
  14. package/pkg/dist/modes/interactive/theme/dark.json +85 -0
  15. package/pkg/dist/modes/interactive/theme/light.json +84 -0
  16. package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
  17. package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
  18. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  19. package/pkg/dist/modes/interactive/theme/theme.js +949 -0
  20. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
  21. package/pkg/package.json +8 -0
  22. package/scripts/postinstall.js +45 -0
  23. package/src/resources/AGENTS.md +108 -0
  24. package/src/resources/KATA-WORKFLOW.md +661 -0
  25. package/src/resources/agents/researcher.md +29 -0
  26. package/src/resources/agents/scout.md +56 -0
  27. package/src/resources/agents/worker.md +31 -0
  28. package/src/resources/extensions/ask-user-questions.ts +200 -0
  29. package/src/resources/extensions/bg-shell/index.ts +2758 -0
  30. package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
  31. package/src/resources/extensions/browser-tools/core.js +1057 -0
  32. package/src/resources/extensions/browser-tools/index.ts +4916 -0
  33. package/src/resources/extensions/browser-tools/package.json +20 -0
  34. package/src/resources/extensions/context7/index.ts +428 -0
  35. package/src/resources/extensions/context7/package.json +11 -0
  36. package/src/resources/extensions/get-secrets-from-user.ts +352 -0
  37. package/src/resources/extensions/github/formatters.ts +207 -0
  38. package/src/resources/extensions/github/gh-api.ts +537 -0
  39. package/src/resources/extensions/github/index.ts +778 -0
  40. package/src/resources/extensions/kata/activity-log.ts +88 -0
  41. package/src/resources/extensions/kata/auto.ts +2786 -0
  42. package/src/resources/extensions/kata/commands.ts +355 -0
  43. package/src/resources/extensions/kata/crash-recovery.ts +85 -0
  44. package/src/resources/extensions/kata/dashboard-overlay.ts +516 -0
  45. package/src/resources/extensions/kata/docs/preferences-reference.md +103 -0
  46. package/src/resources/extensions/kata/doctor.ts +683 -0
  47. package/src/resources/extensions/kata/files.ts +730 -0
  48. package/src/resources/extensions/kata/gitignore.ts +165 -0
  49. package/src/resources/extensions/kata/guided-flow.ts +976 -0
  50. package/src/resources/extensions/kata/index.ts +556 -0
  51. package/src/resources/extensions/kata/metrics.ts +397 -0
  52. package/src/resources/extensions/kata/observability-validator.ts +408 -0
  53. package/src/resources/extensions/kata/package.json +11 -0
  54. package/src/resources/extensions/kata/paths.ts +346 -0
  55. package/src/resources/extensions/kata/preferences.ts +695 -0
  56. package/src/resources/extensions/kata/prompt-loader.ts +50 -0
  57. package/src/resources/extensions/kata/prompts/complete-milestone.md +25 -0
  58. package/src/resources/extensions/kata/prompts/complete-slice.md +27 -0
  59. package/src/resources/extensions/kata/prompts/discuss.md +151 -0
  60. package/src/resources/extensions/kata/prompts/doctor-heal.md +29 -0
  61. package/src/resources/extensions/kata/prompts/execute-task.md +64 -0
  62. package/src/resources/extensions/kata/prompts/guided-complete-slice.md +1 -0
  63. package/src/resources/extensions/kata/prompts/guided-discuss-milestone.md +3 -0
  64. package/src/resources/extensions/kata/prompts/guided-discuss-slice.md +59 -0
  65. package/src/resources/extensions/kata/prompts/guided-execute-task.md +1 -0
  66. package/src/resources/extensions/kata/prompts/guided-plan-milestone.md +23 -0
  67. package/src/resources/extensions/kata/prompts/guided-plan-slice.md +1 -0
  68. package/src/resources/extensions/kata/prompts/guided-research-slice.md +11 -0
  69. package/src/resources/extensions/kata/prompts/guided-resume-task.md +1 -0
  70. package/src/resources/extensions/kata/prompts/plan-milestone.md +47 -0
  71. package/src/resources/extensions/kata/prompts/plan-slice.md +63 -0
  72. package/src/resources/extensions/kata/prompts/queue.md +85 -0
  73. package/src/resources/extensions/kata/prompts/reassess-roadmap.md +48 -0
  74. package/src/resources/extensions/kata/prompts/replan-slice.md +39 -0
  75. package/src/resources/extensions/kata/prompts/research-milestone.md +37 -0
  76. package/src/resources/extensions/kata/prompts/research-slice.md +28 -0
  77. package/src/resources/extensions/kata/prompts/run-uat.md +109 -0
  78. package/src/resources/extensions/kata/prompts/system.md +341 -0
  79. package/src/resources/extensions/kata/session-forensics.ts +550 -0
  80. package/src/resources/extensions/kata/skill-discovery.ts +137 -0
  81. package/src/resources/extensions/kata/state.ts +509 -0
  82. package/src/resources/extensions/kata/templates/context.md +76 -0
  83. package/src/resources/extensions/kata/templates/decisions.md +8 -0
  84. package/src/resources/extensions/kata/templates/milestone-summary.md +73 -0
  85. package/src/resources/extensions/kata/templates/plan.md +133 -0
  86. package/src/resources/extensions/kata/templates/preferences.md +15 -0
  87. package/src/resources/extensions/kata/templates/project.md +31 -0
  88. package/src/resources/extensions/kata/templates/reassessment.md +28 -0
  89. package/src/resources/extensions/kata/templates/requirements.md +81 -0
  90. package/src/resources/extensions/kata/templates/research.md +46 -0
  91. package/src/resources/extensions/kata/templates/roadmap.md +118 -0
  92. package/src/resources/extensions/kata/templates/slice-context.md +58 -0
  93. package/src/resources/extensions/kata/templates/slice-summary.md +99 -0
  94. package/src/resources/extensions/kata/templates/state.md +19 -0
  95. package/src/resources/extensions/kata/templates/task-plan.md +52 -0
  96. package/src/resources/extensions/kata/templates/task-summary.md +57 -0
  97. package/src/resources/extensions/kata/templates/uat.md +54 -0
  98. package/src/resources/extensions/kata/tests/activity-log-prune.test.ts +327 -0
  99. package/src/resources/extensions/kata/tests/auto-preflight.test.ts +97 -0
  100. package/src/resources/extensions/kata/tests/auto-supervisor.test.mjs +53 -0
  101. package/src/resources/extensions/kata/tests/complete-milestone.test.ts +317 -0
  102. package/src/resources/extensions/kata/tests/cost-projection.test.ts +160 -0
  103. package/src/resources/extensions/kata/tests/derive-state-deps.test.ts +477 -0
  104. package/src/resources/extensions/kata/tests/derive-state.test.ts +1013 -0
  105. package/src/resources/extensions/kata/tests/doctor.test.ts +718 -0
  106. package/src/resources/extensions/kata/tests/idle-recovery.test.ts +490 -0
  107. package/src/resources/extensions/kata/tests/metrics-io.test.ts +254 -0
  108. package/src/resources/extensions/kata/tests/metrics.test.ts +217 -0
  109. package/src/resources/extensions/kata/tests/must-have-parser.test.ts +309 -0
  110. package/src/resources/extensions/kata/tests/parsers.test.ts +1257 -0
  111. package/src/resources/extensions/kata/tests/plan-milestone.test.ts +185 -0
  112. package/src/resources/extensions/kata/tests/plan-quality-validator.test.ts +386 -0
  113. package/src/resources/extensions/kata/tests/reassess-prompt.test.ts +208 -0
  114. package/src/resources/extensions/kata/tests/replan-slice.test.ts +686 -0
  115. package/src/resources/extensions/kata/tests/requirements.test.ts +151 -0
  116. package/src/resources/extensions/kata/tests/resolve-ts-hooks.mjs +17 -0
  117. package/src/resources/extensions/kata/tests/resolve-ts.mjs +11 -0
  118. package/src/resources/extensions/kata/tests/run-uat.test.ts +383 -0
  119. package/src/resources/extensions/kata/tests/unit-runtime.test.ts +388 -0
  120. package/src/resources/extensions/kata/tests/workspace-index.test.ts +118 -0
  121. package/src/resources/extensions/kata/tests/worktree.test.ts +222 -0
  122. package/src/resources/extensions/kata/types.ts +159 -0
  123. package/src/resources/extensions/kata/unit-runtime.ts +163 -0
  124. package/src/resources/extensions/kata/workspace-index.ts +203 -0
  125. package/src/resources/extensions/kata/worktree.ts +182 -0
  126. package/src/resources/extensions/mac-tools/index.ts +852 -0
  127. package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
  128. package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
  129. package/src/resources/extensions/search-the-web/cache.ts +78 -0
  130. package/src/resources/extensions/search-the-web/format.ts +258 -0
  131. package/src/resources/extensions/search-the-web/http.ts +238 -0
  132. package/src/resources/extensions/search-the-web/index.ts +68 -0
  133. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
  134. package/src/resources/extensions/search-the-web/tool-llm-context.ts +404 -0
  135. package/src/resources/extensions/search-the-web/tool-search.ts +503 -0
  136. package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
  137. package/src/resources/extensions/shared/confirm-ui.ts +126 -0
  138. package/src/resources/extensions/shared/interview-ui.ts +822 -0
  139. package/src/resources/extensions/shared/next-action-ui.ts +235 -0
  140. package/src/resources/extensions/shared/progress-widget.ts +282 -0
  141. package/src/resources/extensions/shared/thinking-widget.ts +107 -0
  142. package/src/resources/extensions/shared/ui.ts +400 -0
  143. package/src/resources/extensions/shared/wizard-ui.ts +551 -0
  144. package/src/resources/extensions/slash-commands/audit.ts +92 -0
  145. package/src/resources/extensions/slash-commands/create-extension.ts +375 -0
  146. package/src/resources/extensions/slash-commands/create-slash-command.ts +280 -0
  147. package/src/resources/extensions/slash-commands/index.ts +12 -0
  148. package/src/resources/extensions/slash-commands/kata-run.ts +34 -0
  149. package/src/resources/extensions/subagent/agents.ts +126 -0
  150. package/src/resources/extensions/subagent/index.ts +1293 -0
  151. package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
  152. package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
  153. package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
  154. package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
  155. package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
  156. package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
  157. package/src/resources/skills/frontend-design/SKILL.md +45 -0
  158. package/src/resources/skills/swiftui/SKILL.md +208 -0
  159. package/src/resources/skills/swiftui/references/animations.md +921 -0
  160. package/src/resources/skills/swiftui/references/architecture.md +1561 -0
  161. package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
  162. package/src/resources/skills/swiftui/references/navigation.md +1492 -0
  163. package/src/resources/skills/swiftui/references/networking-async.md +214 -0
  164. package/src/resources/skills/swiftui/references/performance.md +1706 -0
  165. package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
  166. package/src/resources/skills/swiftui/references/state-management.md +1443 -0
  167. package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
  168. package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
  169. package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
  170. package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
  171. package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
  172. package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
  173. package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
  174. package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
  175. package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
  176. package/dist/commands/task.d.ts +0 -9
  177. package/dist/commands/task.d.ts.map +0 -1
  178. package/dist/commands/task.js +0 -129
  179. package/dist/commands/task.js.map +0 -1
  180. package/dist/commands/task.test.d.ts +0 -2
  181. package/dist/commands/task.test.d.ts.map +0 -1
  182. package/dist/commands/task.test.js +0 -169
  183. package/dist/commands/task.test.js.map +0 -1
  184. package/dist/e2e/task-e2e.test.d.ts +0 -2
  185. package/dist/e2e/task-e2e.test.d.ts.map +0 -1
  186. package/dist/e2e/task-e2e.test.js +0 -173
  187. package/dist/e2e/task-e2e.test.js.map +0 -1
  188. package/dist/index.d.ts +0 -3
  189. package/dist/index.d.ts.map +0 -1
  190. package/dist/index.js +0 -93
  191. package/dist/index.js.map +0 -1
  192. package/dist/slug.d.ts +0 -2
  193. package/dist/slug.d.ts.map +0 -1
  194. package/dist/slug.js +0 -12
  195. package/dist/slug.js.map +0 -1
  196. package/dist/slug.test.d.ts +0 -2
  197. package/dist/slug.test.d.ts.map +0 -1
  198. package/dist/slug.test.js +0 -32
  199. package/dist/slug.test.js.map +0 -1
@@ -0,0 +1,2758 @@
1
+ /**
2
+ * Background Shell Extension v2
3
+ *
4
+ * A next-generation background process manager designed for agentic workflows.
5
+ * Provides intelligent process lifecycle management, structured output digests,
6
+ * event-driven readiness detection, and context-efficient communication.
7
+ *
8
+ * Key capabilities:
9
+ * - Multi-tier output: digest (30 tokens) → highlights → raw (full context)
10
+ * - Readiness detection: port probing, pattern matching, auto-classification
11
+ * - Process lifecycle events: starting → ready → error → exited
12
+ * - Output diffing & dedup: detect novel errors vs. repeated noise
13
+ * - Process groups: manage related processes as a unit
14
+ * - Cross-session persistence: survive context resets
15
+ * - Expect-style interactions: send_and_wait for interactive CLIs
16
+ * - Context injection: proactive alerts for crashes and state changes
17
+ *
18
+ * Tools:
19
+ * bg_shell — start, output, digest, wait_for_ready, send, send_and_wait,
20
+ * signal, list, kill, restart, group_status
21
+ *
22
+ * Commands:
23
+ * /bg — interactive process manager overlay
24
+ */
25
+
26
+ import { StringEnum } from "@mariozechner/pi-ai";
27
+ import type {
28
+ ExtensionAPI,
29
+ ExtensionContext,
30
+ Theme,
31
+ } from "@mariozechner/pi-coding-agent";
32
+ import {
33
+ truncateHead,
34
+ DEFAULT_MAX_BYTES,
35
+ DEFAULT_MAX_LINES,
36
+ } from "@mariozechner/pi-coding-agent";
37
+ import {
38
+ Text,
39
+ truncateToWidth,
40
+ visibleWidth,
41
+ matchesKey,
42
+ Key,
43
+ } from "@mariozechner/pi-tui";
44
+ import { Type } from "@sinclair/typebox";
45
+ import { spawn, type ChildProcess } from "node:child_process";
46
+ import { createConnection } from "node:net";
47
+ import { randomUUID } from "node:crypto";
48
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
49
+ import { join } from "node:path";
50
+
51
+ // ── Types ──────────────────────────────────────────────────────────────────
52
+
53
+ type ProcessStatus =
54
+ | "starting"
55
+ | "ready"
56
+ | "error"
57
+ | "exited"
58
+ | "crashed";
59
+
60
+ type ProcessType = "server" | "build" | "test" | "watcher" | "generic";
61
+
62
+ interface ProcessEvent {
63
+ type:
64
+ | "started"
65
+ | "ready"
66
+ | "error_detected"
67
+ | "recovered"
68
+ | "exited"
69
+ | "crashed"
70
+ | "output"
71
+ | "port_open"
72
+ | "pattern_match";
73
+ timestamp: number;
74
+ detail: string;
75
+ data?: Record<string, unknown>;
76
+ }
77
+
78
+ interface OutputDigest {
79
+ status: ProcessStatus;
80
+ uptime: string;
81
+ errors: string[];
82
+ warnings: string[];
83
+ urls: string[];
84
+ ports: number[];
85
+ lastActivity: string;
86
+ outputLines: number;
87
+ changeSummary: string;
88
+ }
89
+
90
+ interface OutputLine {
91
+ stream: "stdout" | "stderr";
92
+ line: string;
93
+ ts: number;
94
+ }
95
+
96
+ interface BgProcess {
97
+ id: string;
98
+ label: string;
99
+ command: string;
100
+ cwd: string;
101
+ startedAt: number;
102
+ proc: ChildProcess;
103
+ /** Unified chronologically-interleaved output buffer */
104
+ output: OutputLine[];
105
+ exitCode: number | null;
106
+ signal: string | null;
107
+ alive: boolean;
108
+ /** Tracks how many lines in the unified output buffer the LLM has already seen */
109
+ lastReadIndex: number;
110
+ /** Process classification */
111
+ processType: ProcessType;
112
+ /** Current lifecycle status */
113
+ status: ProcessStatus;
114
+ /** Detected ports */
115
+ ports: number[];
116
+ /** Detected URLs */
117
+ urls: string[];
118
+ /** Accumulated errors since last read */
119
+ recentErrors: string[];
120
+ /** Accumulated warnings since last read */
121
+ recentWarnings: string[];
122
+ /** Lifecycle events log */
123
+ events: ProcessEvent[];
124
+ /** Ready pattern (regex string) */
125
+ readyPattern: string | null;
126
+ /** Ready port to probe */
127
+ readyPort: number | null;
128
+ /** Whether readiness was ever achieved */
129
+ wasReady: boolean;
130
+ /** Group membership */
131
+ group: string | null;
132
+ /** Last error count snapshot for diff detection */
133
+ lastErrorCount: number;
134
+ /** Last warning count snapshot for diff detection */
135
+ lastWarningCount: number;
136
+ /** Dedup tracker: hash → count of repeated lines */
137
+ lineDedup: Map<string, number>;
138
+ /** Total raw lines (before dedup) for token savings calc */
139
+ totalRawLines: number;
140
+ /** Env snapshot (keys only, no values for security) */
141
+ envKeys: string[];
142
+ /** Restart count */
143
+ restartCount: number;
144
+ /** Original start config for restart */
145
+ startConfig: { command: string; cwd: string; label: string; processType: ProcessType; readyPattern: string | null; readyPort: number | null; group: string | null };
146
+ }
147
+
148
+ interface BgProcessInfo {
149
+ id: string;
150
+ label: string;
151
+ command: string;
152
+ cwd: string;
153
+ startedAt: number;
154
+ alive: boolean;
155
+ exitCode: number | null;
156
+ signal: string | null;
157
+ outputLines: number;
158
+ stdoutLines: number;
159
+ stderrLines: number;
160
+ status: ProcessStatus;
161
+ processType: ProcessType;
162
+ ports: number[];
163
+ urls: string[];
164
+ group: string | null;
165
+ restartCount: number;
166
+ uptime: string;
167
+ recentErrorCount: number;
168
+ recentWarningCount: number;
169
+ eventCount: number;
170
+ }
171
+
172
+ // ── Constants ──────────────────────────────────────────────────────────────
173
+
174
+ const MAX_BUFFER_LINES = 5000;
175
+ const MAX_EVENTS = 200;
176
+ const DEAD_PROCESS_TTL = 10 * 60 * 1000;
177
+ const PORT_PROBE_TIMEOUT = 500;
178
+ const READY_POLL_INTERVAL = 250;
179
+ const DEFAULT_READY_TIMEOUT = 30000;
180
+
181
+ // ── Pattern Databases ──────────────────────────────────────────────────────
182
+
183
+ /** Patterns that indicate a process is ready/listening */
184
+ const READINESS_PATTERNS: RegExp[] = [
185
+ // Node/JS servers
186
+ /listening\s+on\s+(?:port\s+)?(\d+)/i,
187
+ /server\s+(?:is\s+)?(?:running|started|listening)\s+(?:at|on)\s+/i,
188
+ /ready\s+(?:in|on|at)\s+/i,
189
+ /started\s+(?:server\s+)?on\s+/i,
190
+ // Next.js / Vite / etc
191
+ /Local:\s*https?:\/\//i,
192
+ /➜\s+Local:\s*/i,
193
+ /compiled\s+(?:successfully|client\s+and\s+server)/i,
194
+ // Python
195
+ /running\s+on\s+https?:\/\//i,
196
+ /Uvicorn\s+running/i,
197
+ /Development\s+server\s+is\s+running/i,
198
+ // Generic
199
+ /press\s+ctrl[\-+]c\s+to\s+(?:quit|stop)/i,
200
+ /watching\s+for\s+(?:file\s+)?changes/i,
201
+ /build\s+(?:completed|succeeded|finished)/i,
202
+ ];
203
+
204
+ /** Patterns that indicate errors */
205
+ const ERROR_PATTERNS: RegExp[] = [
206
+ /\berror\b[\s:[\](]/i,
207
+ /\bERROR\b/,
208
+ /\bfailed\b/i,
209
+ /\bFAILED\b/,
210
+ /\bfatal\b/i,
211
+ /\bFATAL\b/,
212
+ /\bexception\b/i,
213
+ /\bpanic\b/i,
214
+ /\bsegmentation\s+fault\b/i,
215
+ /\bsyntax\s*error\b/i,
216
+ /\btype\s*error\b/i,
217
+ /\breference\s*error\b/i,
218
+ /Cannot\s+find\s+module/i,
219
+ /Module\s+not\s+found/i,
220
+ /ENOENT/,
221
+ /EACCES/,
222
+ /EADDRINUSE/,
223
+ /TS\d{4,5}:/, // TypeScript errors
224
+ /E\d{4,5}:/, // Rust errors
225
+ /\[ERROR\]/,
226
+ /✖|✗|❌/, // Common error symbols
227
+ ];
228
+
229
+ /** Patterns that indicate warnings */
230
+ const WARNING_PATTERNS: RegExp[] = [
231
+ /\bwarning\b[\s:[\](]/i,
232
+ /\bWARN(?:ING)?\b/,
233
+ /\bdeprecated\b/i,
234
+ /\bDEPRECATED\b/,
235
+ /⚠️?/,
236
+ /\[WARN\]/,
237
+ ];
238
+
239
+ /** Patterns to extract URLs */
240
+ const URL_PATTERN = /https?:\/\/[^\s"'<>)\]]+/gi;
241
+
242
+ /** Patterns to extract port numbers from "listening" messages */
243
+ const PORT_PATTERN = /(?:port|listening\s+on|:)\s*(\d{2,5})\b/gi;
244
+
245
+ /** Patterns indicating test results */
246
+ const TEST_RESULT_PATTERNS: RegExp[] = [
247
+ /(\d+)\s+(?:tests?\s+)?passed/i,
248
+ /(\d+)\s+(?:tests?\s+)?failed/i,
249
+ /Tests?:\s+(\d+)\s+passed/i,
250
+ /(\d+)\s+passing/i,
251
+ /(\d+)\s+failing/i,
252
+ /PASS|FAIL/,
253
+ ];
254
+
255
+ /** Patterns indicating build completion */
256
+ const BUILD_COMPLETE_PATTERNS: RegExp[] = [
257
+ /build\s+(?:completed|succeeded|finished|done)/i,
258
+ /compiled\s+(?:successfully|with\s+\d+\s+(?:error|warning))/i,
259
+ /✓\s+Built/i,
260
+ /webpack\s+\d+\.\d+/i,
261
+ /bundle\s+(?:is\s+)?ready/i,
262
+ ];
263
+
264
+ // ── Process Registry ───────────────────────────────────────────────────────
265
+
266
+ const processes = new Map<string, BgProcess>();
267
+
268
+ /** Pending alerts to inject into the next agent context */
269
+ let pendingAlerts: string[] = [];
270
+
271
+ function addOutputLine(bg: BgProcess, stream: "stdout" | "stderr", line: string): void {
272
+ bg.output.push({ stream, line, ts: Date.now() });
273
+ if (bg.output.length > MAX_BUFFER_LINES) {
274
+ const excess = bg.output.length - MAX_BUFFER_LINES;
275
+ bg.output.splice(0, excess);
276
+ // Adjust the read cursor so incremental delivery stays correct
277
+ bg.lastReadIndex = Math.max(0, bg.lastReadIndex - excess);
278
+ }
279
+ }
280
+
281
+ function addEvent(bg: BgProcess, event: Omit<ProcessEvent, "timestamp">): void {
282
+ const ev: ProcessEvent = { ...event, timestamp: Date.now() };
283
+ bg.events.push(ev);
284
+ if (bg.events.length > MAX_EVENTS) {
285
+ bg.events.splice(0, bg.events.length - MAX_EVENTS);
286
+ }
287
+ }
288
+
289
+ function getInfo(p: BgProcess): BgProcessInfo {
290
+ const stdoutLines = p.output.filter(l => l.stream === "stdout").length;
291
+ const stderrLines = p.output.filter(l => l.stream === "stderr").length;
292
+ return {
293
+ id: p.id,
294
+ label: p.label,
295
+ command: p.command,
296
+ cwd: p.cwd,
297
+ startedAt: p.startedAt,
298
+ alive: p.alive,
299
+ exitCode: p.exitCode,
300
+ signal: p.signal,
301
+ outputLines: p.output.length,
302
+ stdoutLines,
303
+ stderrLines,
304
+ status: p.status,
305
+ processType: p.processType,
306
+ ports: p.ports,
307
+ urls: p.urls,
308
+ group: p.group,
309
+ restartCount: p.restartCount,
310
+ uptime: formatUptime(Date.now() - p.startedAt),
311
+ recentErrorCount: p.recentErrors.length,
312
+ recentWarningCount: p.recentWarnings.length,
313
+ eventCount: p.events.length,
314
+ };
315
+ }
316
+
317
+ // ── Process Type Detection ─────────────────────────────────────────────────
318
+
319
+ function detectProcessType(command: string): ProcessType {
320
+ const cmd = command.toLowerCase();
321
+
322
+ // Server patterns
323
+ if (
324
+ /\b(serve|server|dev|start)\b/.test(cmd) &&
325
+ /\b(npm|yarn|pnpm|bun|node|next|vite|nuxt|astro|remix|gatsby|uvicorn|flask|django|rails|cargo)\b/.test(cmd)
326
+ ) return "server";
327
+ if (/\b(uvicorn|gunicorn|flask\s+run|manage\.py\s+runserver|rails\s+s)\b/.test(cmd)) return "server";
328
+ if (/\b(http-server|live-server|serve)\b/.test(cmd)) return "server";
329
+
330
+ // Build patterns
331
+ if (/\b(build|compile|make|tsc|webpack|rollup|esbuild|swc)\b/.test(cmd)) {
332
+ if (/\b(watch|--watch|-w)\b/.test(cmd)) return "watcher";
333
+ return "build";
334
+ }
335
+
336
+ // Test patterns
337
+ if (/\b(test|jest|vitest|mocha|pytest|cargo\s+test|go\s+test|rspec)\b/.test(cmd)) return "test";
338
+
339
+ // Watcher patterns
340
+ if (/\b(watch|nodemon|chokidar|fswatch|inotifywait)\b/.test(cmd)) return "watcher";
341
+
342
+ return "generic";
343
+ }
344
+
345
+ // ── Output Analysis ────────────────────────────────────────────────────────
346
+
347
+ function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "stderr"): void {
348
+ // Error detection
349
+ if (ERROR_PATTERNS.some(p => p.test(line))) {
350
+ bg.recentErrors.push(line.trim().slice(0, 200)); // Cap line length
351
+ if (bg.recentErrors.length > 50) bg.recentErrors.splice(0, bg.recentErrors.length - 50);
352
+
353
+ if (bg.status === "ready") {
354
+ bg.status = "error";
355
+ addEvent(bg, {
356
+ type: "error_detected",
357
+ detail: line.trim().slice(0, 200),
358
+ data: { errorCount: bg.recentErrors.length },
359
+ });
360
+ pushAlert(bg, `error_detected: ${line.trim().slice(0, 120)}`);
361
+ }
362
+ }
363
+
364
+ // Warning detection
365
+ if (WARNING_PATTERNS.some(p => p.test(line))) {
366
+ bg.recentWarnings.push(line.trim().slice(0, 200));
367
+ if (bg.recentWarnings.length > 50) bg.recentWarnings.splice(0, bg.recentWarnings.length - 50);
368
+ }
369
+
370
+ // URL extraction
371
+ const urlMatches = line.match(URL_PATTERN);
372
+ if (urlMatches) {
373
+ for (const url of urlMatches) {
374
+ if (!bg.urls.includes(url)) {
375
+ bg.urls.push(url);
376
+ }
377
+ }
378
+ }
379
+
380
+ // Port extraction
381
+ let portMatch: RegExpExecArray | null;
382
+ const portRe = new RegExp(PORT_PATTERN.source, PORT_PATTERN.flags);
383
+ while ((portMatch = portRe.exec(line)) !== null) {
384
+ const port = parseInt(portMatch[1], 10);
385
+ if (port > 0 && port <= 65535 && !bg.ports.includes(port)) {
386
+ bg.ports.push(port);
387
+ addEvent(bg, {
388
+ type: "port_open",
389
+ detail: `Port ${port} detected`,
390
+ data: { port },
391
+ });
392
+ }
393
+ }
394
+
395
+ // Readiness detection
396
+ if (bg.status === "starting") {
397
+ // Check custom ready pattern first
398
+ if (bg.readyPattern) {
399
+ try {
400
+ if (new RegExp(bg.readyPattern, "i").test(line)) {
401
+ transitionToReady(bg, `Custom pattern matched: ${line.trim().slice(0, 100)}`);
402
+ }
403
+ } catch { /* invalid regex, skip */ }
404
+ }
405
+
406
+ // Check built-in readiness patterns
407
+ if (bg.status === "starting" && READINESS_PATTERNS.some(p => p.test(line))) {
408
+ transitionToReady(bg, `Readiness pattern matched: ${line.trim().slice(0, 100)}`);
409
+ }
410
+ }
411
+
412
+ // Recovery detection: if we were in error and see a success pattern
413
+ if (bg.status === "error") {
414
+ if (READINESS_PATTERNS.some(p => p.test(line)) || BUILD_COMPLETE_PATTERNS.some(p => p.test(line))) {
415
+ bg.status = "ready";
416
+ bg.recentErrors = [];
417
+ addEvent(bg, { type: "recovered", detail: "Process recovered from error state" });
418
+ pushAlert(bg, "recovered — errors cleared");
419
+ }
420
+ }
421
+
422
+ // Dedup tracking
423
+ bg.totalRawLines++;
424
+ const lineHash = line.trim().slice(0, 100);
425
+ bg.lineDedup.set(lineHash, (bg.lineDedup.get(lineHash) || 0) + 1);
426
+ }
427
+
428
+ function transitionToReady(bg: BgProcess, detail: string): void {
429
+ bg.status = "ready";
430
+ bg.wasReady = true;
431
+ addEvent(bg, { type: "ready", detail });
432
+ }
433
+
434
+ function pushAlert(bg: BgProcess, message: string): void {
435
+ pendingAlerts.push(`[bg:${bg.id} ${bg.label}] ${message}`);
436
+ }
437
+
438
+ // ── Port Probing ───────────────────────────────────────────────────────────
439
+
440
+ function probePort(port: number, host: string = "127.0.0.1"): Promise<boolean> {
441
+ return new Promise((resolve) => {
442
+ const socket = createConnection({ port, host, timeout: PORT_PROBE_TIMEOUT }, () => {
443
+ socket.destroy();
444
+ resolve(true);
445
+ });
446
+ socket.on("error", () => {
447
+ socket.destroy();
448
+ resolve(false);
449
+ });
450
+ socket.on("timeout", () => {
451
+ socket.destroy();
452
+ resolve(false);
453
+ });
454
+ });
455
+ }
456
+
457
+ // ── Digest Generation ──────────────────────────────────────────────────────
458
+
459
+ function generateDigest(bg: BgProcess, mutate: boolean = false): OutputDigest {
460
+ // Change summary: what's different since last read
461
+ const newErrors = bg.recentErrors.length - bg.lastErrorCount;
462
+ const newWarnings = bg.recentWarnings.length - bg.lastWarningCount;
463
+ const newLines = bg.output.length - bg.lastReadIndex;
464
+
465
+ let changeSummary: string;
466
+ if (newLines === 0) {
467
+ changeSummary = "no new output";
468
+ } else {
469
+ const parts: string[] = [];
470
+ parts.push(`${newLines} new lines`);
471
+ if (newErrors > 0) parts.push(`${newErrors} new errors`);
472
+ if (newWarnings > 0) parts.push(`${newWarnings} new warnings`);
473
+ changeSummary = parts.join(", ");
474
+ }
475
+
476
+ // Only mutate snapshot counters when explicitly requested (e.g. from tool calls)
477
+ if (mutate) {
478
+ bg.lastErrorCount = bg.recentErrors.length;
479
+ bg.lastWarningCount = bg.recentWarnings.length;
480
+ }
481
+
482
+ return {
483
+ status: bg.status,
484
+ uptime: formatUptime(Date.now() - bg.startedAt),
485
+ errors: bg.recentErrors.slice(-5), // Last 5 errors
486
+ warnings: bg.recentWarnings.slice(-3), // Last 3 warnings
487
+ urls: bg.urls,
488
+ ports: bg.ports,
489
+ lastActivity: bg.events.length > 0
490
+ ? formatTimeAgo(bg.events[bg.events.length - 1].timestamp)
491
+ : "none",
492
+ outputLines: bg.output.length,
493
+ changeSummary,
494
+ };
495
+ }
496
+
497
+ // ── Highlight Extraction ───────────────────────────────────────────────────
498
+
499
+ function getHighlights(bg: BgProcess, maxLines: number = 15): string[] {
500
+ const lines: string[] = [];
501
+
502
+ // Collect significant lines
503
+ const significant: { line: string; score: number; idx: number }[] = [];
504
+ for (let i = 0; i < bg.output.length; i++) {
505
+ const entry = bg.output[i];
506
+ let score = 0;
507
+ if (ERROR_PATTERNS.some(p => p.test(entry.line))) score += 10;
508
+ if (WARNING_PATTERNS.some(p => p.test(entry.line))) score += 5;
509
+ if (URL_PATTERN.test(entry.line)) score += 3;
510
+ if (READINESS_PATTERNS.some(p => p.test(entry.line))) score += 8;
511
+ if (TEST_RESULT_PATTERNS.some(p => p.test(entry.line))) score += 7;
512
+ if (BUILD_COMPLETE_PATTERNS.some(p => p.test(entry.line))) score += 6;
513
+ // Boost recent lines so highlights favor fresh output over stale
514
+ if (i >= bg.output.length - 50) score += 2;
515
+ if (score > 0) {
516
+ significant.push({ line: entry.line.trim().slice(0, 300), score, idx: i });
517
+ }
518
+ }
519
+
520
+ // Sort by significance (tie-break by recency)
521
+ significant.sort((a, b) => b.score - a.score || b.idx - a.idx);
522
+ const top = significant.slice(0, maxLines);
523
+
524
+ if (top.length === 0) {
525
+ // If nothing significant, show last few lines
526
+ const tail = bg.output.slice(-5);
527
+ for (const l of tail) lines.push(l.line.trim().slice(0, 300));
528
+ } else {
529
+ for (const entry of top) lines.push(entry.line);
530
+ }
531
+
532
+ return lines;
533
+ }
534
+
535
+ // ── Process Start ──────────────────────────────────────────────────────────
536
+
537
+ interface StartOptions {
538
+ command: string;
539
+ cwd: string;
540
+ label?: string;
541
+ type?: ProcessType;
542
+ readyPattern?: string;
543
+ readyPort?: number;
544
+ group?: string;
545
+ env?: Record<string, string>;
546
+ }
547
+
548
+ function startProcess(opts: StartOptions): BgProcess {
549
+ const id = randomUUID().slice(0, 8);
550
+ const processType = opts.type || detectProcessType(opts.command);
551
+
552
+ const env = { ...process.env, ...(opts.env || {}) };
553
+
554
+ const proc = spawn("bash", ["-c", opts.command], {
555
+ cwd: opts.cwd,
556
+ stdio: ["pipe", "pipe", "pipe"],
557
+ env,
558
+ detached: true,
559
+ });
560
+
561
+ const bg: BgProcess = {
562
+ id,
563
+ label: opts.label || opts.command.slice(0, 60),
564
+ command: opts.command,
565
+ cwd: opts.cwd,
566
+ startedAt: Date.now(),
567
+ proc,
568
+ output: [],
569
+ exitCode: null,
570
+ signal: null,
571
+ alive: true,
572
+ lastReadIndex: 0,
573
+ processType,
574
+ status: "starting",
575
+ ports: [],
576
+ urls: [],
577
+ recentErrors: [],
578
+ recentWarnings: [],
579
+ events: [],
580
+ readyPattern: opts.readyPattern || null,
581
+ readyPort: opts.readyPort || null,
582
+ wasReady: false,
583
+ group: opts.group || null,
584
+ lastErrorCount: 0,
585
+ lastWarningCount: 0,
586
+ lineDedup: new Map(),
587
+ totalRawLines: 0,
588
+ envKeys: Object.keys(opts.env || {}),
589
+ restartCount: 0,
590
+ startConfig: {
591
+ command: opts.command,
592
+ cwd: opts.cwd,
593
+ label: opts.label || opts.command.slice(0, 60),
594
+ processType,
595
+ readyPattern: opts.readyPattern || null,
596
+ readyPort: opts.readyPort || null,
597
+ group: opts.group || null,
598
+ },
599
+ };
600
+
601
+ addEvent(bg, { type: "started", detail: `Process started: ${opts.command.slice(0, 100)}` });
602
+
603
+ proc.stdout?.on("data", (chunk: Buffer) => {
604
+ const lines = chunk.toString().split("\n");
605
+ for (const line of lines) {
606
+ if (line.length > 0) {
607
+ addOutputLine(bg, "stdout", line);
608
+ analyzeLine(bg, line, "stdout");
609
+ }
610
+ }
611
+ });
612
+
613
+ proc.stderr?.on("data", (chunk: Buffer) => {
614
+ const lines = chunk.toString().split("\n");
615
+ for (const line of lines) {
616
+ if (line.length > 0) {
617
+ addOutputLine(bg, "stderr", line);
618
+ analyzeLine(bg, line, "stderr");
619
+ }
620
+ }
621
+ });
622
+
623
+ proc.on("exit", (code, sig) => {
624
+ bg.alive = false;
625
+ bg.exitCode = code;
626
+ bg.signal = sig ?? null;
627
+
628
+ if (code === 0) {
629
+ bg.status = "exited";
630
+ addEvent(bg, { type: "exited", detail: `Exited cleanly (code 0)` });
631
+ } else {
632
+ bg.status = "crashed";
633
+ const lastErrors = bg.recentErrors.slice(-3).join("; ");
634
+ const detail = `Crashed with code ${code}${sig ? ` (signal ${sig})` : ""}${lastErrors ? ` — ${lastErrors}` : ""}`;
635
+ addEvent(bg, {
636
+ type: "crashed",
637
+ detail,
638
+ data: { exitCode: code, signal: sig, lastErrors: bg.recentErrors.slice(-5) },
639
+ });
640
+ pushAlert(bg, `CRASHED (code ${code})${lastErrors ? `: ${lastErrors.slice(0, 120)}` : ""}`);
641
+ }
642
+ });
643
+
644
+ proc.on("error", (err) => {
645
+ bg.alive = false;
646
+ bg.status = "crashed";
647
+ addOutputLine(bg, "stderr", `[spawn error] ${err.message}`);
648
+ addEvent(bg, { type: "crashed", detail: `Spawn error: ${err.message}` });
649
+ pushAlert(bg, `spawn error: ${err.message}`);
650
+ });
651
+
652
+ // Port probing for server-type processes
653
+ if (bg.readyPort) {
654
+ startPortProbing(bg, bg.readyPort);
655
+ }
656
+
657
+ processes.set(id, bg);
658
+ return bg;
659
+ }
660
+
661
+ // ── Port Probing Loop ──────────────────────────────────────────────────────
662
+
663
+ function startPortProbing(bg: BgProcess, port: number): void {
664
+ const interval = setInterval(async () => {
665
+ if (!bg.alive || bg.status !== "starting") {
666
+ clearInterval(interval);
667
+ return;
668
+ }
669
+ const open = await probePort(port);
670
+ if (open) {
671
+ clearInterval(interval);
672
+ if (!bg.ports.includes(port)) bg.ports.push(port);
673
+ transitionToReady(bg, `Port ${port} is open`);
674
+ addEvent(bg, { type: "port_open", detail: `Port ${port} is open`, data: { port } });
675
+ }
676
+ }, READY_POLL_INTERVAL);
677
+
678
+ // Stop probing after timeout
679
+ setTimeout(() => clearInterval(interval), DEFAULT_READY_TIMEOUT);
680
+ }
681
+
682
+ // ── Process Kill ───────────────────────────────────────────────────────────
683
+
684
+ function killProcess(id: string, sig: NodeJS.Signals = "SIGTERM"): boolean {
685
+ const bg = processes.get(id);
686
+ if (!bg) return false;
687
+ if (!bg.alive) return true;
688
+ try {
689
+ if (bg.proc.pid) {
690
+ try {
691
+ process.kill(-bg.proc.pid, sig);
692
+ } catch {
693
+ bg.proc.kill(sig);
694
+ }
695
+ } else {
696
+ bg.proc.kill(sig);
697
+ }
698
+ return true;
699
+ } catch {
700
+ return false;
701
+ }
702
+ }
703
+
704
+ // ── Process Restart ────────────────────────────────────────────────────────
705
+
706
+ async function restartProcess(id: string): Promise<BgProcess | null> {
707
+ const old = processes.get(id);
708
+ if (!old) return null;
709
+
710
+ const config = old.startConfig;
711
+ const restartCount = old.restartCount + 1;
712
+
713
+ // Kill old process
714
+ if (old.alive) {
715
+ killProcess(id, "SIGTERM");
716
+ await new Promise(r => setTimeout(r, 300));
717
+ if (old.alive) {
718
+ killProcess(id, "SIGKILL");
719
+ await new Promise(r => setTimeout(r, 200));
720
+ }
721
+ }
722
+ processes.delete(id);
723
+
724
+ // Start new one
725
+ const newBg = startProcess({
726
+ command: config.command,
727
+ cwd: config.cwd,
728
+ label: config.label,
729
+ type: config.processType,
730
+ readyPattern: config.readyPattern || undefined,
731
+ readyPort: config.readyPort || undefined,
732
+ group: config.group || undefined,
733
+ });
734
+ newBg.restartCount = restartCount;
735
+
736
+ return newBg;
737
+ }
738
+
739
+ // ── Output Retrieval (multi-tier) ──────────────────────────────────────────
740
+
741
+ interface GetOutputOptions {
742
+ stream: "stdout" | "stderr" | "both";
743
+ tail?: number;
744
+ filter?: string;
745
+ incremental?: boolean;
746
+ }
747
+
748
+ function getOutput(bg: BgProcess, opts: GetOutputOptions): string {
749
+ const { stream, tail, filter, incremental } = opts;
750
+
751
+ // Get the relevant slice of the unified buffer (already in chronological order)
752
+ let entries: OutputLine[];
753
+ if (incremental) {
754
+ entries = bg.output.slice(bg.lastReadIndex);
755
+ bg.lastReadIndex = bg.output.length;
756
+ } else {
757
+ entries = [...bg.output];
758
+ }
759
+
760
+ // Filter by stream if requested
761
+ if (stream !== "both") {
762
+ entries = entries.filter(e => e.stream === stream);
763
+ }
764
+
765
+ // Apply regex filter
766
+ if (filter) {
767
+ try {
768
+ const re = new RegExp(filter, "i");
769
+ entries = entries.filter(e => re.test(e.line));
770
+ } catch { /* invalid regex */ }
771
+ }
772
+
773
+ // Tail
774
+ if (tail && tail > 0 && entries.length > tail) {
775
+ entries = entries.slice(-tail);
776
+ }
777
+
778
+ const lines = entries.map(e => e.line);
779
+ const raw = lines.join("\n");
780
+ const truncation = truncateHead(raw, {
781
+ maxLines: DEFAULT_MAX_LINES,
782
+ maxBytes: DEFAULT_MAX_BYTES,
783
+ });
784
+
785
+ let result = truncation.content;
786
+ if (truncation.truncated) {
787
+ result += `\n\n[Output truncated: showing ${truncation.outputLines}/${truncation.totalLines} lines]`;
788
+ }
789
+ return result;
790
+ }
791
+
792
+ // ── Wait for Ready ─────────────────────────────────────────────────────────
793
+
794
+ async function waitForReady(bg: BgProcess, timeout: number, signal?: AbortSignal): Promise<{ ready: boolean; detail: string }> {
795
+ const start = Date.now();
796
+
797
+ while (Date.now() - start < timeout) {
798
+ if (signal?.aborted) {
799
+ return { ready: false, detail: "Cancelled" };
800
+ }
801
+ if (!bg.alive) {
802
+ return {
803
+ ready: false,
804
+ detail: `Process exited before becoming ready (code ${bg.exitCode})${bg.recentErrors.length > 0 ? ` — ${bg.recentErrors.slice(-1)[0]}` : ""}`,
805
+ };
806
+ }
807
+ if (bg.status === "ready") {
808
+ return {
809
+ ready: true,
810
+ detail: bg.events.find(e => e.type === "ready")?.detail || "Process is ready",
811
+ };
812
+ }
813
+ await new Promise(r => setTimeout(r, READY_POLL_INTERVAL));
814
+ }
815
+
816
+ // Timeout — try port probe as last resort
817
+ if (bg.readyPort) {
818
+ const open = await probePort(bg.readyPort);
819
+ if (open) {
820
+ transitionToReady(bg, `Port ${bg.readyPort} is open (detected at timeout)`);
821
+ return { ready: true, detail: `Port ${bg.readyPort} is open` };
822
+ }
823
+ }
824
+
825
+ return { ready: false, detail: `Timed out after ${timeout}ms waiting for ready signal` };
826
+ }
827
+
828
+ // ── Send and Wait ──────────────────────────────────────────────────────────
829
+
830
+ async function sendAndWait(
831
+ bg: BgProcess,
832
+ input: string,
833
+ waitPattern: string,
834
+ timeout: number,
835
+ signal?: AbortSignal,
836
+ ): Promise<{ matched: boolean; output: string }> {
837
+ // Snapshot the current position in the unified buffer before sending
838
+ const startIndex = bg.output.length;
839
+ bg.proc.stdin?.write(input + "\n");
840
+
841
+ let re: RegExp;
842
+ try {
843
+ re = new RegExp(waitPattern, "i");
844
+ } catch {
845
+ return { matched: false, output: "Invalid wait pattern regex" };
846
+ }
847
+
848
+ const start = Date.now();
849
+ while (Date.now() - start < timeout) {
850
+ if (signal?.aborted) {
851
+ const newEntries = bg.output.slice(startIndex);
852
+ return { matched: false, output: newEntries.map(e => e.line).join("\n") || "(cancelled)" };
853
+ }
854
+ const newEntries = bg.output.slice(startIndex);
855
+ for (const entry of newEntries) {
856
+ if (re.test(entry.line)) {
857
+ return { matched: true, output: newEntries.map(e => e.line).join("\n") };
858
+ }
859
+ }
860
+ await new Promise(r => setTimeout(r, 100));
861
+ }
862
+
863
+ const newEntries = bg.output.slice(startIndex);
864
+ return { matched: false, output: newEntries.map(e => e.line).join("\n") || "(no output)" };
865
+ }
866
+
867
+ // ── Group Operations ───────────────────────────────────────────────────────
868
+
869
+ function getGroupProcesses(group: string): BgProcess[] {
870
+ return Array.from(processes.values()).filter(p => p.group === group);
871
+ }
872
+
873
+ function getGroupStatus(group: string): {
874
+ group: string;
875
+ healthy: boolean;
876
+ processes: { id: string; label: string; status: ProcessStatus; alive: boolean }[];
877
+ } {
878
+ const procs = getGroupProcesses(group);
879
+ const healthy = procs.length > 0 && procs.every(p => p.alive && (p.status === "ready" || p.status === "starting"));
880
+ return {
881
+ group,
882
+ healthy,
883
+ processes: procs.map(p => ({
884
+ id: p.id,
885
+ label: p.label,
886
+ status: p.status,
887
+ alive: p.alive,
888
+ })),
889
+ };
890
+ }
891
+
892
+ // ── Persistence ────────────────────────────────────────────────────────────
893
+
894
+ interface ProcessManifest {
895
+ id: string;
896
+ label: string;
897
+ command: string;
898
+ cwd: string;
899
+ startedAt: number;
900
+ processType: ProcessType;
901
+ group: string | null;
902
+ readyPattern: string | null;
903
+ readyPort: number | null;
904
+ pid: number | undefined;
905
+ }
906
+
907
+ function getManifestPath(cwd: string): string {
908
+ const dir = join(cwd, ".bg-shell");
909
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
910
+ return join(dir, "manifest.json");
911
+ }
912
+
913
+ function persistManifest(cwd: string): void {
914
+ try {
915
+ const manifest: ProcessManifest[] = Array.from(processes.values())
916
+ .filter(p => p.alive)
917
+ .map(p => ({
918
+ id: p.id,
919
+ label: p.label,
920
+ command: p.command,
921
+ cwd: p.cwd,
922
+ startedAt: p.startedAt,
923
+ processType: p.processType,
924
+ group: p.group,
925
+ readyPattern: p.readyPattern,
926
+ readyPort: p.readyPort,
927
+ pid: p.proc.pid,
928
+ }));
929
+ writeFileSync(getManifestPath(cwd), JSON.stringify(manifest, null, 2));
930
+ } catch { /* best effort */ }
931
+ }
932
+
933
+ function loadManifest(cwd: string): ProcessManifest[] {
934
+ try {
935
+ const path = getManifestPath(cwd);
936
+ if (existsSync(path)) {
937
+ return JSON.parse(readFileSync(path, "utf-8"));
938
+ }
939
+ } catch { /* best effort */ }
940
+ return [];
941
+ }
942
+
943
+ // ── Utilities ──────────────────────────────────────────────────────────────
944
+
945
+ function formatUptime(ms: number): string {
946
+ const seconds = Math.floor(ms / 1000);
947
+ if (seconds < 60) return `${seconds}s`;
948
+ const minutes = Math.floor(seconds / 60);
949
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
950
+ const hours = Math.floor(minutes / 60);
951
+ return `${hours}h ${minutes % 60}m`;
952
+ }
953
+
954
+ function formatTimeAgo(timestamp: number): string {
955
+ return formatUptime(Date.now() - timestamp) + " ago";
956
+ }
957
+
958
+ // ── Cleanup ────────────────────────────────────────────────────────────────
959
+
960
+ function pruneDeadProcesses(): void {
961
+ const now = Date.now();
962
+ for (const [id, bg] of processes) {
963
+ if (!bg.alive && now - bg.startedAt > DEAD_PROCESS_TTL) {
964
+ processes.delete(id);
965
+ }
966
+ }
967
+ }
968
+
969
+ function cleanupAll(): void {
970
+ for (const [id, bg] of processes) {
971
+ if (bg.alive) killProcess(id, "SIGKILL");
972
+ }
973
+ processes.clear();
974
+ }
975
+
976
+ // ── Format Digest for LLM ──────────────────────────────────────────────────
977
+
978
+ function formatDigestText(bg: BgProcess, digest: OutputDigest): string {
979
+ let text = `Process ${bg.id} (${bg.label}):\n`;
980
+ text += ` status: ${digest.status}\n`;
981
+ text += ` type: ${bg.processType}\n`;
982
+ text += ` uptime: ${digest.uptime}\n`;
983
+
984
+ if (digest.ports.length > 0) text += ` ports: ${digest.ports.join(", ")}\n`;
985
+ if (digest.urls.length > 0) text += ` urls: ${digest.urls.join(", ")}\n`;
986
+
987
+ text += ` output: ${digest.outputLines} lines\n`;
988
+ text += ` changes: ${digest.changeSummary}`;
989
+
990
+ if (digest.errors.length > 0) {
991
+ text += `\n errors (${digest.errors.length}):`;
992
+ for (const err of digest.errors) {
993
+ text += `\n - ${err}`;
994
+ }
995
+ }
996
+ if (digest.warnings.length > 0) {
997
+ text += `\n warnings (${digest.warnings.length}):`;
998
+ for (const w of digest.warnings) {
999
+ text += `\n - ${w}`;
1000
+ }
1001
+ }
1002
+
1003
+ return text;
1004
+ }
1005
+
1006
+ // ── Extension Entry Point ──────────────────────────────────────────────────
1007
+
1008
+ export default function (pi: ExtensionAPI) {
1009
+ let latestCtx: ExtensionContext | null = null;
1010
+
1011
+ // Clean up on session shutdown
1012
+ pi.on("session_shutdown", async () => {
1013
+ cleanupAll();
1014
+ });
1015
+
1016
+ // ── Compaction Awareness: Survive Context Resets ───────────────────
1017
+
1018
+ /** Build a compact state summary of all alive processes for context re-injection */
1019
+ function buildProcessStateAlert(reason: string): void {
1020
+ const alive = Array.from(processes.values()).filter(p => p.alive);
1021
+ if (alive.length === 0) return;
1022
+
1023
+ const processSummaries = alive.map(p => {
1024
+ const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : "";
1025
+ const urlInfo = p.urls.length > 0 ? ` ${p.urls[0]}` : "";
1026
+ const errInfo = p.recentErrors.length > 0 ? ` (${p.recentErrors.length} errors)` : "";
1027
+ const groupInfo = p.group ? ` [${p.group}]` : "";
1028
+ return ` - id:${p.id} "${p.label}" [${p.processType}] status:${p.status} uptime:${formatUptime(Date.now() - p.startedAt)}${portInfo}${urlInfo}${errInfo}${groupInfo}`;
1029
+ }).join("\n");
1030
+
1031
+ pendingAlerts.push(
1032
+ `${reason} ${alive.length} background process(es) are still running:\n${processSummaries}\nUse bg_shell digest/output/kill with these IDs.`
1033
+ );
1034
+ }
1035
+
1036
+ // After compaction, the LLM loses all memory of running processes.
1037
+ // Queue a detailed alert so the next before_agent_start injects full state.
1038
+ pi.on("session_compact", async () => {
1039
+ buildProcessStateAlert("Context was compacted.");
1040
+ });
1041
+
1042
+ // Tree navigation also resets the agent's context.
1043
+ pi.on("session_tree", async () => {
1044
+ buildProcessStateAlert("Session tree was navigated.");
1045
+ });
1046
+
1047
+ // Session switch resets the agent's context.
1048
+ pi.on("session_switch", async () => {
1049
+ buildProcessStateAlert("Session was switched.");
1050
+ });
1051
+
1052
+ // ── Context Injection: Proactive Alerts ────────────────────────────
1053
+
1054
+ pi.on("before_agent_start", async (_event, _ctx) => {
1055
+ // Inject process status overview and any pending alerts
1056
+ const alerts = pendingAlerts.splice(0);
1057
+ const alive = Array.from(processes.values()).filter(p => p.alive);
1058
+
1059
+ if (alerts.length === 0 && alive.length === 0) return;
1060
+
1061
+ const parts: string[] = [];
1062
+
1063
+ if (alerts.length > 0) {
1064
+ parts.push(`Background process alerts:\n${alerts.map(a => ` ${a}`).join("\n")}`);
1065
+ }
1066
+
1067
+ if (alive.length > 0) {
1068
+ const summary = alive.map(p => {
1069
+ const status = p.status === "ready" ? "✓" : p.status === "error" ? "✗" : p.status === "starting" ? "⋯" : "?";
1070
+ const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : "";
1071
+ const errInfo = p.recentErrors.length > 0 ? ` (${p.recentErrors.length} errors)` : "";
1072
+ return ` ${status} ${p.id} ${p.label}${portInfo}${errInfo}`;
1073
+ }).join("\n");
1074
+ parts.push(`Background processes:\n${summary}`);
1075
+ }
1076
+
1077
+ return {
1078
+ message: {
1079
+ customType: "bg-shell-status",
1080
+ content: parts.join("\n\n"),
1081
+ display: false,
1082
+ },
1083
+ };
1084
+ });
1085
+
1086
+ // ── Session Start: Discover Surviving Processes ────────────────────
1087
+
1088
+ pi.on("session_start", async (_event, ctx) => {
1089
+ latestCtx = ctx;
1090
+
1091
+ // Check for surviving processes from previous session
1092
+ const manifest = loadManifest(ctx.cwd);
1093
+ if (manifest.length > 0) {
1094
+ // Check which PIDs are still alive
1095
+ const surviving: ProcessManifest[] = [];
1096
+ for (const entry of manifest) {
1097
+ if (entry.pid) {
1098
+ try {
1099
+ process.kill(entry.pid, 0); // Check if process exists
1100
+ surviving.push(entry);
1101
+ } catch { /* process is dead */ }
1102
+ }
1103
+ }
1104
+
1105
+ if (surviving.length > 0) {
1106
+ const summary = surviving.map(s =>
1107
+ ` - ${s.id}: ${s.label} (pid ${s.pid}, type: ${s.processType}${s.group ? `, group: ${s.group}` : ""})`
1108
+ ).join("\n");
1109
+
1110
+ pendingAlerts.push(
1111
+ `${surviving.length} background process(es) from previous session still running:\n${summary}\n Note: These processes are outside bg_shell's control. Kill them manually if needed.`
1112
+ );
1113
+ }
1114
+ }
1115
+ });
1116
+
1117
+ // ── Tool ─────────────────────────────────────────────────────────────
1118
+
1119
+ pi.registerTool({
1120
+ name: "bg_shell",
1121
+ label: "Background Shell",
1122
+ description:
1123
+ "Run shell commands in the background without blocking. Manages persistent background processes with intelligent lifecycle tracking. " +
1124
+ "Actions: start (launch with auto-classification & readiness detection), digest (structured summary ~30 tokens vs ~2000 raw), " +
1125
+ "output (raw lines with incremental delivery), wait_for_ready (block until process signals readiness), " +
1126
+ "send (write stdin), send_and_wait (expect-style: send + wait for output pattern), " +
1127
+ "signal (send OS signal), list (all processes with status), kill (terminate), restart (kill + relaunch), " +
1128
+ "group_status (health of a process group), highlights (significant output lines only).",
1129
+
1130
+ promptGuidelines: [
1131
+ "Use bg_shell to start long-running processes (servers, watchers, builds) that should not block the agent.",
1132
+ "After starting a server, use 'wait_for_ready' to efficiently block until it's listening — avoids polling loops entirely.",
1133
+ "Use 'digest' instead of 'output' when you just need status — it returns a structured ~30-token summary instead of ~2000 tokens of raw output.",
1134
+ "Use 'highlights' to see only significant output (errors, URLs, results) — typically 5-15 lines instead of hundreds.",
1135
+ "Use 'output' only when you need raw lines for debugging — add filter:'error|warning' to narrow results.",
1136
+ "The 'output' action returns only new output since the last check (incremental). Repeated calls are cheap on context.",
1137
+ "Set type:'server' and ready_port:3000 for dev servers so readiness detection is automatic.",
1138
+ "Set group:'my-stack' on related processes to manage them together with 'group_status'.",
1139
+ "Use 'send_and_wait' for interactive CLIs: send input and wait for expected output pattern.",
1140
+ "Use 'restart' to kill and relaunch with the same config — preserves restart count.",
1141
+ "Background processes are auto-classified (server/build/test/watcher) based on the command.",
1142
+ "Process crashes and errors are automatically surfaced as alerts at the start of your next turn — you don't need to poll.",
1143
+ ],
1144
+
1145
+ parameters: Type.Object({
1146
+ action: StringEnum([
1147
+ "start",
1148
+ "digest",
1149
+ "output",
1150
+ "highlights",
1151
+ "wait_for_ready",
1152
+ "send",
1153
+ "send_and_wait",
1154
+ "signal",
1155
+ "list",
1156
+ "kill",
1157
+ "restart",
1158
+ "group_status",
1159
+ ] as const),
1160
+ command: Type.Optional(
1161
+ Type.String({ description: "Shell command to run (for start)" }),
1162
+ ),
1163
+ label: Type.Optional(
1164
+ Type.String({ description: "Short human-readable label for the process (for start)" }),
1165
+ ),
1166
+ id: Type.Optional(
1167
+ Type.String({ description: "Process ID (for digest, output, highlights, wait_for_ready, send, send_and_wait, signal, kill, restart)" }),
1168
+ ),
1169
+ stream: Type.Optional(
1170
+ StringEnum(["stdout", "stderr", "both"] as const),
1171
+ ),
1172
+ tail: Type.Optional(
1173
+ Type.Number({ description: "Number of most recent lines to return (for output). Defaults to 100." }),
1174
+ ),
1175
+ filter: Type.Optional(
1176
+ Type.String({ description: "Regex pattern to filter output lines (for output). Case-insensitive." }),
1177
+ ),
1178
+ input: Type.Optional(
1179
+ Type.String({ description: "Text to write to process stdin (for send, send_and_wait)" }),
1180
+ ),
1181
+ wait_pattern: Type.Optional(
1182
+ Type.String({ description: "Regex to wait for in output (for send_and_wait)" }),
1183
+ ),
1184
+ signal_name: Type.Optional(
1185
+ Type.String({ description: "OS signal to send, e.g. SIGINT, SIGTERM, SIGHUP (for signal)" }),
1186
+ ),
1187
+ timeout: Type.Optional(
1188
+ Type.Number({ description: "Timeout in milliseconds (for wait_for_ready, send_and_wait). Default: 30000" }),
1189
+ ),
1190
+ type: Type.Optional(
1191
+ StringEnum(["server", "build", "test", "watcher", "generic"] as const),
1192
+ ),
1193
+ ready_pattern: Type.Optional(
1194
+ Type.String({ description: "Regex pattern that indicates the process is ready (for start)" }),
1195
+ ),
1196
+ ready_port: Type.Optional(
1197
+ Type.Number({ description: "Port to probe for readiness (for start). When open, process is considered ready." }),
1198
+ ),
1199
+ group: Type.Optional(
1200
+ Type.String({ description: "Group name for related processes (for start, group_status)" }),
1201
+ ),
1202
+ }),
1203
+
1204
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1205
+ latestCtx = ctx;
1206
+
1207
+ switch (params.action) {
1208
+ // ── start ──────────────────────────────────────────
1209
+ case "start": {
1210
+ if (!params.command) {
1211
+ return {
1212
+ content: [{ type: "text" as const, text: "Error: 'command' is required for start" }],
1213
+ isError: true,
1214
+ };
1215
+ }
1216
+
1217
+ const bg = startProcess({
1218
+ command: params.command,
1219
+ cwd: ctx.cwd,
1220
+ label: params.label,
1221
+ type: params.type as ProcessType | undefined,
1222
+ readyPattern: params.ready_pattern,
1223
+ readyPort: params.ready_port,
1224
+ group: params.group,
1225
+ });
1226
+
1227
+ // Give the process a moment to potentially fail immediately
1228
+ await new Promise(r => setTimeout(r, 500));
1229
+
1230
+ // Persist manifest
1231
+ persistManifest(ctx.cwd);
1232
+
1233
+ const info = getInfo(bg);
1234
+ let text = `Started background process ${bg.id}\n`;
1235
+ text += ` label: ${bg.label}\n`;
1236
+ text += ` type: ${bg.processType}\n`;
1237
+ text += ` status: ${bg.status}\n`;
1238
+ text += ` command: ${bg.command}\n`;
1239
+ text += ` cwd: ${bg.cwd}`;
1240
+
1241
+ if (bg.group) text += `\n group: ${bg.group}`;
1242
+ if (bg.readyPort) text += `\n ready_port: ${bg.readyPort}`;
1243
+ if (bg.readyPattern) text += `\n ready_pattern: ${bg.readyPattern}`;
1244
+ if (bg.ports.length > 0) text += `\n detected ports: ${bg.ports.join(", ")}`;
1245
+ if (bg.urls.length > 0) text += `\n detected urls: ${bg.urls.join(", ")}`;
1246
+
1247
+ if (!bg.alive) {
1248
+ text += `\n exit code: ${bg.exitCode}`;
1249
+ const errLines = bg.output.filter(l => l.stream === "stderr").map(l => l.line);
1250
+ const errOut = errLines.join("\n").trim();
1251
+ if (errOut) text += `\n stderr:\n${errOut}`;
1252
+ }
1253
+
1254
+ return {
1255
+ content: [{ type: "text" as const, text }],
1256
+ details: { action: "start", process: info },
1257
+ };
1258
+ }
1259
+
1260
+ // ── digest ─────────────────────────────────────────
1261
+ case "digest": {
1262
+ // Can get digest for a single process or all
1263
+ if (params.id) {
1264
+ const bg = processes.get(params.id);
1265
+ if (!bg) {
1266
+ return {
1267
+ content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
1268
+ isError: true,
1269
+ };
1270
+ }
1271
+ const digest = generateDigest(bg, true);
1272
+ return {
1273
+ content: [{ type: "text" as const, text: formatDigestText(bg, digest) }],
1274
+ details: { action: "digest", process: getInfo(bg), digest },
1275
+ };
1276
+ }
1277
+
1278
+ // All processes digest
1279
+ const all = Array.from(processes.values());
1280
+ if (all.length === 0) {
1281
+ return {
1282
+ content: [{ type: "text" as const, text: "No background processes." }],
1283
+ details: { action: "digest", processes: [] },
1284
+ };
1285
+ }
1286
+
1287
+ const lines = all.map(bg => {
1288
+ const d = generateDigest(bg, true);
1289
+ const status = bg.alive
1290
+ ? (bg.status === "ready" ? "✓" : bg.status === "error" ? "✗" : "⋯")
1291
+ : "○";
1292
+ const portInfo = d.ports.length > 0 ? ` :${d.ports.join(",")}` : "";
1293
+ const errInfo = d.errors.length > 0 ? ` (${d.errors.length} errors)` : "";
1294
+ return `${status} ${bg.id} ${bg.label} [${bg.processType}] ${d.uptime}${portInfo}${errInfo} — ${d.changeSummary}`;
1295
+ });
1296
+
1297
+ return {
1298
+ content: [{ type: "text" as const, text: `Background processes (${all.length}):\n${lines.join("\n")}` }],
1299
+ details: { action: "digest", count: all.length },
1300
+ };
1301
+ }
1302
+
1303
+ // ── highlights ──────────────────────────────────────
1304
+ case "highlights": {
1305
+ if (!params.id) {
1306
+ return {
1307
+ content: [{ type: "text" as const, text: "Error: 'id' is required for highlights" }],
1308
+ isError: true,
1309
+ };
1310
+ }
1311
+
1312
+ const bg = processes.get(params.id);
1313
+ if (!bg) {
1314
+ return {
1315
+ content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
1316
+ isError: true,
1317
+ };
1318
+ }
1319
+
1320
+ const highlights = getHighlights(bg, params.tail || 15);
1321
+ const info = getInfo(bg);
1322
+ let text = `Highlights for ${bg.id} (${bg.label}) — ${bg.status}:\n`;
1323
+ if (highlights.length === 0) {
1324
+ text += "(no significant output)";
1325
+ } else {
1326
+ text += highlights.join("\n");
1327
+ }
1328
+
1329
+ return {
1330
+ content: [{ type: "text" as const, text }],
1331
+ details: { action: "highlights", process: info, lineCount: highlights.length },
1332
+ };
1333
+ }
1334
+
1335
+ // ── output ─────────────────────────────────────────
1336
+ case "output": {
1337
+ if (!params.id) {
1338
+ return {
1339
+ content: [{ type: "text" as const, text: "Error: 'id' is required for output" }],
1340
+ isError: true,
1341
+ };
1342
+ }
1343
+
1344
+ const bg = processes.get(params.id);
1345
+ if (!bg) {
1346
+ return {
1347
+ content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
1348
+ isError: true,
1349
+ };
1350
+ }
1351
+
1352
+ const stream = params.stream || "both";
1353
+ const tail = params.tail ?? 100;
1354
+ const output = getOutput(bg, {
1355
+ stream,
1356
+ tail,
1357
+ filter: params.filter,
1358
+ incremental: true,
1359
+ });
1360
+ const info = getInfo(bg);
1361
+
1362
+ let text = `Process ${bg.id} (${bg.label})`;
1363
+ text += ` — ${bg.alive ? `${bg.status}` : `exited (code ${bg.exitCode})`}`;
1364
+ if (output) {
1365
+ text += `\n${output}`;
1366
+ } else {
1367
+ text += `\n(no new output since last check)`;
1368
+ }
1369
+
1370
+ return {
1371
+ content: [{ type: "text" as const, text }],
1372
+ details: { action: "output", process: info, stream, tail },
1373
+ };
1374
+ }
1375
+
1376
+ // ── wait_for_ready ──────────────────────────────────
1377
+ case "wait_for_ready": {
1378
+ if (!params.id) {
1379
+ return {
1380
+ content: [{ type: "text" as const, text: "Error: 'id' is required for wait_for_ready" }],
1381
+ isError: true,
1382
+ };
1383
+ }
1384
+
1385
+ const bg = processes.get(params.id);
1386
+ if (!bg) {
1387
+ return {
1388
+ content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
1389
+ isError: true,
1390
+ };
1391
+ }
1392
+
1393
+ // Already ready?
1394
+ if (bg.status === "ready") {
1395
+ const digest = generateDigest(bg, true);
1396
+ return {
1397
+ content: [{ type: "text" as const, text: `Process ${bg.id} is already ready.\n${formatDigestText(bg, digest)}` }],
1398
+ details: { action: "wait_for_ready", process: getInfo(bg), ready: true },
1399
+ };
1400
+ }
1401
+
1402
+ const timeout = params.timeout || DEFAULT_READY_TIMEOUT;
1403
+ const result = await waitForReady(bg, timeout, signal ?? undefined);
1404
+
1405
+ const digest = generateDigest(bg, true);
1406
+ let text: string;
1407
+ if (result.ready) {
1408
+ text = `✓ Process ${bg.id} is ready: ${result.detail}\n${formatDigestText(bg, digest)}`;
1409
+ } else {
1410
+ text = `✗ Process ${bg.id} not ready: ${result.detail}\n${formatDigestText(bg, digest)}`;
1411
+ }
1412
+
1413
+ return {
1414
+ content: [{ type: "text" as const, text }],
1415
+ details: { action: "wait_for_ready", process: getInfo(bg), ready: result.ready, detail: result.detail },
1416
+ };
1417
+ }
1418
+
1419
+ // ── send ───────────────────────────────────────────
1420
+ case "send": {
1421
+ if (!params.id) {
1422
+ return {
1423
+ content: [{ type: "text" as const, text: "Error: 'id' is required for send" }],
1424
+ isError: true,
1425
+ };
1426
+ }
1427
+ if (params.input === undefined) {
1428
+ return {
1429
+ content: [{ type: "text" as const, text: "Error: 'input' is required for send" }],
1430
+ isError: true,
1431
+ };
1432
+ }
1433
+
1434
+ const bg = processes.get(params.id);
1435
+ if (!bg) {
1436
+ return {
1437
+ content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
1438
+ isError: true,
1439
+ };
1440
+ }
1441
+
1442
+ if (!bg.alive) {
1443
+ return {
1444
+ content: [{ type: "text" as const, text: `Error: Process ${params.id} has already exited` }],
1445
+ isError: true,
1446
+ };
1447
+ }
1448
+
1449
+ try {
1450
+ bg.proc.stdin?.write(params.input + "\n");
1451
+ return {
1452
+ content: [{ type: "text" as const, text: `Sent input to process ${bg.id}` }],
1453
+ details: { action: "send", process: getInfo(bg) },
1454
+ };
1455
+ } catch (err) {
1456
+ return {
1457
+ content: [{ type: "text" as const, text: `Error writing to stdin: ${err instanceof Error ? err.message : String(err)}` }],
1458
+ isError: true,
1459
+ };
1460
+ }
1461
+ }
1462
+
1463
+ // ── send_and_wait ───────────────────────────────────
1464
+ case "send_and_wait": {
1465
+ if (!params.id) {
1466
+ return {
1467
+ content: [{ type: "text" as const, text: "Error: 'id' is required for send_and_wait" }],
1468
+ isError: true,
1469
+ };
1470
+ }
1471
+ if (params.input === undefined) {
1472
+ return {
1473
+ content: [{ type: "text" as const, text: "Error: 'input' is required for send_and_wait" }],
1474
+ isError: true,
1475
+ };
1476
+ }
1477
+ if (!params.wait_pattern) {
1478
+ return {
1479
+ content: [{ type: "text" as const, text: "Error: 'wait_pattern' is required for send_and_wait" }],
1480
+ isError: true,
1481
+ };
1482
+ }
1483
+
1484
+ const bg = processes.get(params.id);
1485
+ if (!bg) {
1486
+ return {
1487
+ content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
1488
+ isError: true,
1489
+ };
1490
+ }
1491
+
1492
+ if (!bg.alive) {
1493
+ return {
1494
+ content: [{ type: "text" as const, text: `Error: Process ${params.id} has already exited` }],
1495
+ isError: true,
1496
+ };
1497
+ }
1498
+
1499
+ const timeout = params.timeout || 10000;
1500
+ const result = await sendAndWait(bg, params.input, params.wait_pattern, timeout, signal ?? undefined);
1501
+
1502
+ let text: string;
1503
+ if (result.matched) {
1504
+ text = `✓ Pattern matched for process ${bg.id}\n${result.output}`;
1505
+ } else {
1506
+ text = `✗ Pattern not matched (timed out after ${timeout}ms)\n${result.output}`;
1507
+ }
1508
+
1509
+ return {
1510
+ content: [{ type: "text" as const, text }],
1511
+ details: { action: "send_and_wait", process: getInfo(bg), matched: result.matched },
1512
+ };
1513
+ }
1514
+
1515
+ // ── signal ─────────────────────────────────────────
1516
+ case "signal": {
1517
+ if (!params.id) {
1518
+ return {
1519
+ content: [{ type: "text" as const, text: "Error: 'id' is required for signal" }],
1520
+ isError: true,
1521
+ };
1522
+ }
1523
+
1524
+ const bg = processes.get(params.id);
1525
+ if (!bg) {
1526
+ return {
1527
+ content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
1528
+ isError: true,
1529
+ };
1530
+ }
1531
+
1532
+ const sig = (params.signal_name || "SIGINT") as NodeJS.Signals;
1533
+ const sent = killProcess(params.id, sig);
1534
+
1535
+ return {
1536
+ content: [{ type: "text" as const, text: sent ? `Sent ${sig} to process ${bg.id} (${bg.label})` : `Failed to send ${sig} to process ${bg.id}` }],
1537
+ details: { action: "signal", process: getInfo(bg), signal: sig },
1538
+ };
1539
+ }
1540
+
1541
+ // ── list ───────────────────────────────────────────
1542
+ case "list": {
1543
+ const all = Array.from(processes.values()).map(getInfo);
1544
+
1545
+ if (all.length === 0) {
1546
+ return {
1547
+ content: [{ type: "text" as const, text: "No background processes." }],
1548
+ details: { action: "list", processes: [] },
1549
+ };
1550
+ }
1551
+
1552
+ const lines = all.map(p => {
1553
+ const status = p.alive
1554
+ ? (p.status === "ready" ? "✓ ready" : p.status === "error" ? "✗ error" : "⋯ starting")
1555
+ : `○ ${p.status} (code ${p.exitCode})`;
1556
+ const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : "";
1557
+ const urlInfo = p.urls.length > 0 ? ` ${p.urls[0]}` : "";
1558
+ const groupInfo = p.group ? ` [${p.group}]` : "";
1559
+ return `${p.id} ${status} ${p.uptime} ${p.label} [${p.processType}]${portInfo}${urlInfo}${groupInfo}`;
1560
+ });
1561
+
1562
+ return {
1563
+ content: [{ type: "text" as const, text: `Background processes (${all.length}):\n${lines.join("\n")}` }],
1564
+ details: { action: "list", processes: all },
1565
+ };
1566
+ }
1567
+
1568
+ // ── kill ───────────────────────────────────────────
1569
+ case "kill": {
1570
+ if (!params.id) {
1571
+ return {
1572
+ content: [{ type: "text" as const, text: "Error: 'id' is required for kill" }],
1573
+ isError: true,
1574
+ };
1575
+ }
1576
+
1577
+ const bg = processes.get(params.id);
1578
+ if (!bg) {
1579
+ return {
1580
+ content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
1581
+ isError: true,
1582
+ };
1583
+ }
1584
+
1585
+ const killed = killProcess(params.id, "SIGTERM");
1586
+ await new Promise(r => setTimeout(r, 300));
1587
+ if (bg.alive) {
1588
+ killProcess(params.id, "SIGKILL");
1589
+ await new Promise(r => setTimeout(r, 200));
1590
+ }
1591
+
1592
+ const info = getInfo(bg);
1593
+ if (!bg.alive) processes.delete(params.id);
1594
+
1595
+ // Update manifest
1596
+ persistManifest(ctx.cwd);
1597
+
1598
+ return {
1599
+ content: [{ type: "text" as const, text: killed ? `Killed process ${bg.id} (${bg.label})` : `Failed to kill process ${bg.id}` }],
1600
+ details: { action: "kill", process: info },
1601
+ };
1602
+ }
1603
+
1604
+ // ── restart ────────────────────────────────────────
1605
+ case "restart": {
1606
+ if (!params.id) {
1607
+ return {
1608
+ content: [{ type: "text" as const, text: "Error: 'id' is required for restart" }],
1609
+ isError: true,
1610
+ };
1611
+ }
1612
+
1613
+ const newBg = await restartProcess(params.id);
1614
+ if (!newBg) {
1615
+ return {
1616
+ content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
1617
+ isError: true,
1618
+ };
1619
+ }
1620
+
1621
+ // Give it a moment
1622
+ await new Promise(r => setTimeout(r, 500));
1623
+ persistManifest(ctx.cwd);
1624
+
1625
+ const info = getInfo(newBg);
1626
+ let text = `Restarted process (restart #${newBg.restartCount})\n`;
1627
+ text += ` new id: ${newBg.id}\n`;
1628
+ text += ` label: ${newBg.label}\n`;
1629
+ text += ` type: ${newBg.processType}\n`;
1630
+ text += ` status: ${newBg.status}\n`;
1631
+ text += ` command: ${newBg.command}`;
1632
+
1633
+ return {
1634
+ content: [{ type: "text" as const, text }],
1635
+ details: { action: "restart", process: info, previousId: params.id },
1636
+ };
1637
+ }
1638
+
1639
+ // ── group_status ────────────────────────────────────
1640
+ case "group_status": {
1641
+ if (!params.group) {
1642
+ // List all groups
1643
+ const groups = new Set<string>();
1644
+ for (const p of processes.values()) {
1645
+ if (p.group) groups.add(p.group);
1646
+ }
1647
+
1648
+ if (groups.size === 0) {
1649
+ return {
1650
+ content: [{ type: "text" as const, text: "No process groups defined." }],
1651
+ details: { action: "group_status", groups: [] },
1652
+ };
1653
+ }
1654
+
1655
+ const statuses = Array.from(groups).map(g => {
1656
+ const gs = getGroupStatus(g);
1657
+ const icon = gs.healthy ? "✓" : "✗";
1658
+ const procs = gs.processes.map(p => `${p.id} (${p.status})`).join(", ");
1659
+ return `${icon} ${g}: ${procs}`;
1660
+ });
1661
+
1662
+ return {
1663
+ content: [{ type: "text" as const, text: `Process groups:\n${statuses.join("\n")}` }],
1664
+ details: { action: "group_status", groups: Array.from(groups) },
1665
+ };
1666
+ }
1667
+
1668
+ const gs = getGroupStatus(params.group);
1669
+ const icon = gs.healthy ? "✓" : "✗";
1670
+ let text = `${icon} Group '${params.group}' — ${gs.healthy ? "healthy" : "unhealthy"}\n`;
1671
+ for (const p of gs.processes) {
1672
+ text += ` ${p.id}: ${p.label} — ${p.status}${p.alive ? "" : " (dead)"}\n`;
1673
+ }
1674
+
1675
+ return {
1676
+ content: [{ type: "text" as const, text }],
1677
+ details: { action: "group_status", groupStatus: gs },
1678
+ };
1679
+ }
1680
+
1681
+ default:
1682
+ return {
1683
+ content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }],
1684
+ isError: true,
1685
+ };
1686
+ }
1687
+ },
1688
+
1689
+ // ── Rendering ────────────────────────────────────────────────────
1690
+
1691
+ renderCall(args, theme) {
1692
+ let text = theme.fg("toolTitle", theme.bold("bg_shell "));
1693
+ text += theme.fg("accent", args.action);
1694
+ if (args.command) text += " " + theme.fg("muted", `$ ${args.command}`);
1695
+ if (args.id) text += " " + theme.fg("dim", `[${args.id}]`);
1696
+ if (args.label) text += " " + theme.fg("dim", `(${args.label})`);
1697
+ if (args.type) text += " " + theme.fg("dim", `type:${args.type}`);
1698
+ if (args.ready_port) text += " " + theme.fg("dim", `port:${args.ready_port}`);
1699
+ if (args.group) text += " " + theme.fg("dim", `group:${args.group}`);
1700
+ return new Text(text, 0, 0);
1701
+ },
1702
+
1703
+ renderResult(result, { expanded }, theme) {
1704
+ const details = result.details as Record<string, unknown> | undefined;
1705
+ if (!details) {
1706
+ const text = result.content[0];
1707
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
1708
+ }
1709
+
1710
+ const action = details.action as string;
1711
+
1712
+ if (result.isError) {
1713
+ const text = result.content[0];
1714
+ return new Text(
1715
+ theme.fg("error", text?.type === "text" ? text.text : "Error"),
1716
+ 0, 0,
1717
+ );
1718
+ }
1719
+
1720
+ switch (action) {
1721
+ case "start": {
1722
+ const proc = details.process as BgProcessInfo;
1723
+ let text = theme.fg("success", "▸ Started ");
1724
+ text += theme.fg("accent", proc.id);
1725
+ text += " " + theme.fg("muted", proc.label);
1726
+ text += " " + theme.fg("dim", `[${proc.processType}]`);
1727
+ if (proc.ports.length > 0) text += " " + theme.fg("dim", `:${proc.ports.join(",")}`);
1728
+ if (!proc.alive) {
1729
+ text += " " + theme.fg("error", `(exited: ${proc.exitCode})`);
1730
+ }
1731
+ return new Text(text, 0, 0);
1732
+ }
1733
+
1734
+ case "digest": {
1735
+ const proc = details.process as BgProcessInfo | undefined;
1736
+ if (proc) {
1737
+ const statusIcon = proc.status === "ready" ? theme.fg("success", "✓")
1738
+ : proc.status === "error" ? theme.fg("error", "✗")
1739
+ : theme.fg("warning", "⋯");
1740
+ let text = `${statusIcon} ${theme.fg("accent", proc.id)} ${theme.fg("muted", proc.label)}`;
1741
+ if (expanded) {
1742
+ const rawText = result.content[0];
1743
+ if (rawText?.type === "text") {
1744
+ const lines = rawText.text.split("\n").slice(1);
1745
+ for (const line of lines.slice(0, 20)) {
1746
+ text += "\n " + theme.fg("dim", line);
1747
+ }
1748
+ }
1749
+ }
1750
+ return new Text(text, 0, 0);
1751
+ }
1752
+ return new Text(theme.fg("dim", `${details.count ?? 0} process(es)`), 0, 0);
1753
+ }
1754
+
1755
+ case "highlights": {
1756
+ const proc = details.process as BgProcessInfo;
1757
+ const lineCount = details.lineCount as number;
1758
+ let text = theme.fg("accent", proc.id) + " " + theme.fg("dim", `${lineCount} highlights`);
1759
+ if (expanded) {
1760
+ const rawText = result.content[0];
1761
+ if (rawText?.type === "text") {
1762
+ const lines = rawText.text.split("\n").slice(1);
1763
+ for (const line of lines.slice(0, 20)) {
1764
+ text += "\n " + theme.fg("toolOutput", line);
1765
+ }
1766
+ }
1767
+ }
1768
+ return new Text(text, 0, 0);
1769
+ }
1770
+
1771
+ case "output": {
1772
+ const proc = details.process as BgProcessInfo;
1773
+ const statusIcon = proc.alive
1774
+ ? (proc.status === "ready" ? theme.fg("success", "●") : proc.status === "error" ? theme.fg("error", "●") : theme.fg("warning", "●"))
1775
+ : theme.fg("error", "○");
1776
+ let text = `${statusIcon} ${theme.fg("accent", proc.id)} ${theme.fg("muted", proc.label)}`;
1777
+
1778
+ if (expanded) {
1779
+ const rawText = result.content[0];
1780
+ if (rawText?.type === "text") {
1781
+ const lines = rawText.text.split("\n").slice(1);
1782
+ const show = lines.slice(0, 30);
1783
+ for (const line of show) {
1784
+ text += "\n " + theme.fg("toolOutput", line);
1785
+ }
1786
+ if (lines.length > 30) {
1787
+ text += `\n ${theme.fg("dim", `... ${lines.length - 30} more lines`)}`;
1788
+ }
1789
+ }
1790
+ } else {
1791
+ text += " " + theme.fg("dim", `(${proc.stdoutLines} stdout, ${proc.stderrLines} stderr lines)`);
1792
+ }
1793
+ return new Text(text, 0, 0);
1794
+ }
1795
+
1796
+ case "wait_for_ready": {
1797
+ const proc = details.process as BgProcessInfo;
1798
+ const ready = details.ready as boolean;
1799
+ if (ready) {
1800
+ let text = theme.fg("success", "✓ Ready ") + theme.fg("accent", proc.id);
1801
+ if (proc.ports.length > 0) text += " " + theme.fg("dim", `:${proc.ports.join(",")}`);
1802
+ if (proc.urls.length > 0) text += " " + theme.fg("dim", proc.urls[0]);
1803
+ return new Text(text, 0, 0);
1804
+ } else {
1805
+ return new Text(
1806
+ theme.fg("error", "✗ Not ready ") + theme.fg("accent", proc.id) + " " + theme.fg("dim", String(details.detail)),
1807
+ 0, 0,
1808
+ );
1809
+ }
1810
+ }
1811
+
1812
+ case "send": {
1813
+ const proc = details.process as BgProcessInfo;
1814
+ return new Text(
1815
+ theme.fg("success", "→ ") + theme.fg("muted", `stdin → ${proc.id}`),
1816
+ 0, 0,
1817
+ );
1818
+ }
1819
+
1820
+ case "send_and_wait": {
1821
+ const proc = details.process as BgProcessInfo;
1822
+ const matched = details.matched as boolean;
1823
+ if (matched) {
1824
+ return new Text(
1825
+ theme.fg("success", "✓ ") + theme.fg("muted", `Pattern matched — ${proc.id}`),
1826
+ 0, 0,
1827
+ );
1828
+ }
1829
+ return new Text(
1830
+ theme.fg("warning", "✗ ") + theme.fg("muted", `Timed out — ${proc.id}`),
1831
+ 0, 0,
1832
+ );
1833
+ }
1834
+
1835
+ case "signal": {
1836
+ const sig = details.signal as string;
1837
+ const proc = details.process as BgProcessInfo;
1838
+ return new Text(
1839
+ theme.fg("warning", `${sig} `) + theme.fg("muted", `→ ${proc.id}`),
1840
+ 0, 0,
1841
+ );
1842
+ }
1843
+
1844
+ case "list": {
1845
+ const procs = details.processes as BgProcessInfo[];
1846
+ if (procs.length === 0) {
1847
+ return new Text(theme.fg("dim", "No background processes"), 0, 0);
1848
+ }
1849
+ let text = theme.fg("muted", `${procs.length} background process(es)`);
1850
+ if (expanded) {
1851
+ for (const p of procs) {
1852
+ const statusIcon = p.alive
1853
+ ? (p.status === "ready" ? theme.fg("success", "●") : p.status === "error" ? theme.fg("error", "●") : theme.fg("warning", "●"))
1854
+ : theme.fg("error", "○");
1855
+ const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : "";
1856
+ text += `\n ${statusIcon} ${theme.fg("accent", p.id)} ${theme.fg("dim", p.uptime)} ${theme.fg("muted", p.label)} [${p.processType}]${portInfo}`;
1857
+ }
1858
+ }
1859
+ return new Text(text, 0, 0);
1860
+ }
1861
+
1862
+ case "kill": {
1863
+ const proc = details.process as BgProcessInfo;
1864
+ return new Text(
1865
+ theme.fg("success", "✓ Killed ") + theme.fg("accent", proc.id) + " " + theme.fg("muted", proc.label),
1866
+ 0, 0,
1867
+ );
1868
+ }
1869
+
1870
+ case "restart": {
1871
+ const proc = details.process as BgProcessInfo;
1872
+ return new Text(
1873
+ theme.fg("success", "↻ Restarted ") + theme.fg("accent", proc.id) + " " + theme.fg("muted", proc.label) + " " + theme.fg("dim", `#${proc.restartCount}`),
1874
+ 0, 0,
1875
+ );
1876
+ }
1877
+
1878
+ case "group_status": {
1879
+ const gs = details.groupStatus as ReturnType<typeof getGroupStatus> | undefined;
1880
+ if (gs) {
1881
+ const icon = gs.healthy ? theme.fg("success", "✓") : theme.fg("error", "✗");
1882
+ return new Text(
1883
+ `${icon} ${theme.fg("accent", gs.group)} — ${gs.processes.length} process(es)`,
1884
+ 0, 0,
1885
+ );
1886
+ }
1887
+ const groups = details.groups as string[];
1888
+ return new Text(theme.fg("dim", `${groups?.length ?? 0} group(s)`), 0, 0);
1889
+ }
1890
+
1891
+ default: {
1892
+ const text = result.content[0];
1893
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
1894
+ }
1895
+ }
1896
+ },
1897
+ });
1898
+
1899
+ // ── Slash command: /bg ────────────────────────────────────────────────
1900
+
1901
+ pi.registerCommand("bg", {
1902
+ description: "Manage background processes: /bg [list|output|kill|killall|groups] [id]",
1903
+
1904
+ getArgumentCompletions: (prefix: string) => {
1905
+ const subcommands = ["list", "output", "kill", "killall", "groups", "digest"];
1906
+ const parts = prefix.trim().split(/\s+/);
1907
+
1908
+ if (parts.length <= 1) {
1909
+ return subcommands
1910
+ .filter(cmd => cmd.startsWith(parts[0] ?? ""))
1911
+ .map(cmd => ({ value: cmd, label: cmd }));
1912
+ }
1913
+
1914
+ if (parts[0] === "output" || parts[0] === "kill" || parts[0] === "digest") {
1915
+ const idPrefix = parts[1] ?? "";
1916
+ return Array.from(processes.values())
1917
+ .filter(p => p.id.startsWith(idPrefix))
1918
+ .map(p => ({
1919
+ value: `${parts[0]} ${p.id}`,
1920
+ label: `${p.id} — ${p.label}`,
1921
+ }));
1922
+ }
1923
+
1924
+ return [];
1925
+ },
1926
+
1927
+ handler: async (args, ctx) => {
1928
+ const parts = args.trim().split(/\s+/);
1929
+ const sub = parts[0] || "list";
1930
+
1931
+ if (sub === "list" || sub === "") {
1932
+ if (processes.size === 0) {
1933
+ ctx.ui.notify("No background processes.", "info");
1934
+ return;
1935
+ }
1936
+
1937
+ if (!ctx.hasUI) {
1938
+ const lines = Array.from(processes.values()).map(p => {
1939
+ const statusIcon = p.alive
1940
+ ? (p.status === "ready" ? "✓" : p.status === "error" ? "✗" : "⋯")
1941
+ : "○";
1942
+ const uptime = formatUptime(Date.now() - p.startedAt);
1943
+ const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : "";
1944
+ return `${p.id} ${statusIcon} ${p.status} ${uptime} ${p.label} [${p.processType}]${portInfo}`;
1945
+ });
1946
+ ctx.ui.notify(lines.join("\n"), "info");
1947
+ return;
1948
+ }
1949
+
1950
+ await ctx.ui.custom<void>(
1951
+ (tui, theme, _kb, done) => {
1952
+ return new BgManagerOverlay(tui, theme, () => {
1953
+ done();
1954
+ refreshWidget();
1955
+ });
1956
+ },
1957
+ {
1958
+ overlay: true,
1959
+ overlayOptions: {
1960
+ width: "60%",
1961
+ minWidth: 50,
1962
+ maxHeight: "70%",
1963
+ anchor: "center",
1964
+ },
1965
+ },
1966
+ );
1967
+ return;
1968
+ }
1969
+
1970
+ if (sub === "output" || sub === "digest") {
1971
+ const id = parts[1];
1972
+ if (!id) {
1973
+ ctx.ui.notify(`Usage: /bg ${sub} <id>`, "error");
1974
+ return;
1975
+ }
1976
+ const bg = processes.get(id);
1977
+ if (!bg) {
1978
+ ctx.ui.notify(`No process with id '${id}'`, "error");
1979
+ return;
1980
+ }
1981
+
1982
+ if (!ctx.hasUI) {
1983
+ if (sub === "digest") {
1984
+ const digest = generateDigest(bg);
1985
+ ctx.ui.notify(formatDigestText(bg, digest), "info");
1986
+ } else {
1987
+ const output = getOutput(bg, { stream: "both", tail: 50 });
1988
+ ctx.ui.notify(output || "(no output)", "info");
1989
+ }
1990
+ return;
1991
+ }
1992
+
1993
+ await ctx.ui.custom<void>(
1994
+ (tui, theme, _kb, done) => {
1995
+ const overlay = new BgManagerOverlay(tui, theme, () => {
1996
+ done();
1997
+ refreshWidget();
1998
+ });
1999
+ const procs = Array.from(processes.values());
2000
+ const idx = procs.findIndex(p => p.id === id);
2001
+ if (idx >= 0) overlay.selectAndView(idx);
2002
+ return overlay;
2003
+ },
2004
+ {
2005
+ overlay: true,
2006
+ overlayOptions: {
2007
+ width: "60%",
2008
+ minWidth: 50,
2009
+ maxHeight: "70%",
2010
+ anchor: "center",
2011
+ },
2012
+ },
2013
+ );
2014
+ return;
2015
+ }
2016
+
2017
+ if (sub === "kill") {
2018
+ const id = parts[1];
2019
+ if (!id) {
2020
+ ctx.ui.notify("Usage: /bg kill <id>", "error");
2021
+ return;
2022
+ }
2023
+ const bg = processes.get(id);
2024
+ if (!bg) {
2025
+ ctx.ui.notify(`No process with id '${id}'`, "error");
2026
+ return;
2027
+ }
2028
+ killProcess(id, "SIGTERM");
2029
+ await new Promise(r => setTimeout(r, 300));
2030
+ if (bg.alive) {
2031
+ killProcess(id, "SIGKILL");
2032
+ await new Promise(r => setTimeout(r, 200));
2033
+ }
2034
+ if (!bg.alive) processes.delete(id);
2035
+ ctx.ui.notify(`Killed process ${id} (${bg.label})`, "info");
2036
+ return;
2037
+ }
2038
+
2039
+ if (sub === "killall") {
2040
+ const count = processes.size;
2041
+ cleanupAll();
2042
+ ctx.ui.notify(`Killed ${count} background process(es)`, "info");
2043
+ return;
2044
+ }
2045
+
2046
+ if (sub === "groups") {
2047
+ const groups = new Set<string>();
2048
+ for (const p of processes.values()) {
2049
+ if (p.group) groups.add(p.group);
2050
+ }
2051
+ if (groups.size === 0) {
2052
+ ctx.ui.notify("No process groups defined.", "info");
2053
+ return;
2054
+ }
2055
+ const lines = Array.from(groups).map(g => {
2056
+ const gs = getGroupStatus(g);
2057
+ const icon = gs.healthy ? "✓" : "✗";
2058
+ const procs = gs.processes.map(p => `${p.id}(${p.status})`).join(", ");
2059
+ return `${icon} ${g}: ${procs}`;
2060
+ });
2061
+ ctx.ui.notify(lines.join("\n"), "info");
2062
+ return;
2063
+ }
2064
+
2065
+ ctx.ui.notify("Usage: /bg [list|output|digest|kill|killall|groups] [id]", "info");
2066
+ },
2067
+ });
2068
+
2069
+ // ── Live Footer ──────────────────────────────────────────────────────
2070
+
2071
+ /** Whether we currently own the footer via setFooter */
2072
+ let footerActive = false;
2073
+
2074
+ function buildBgStatusText(th: Theme): string {
2075
+ const alive = Array.from(processes.values()).filter(p => p.alive);
2076
+ if (alive.length === 0) return "";
2077
+
2078
+ const sep = th.fg("dim", " · ");
2079
+ const items: string[] = [];
2080
+ for (const p of alive) {
2081
+ const statusIcon = p.status === "ready" ? th.fg("success", "●")
2082
+ : p.status === "error" ? th.fg("error", "●")
2083
+ : th.fg("warning", "●");
2084
+ const name = p.label.length > 14 ? p.label.slice(0, 12) + "…" : p.label;
2085
+ const portInfo = p.ports.length > 0 ? th.fg("dim", `:${p.ports[0]}`) : "";
2086
+ const errBadge = p.recentErrors.length > 0
2087
+ ? th.fg("error", ` err:${p.recentErrors.length}`)
2088
+ : "";
2089
+ items.push(`${statusIcon} ${th.fg("muted", name)}${portInfo}${errBadge}`);
2090
+ }
2091
+ return items.join(sep);
2092
+ }
2093
+
2094
+ function formatTokenCount(count: number): string {
2095
+ if (count < 1000) return count.toString();
2096
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
2097
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
2098
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
2099
+ return `${Math.round(count / 1000000)}M`;
2100
+ }
2101
+
2102
+ /** Reference to tui for triggering re-renders when footer is active */
2103
+ let footerTui: { requestRender: () => void } | null = null;
2104
+
2105
+ function refreshWidget() {
2106
+ if (!latestCtx?.hasUI) return;
2107
+ const alive = Array.from(processes.values()).filter(p => p.alive);
2108
+
2109
+ if (alive.length === 0) {
2110
+ if (footerActive) {
2111
+ latestCtx.ui.setFooter(undefined);
2112
+ footerActive = false;
2113
+ footerTui = null;
2114
+ }
2115
+ return;
2116
+ }
2117
+
2118
+ if (footerActive) {
2119
+ // Footer already installed — just trigger a re-render
2120
+ footerTui?.requestRender();
2121
+ return;
2122
+ }
2123
+
2124
+ // Install custom footer that puts bg process info right-aligned on line 1
2125
+ footerActive = true;
2126
+ latestCtx.ui.setFooter((tui, th, footerData) => {
2127
+ footerTui = tui;
2128
+ const branchUnsub = footerData.onBranchChange(() => tui.requestRender());
2129
+
2130
+ return {
2131
+ render(width: number): string[] {
2132
+ // ── Line 1: pwd (branch) [session] ... bg status ──
2133
+ let pwd = process.cwd();
2134
+ const home = process.env.HOME || process.env.USERPROFILE;
2135
+ if (home && pwd.startsWith(home)) {
2136
+ pwd = `~${pwd.slice(home.length)}`;
2137
+ }
2138
+ const branch = footerData.getGitBranch();
2139
+ if (branch) pwd = `${pwd} (${branch})`;
2140
+
2141
+ const sessionName = latestCtx?.sessionManager?.getSessionName?.();
2142
+ if (sessionName) pwd = `${pwd} • ${sessionName}`;
2143
+
2144
+ const bgStatus = buildBgStatusText(th);
2145
+ const leftPwd = th.fg("dim", pwd);
2146
+ const leftWidth = visibleWidth(leftPwd);
2147
+ const rightWidth = visibleWidth(bgStatus);
2148
+
2149
+ let pwdLine: string;
2150
+ const minGap = 2;
2151
+ if (bgStatus && leftWidth + minGap + rightWidth <= width) {
2152
+ const pad = " ".repeat(width - leftWidth - rightWidth);
2153
+ pwdLine = leftPwd + pad + bgStatus;
2154
+ } else if (bgStatus) {
2155
+ // Truncate pwd to make room for bg status
2156
+ const availForPwd = width - rightWidth - minGap;
2157
+ if (availForPwd > 10) {
2158
+ const truncPwd = truncateToWidth(leftPwd, availForPwd, th.fg("dim", "…"));
2159
+ const truncWidth = visibleWidth(truncPwd);
2160
+ const pad = " ".repeat(Math.max(0, width - truncWidth - rightWidth));
2161
+ pwdLine = truncPwd + pad + bgStatus;
2162
+ } else {
2163
+ pwdLine = truncateToWidth(leftPwd, width, th.fg("dim", "…"));
2164
+ }
2165
+ } else {
2166
+ pwdLine = truncateToWidth(leftPwd, width, th.fg("dim", "…"));
2167
+ }
2168
+
2169
+ // ── Line 2: token stats (left) ... model (right) ──
2170
+ const ctx = latestCtx;
2171
+ const sm = ctx?.sessionManager;
2172
+ let totalInput = 0, totalOutput = 0;
2173
+ let totalCacheRead = 0, totalCacheWrite = 0, totalCost = 0;
2174
+ if (sm) {
2175
+ for (const entry of sm.getEntries()) {
2176
+ if (entry.type === "message" && (entry as any).message?.role === "assistant") {
2177
+ const u = (entry as any).message.usage;
2178
+ if (u) {
2179
+ totalInput += u.input || 0;
2180
+ totalOutput += u.output || 0;
2181
+ totalCacheRead += u.cacheRead || 0;
2182
+ totalCacheWrite += u.cacheWrite || 0;
2183
+ totalCost += u.cost?.total || 0;
2184
+ }
2185
+ }
2186
+ }
2187
+ }
2188
+
2189
+ const contextUsage = ctx?.getContextUsage?.();
2190
+ const contextWindow = contextUsage?.contextWindow ?? ctx?.model?.contextWindow ?? 0;
2191
+ const contextPercentValue = contextUsage?.percent ?? 0;
2192
+ const contextPercent = contextUsage?.percent !== null ? (contextPercentValue).toFixed(1) : "?";
2193
+
2194
+ const statsParts: string[] = [];
2195
+ if (totalInput) statsParts.push(`↑${formatTokenCount(totalInput)}`);
2196
+ if (totalOutput) statsParts.push(`↓${formatTokenCount(totalOutput)}`);
2197
+ if (totalCacheRead) statsParts.push(`R${formatTokenCount(totalCacheRead)}`);
2198
+ if (totalCacheWrite) statsParts.push(`W${formatTokenCount(totalCacheWrite)}`);
2199
+ if (totalCost) statsParts.push(`$${totalCost.toFixed(3)}`);
2200
+
2201
+ const contextDisplay = contextPercent === "?"
2202
+ ? `?/${formatTokenCount(contextWindow)}`
2203
+ : `${contextPercent}%/${formatTokenCount(contextWindow)}`;
2204
+ let contextStr: string;
2205
+ if (contextPercentValue > 90) {
2206
+ contextStr = th.fg("error", contextDisplay);
2207
+ } else if (contextPercentValue > 70) {
2208
+ contextStr = th.fg("warning", contextDisplay);
2209
+ } else {
2210
+ contextStr = contextDisplay;
2211
+ }
2212
+ statsParts.push(contextStr);
2213
+
2214
+ let statsLeft = statsParts.join(" ");
2215
+ let statsLeftWidth = visibleWidth(statsLeft);
2216
+ if (statsLeftWidth > width) {
2217
+ statsLeft = truncateToWidth(statsLeft, width, "...");
2218
+ statsLeftWidth = visibleWidth(statsLeft);
2219
+ }
2220
+
2221
+ const modelName = ctx?.model?.id || "no-model";
2222
+ let rightSide = modelName;
2223
+ if (ctx?.model?.reasoning) {
2224
+ const thinkingLevel = (ctx as any).getThinkingLevel?.() || "off";
2225
+ rightSide = thinkingLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`;
2226
+ }
2227
+ if (footerData.getAvailableProviderCount() > 1 && ctx?.model) {
2228
+ const withProvider = `(${ctx.model.provider}) ${rightSide}`;
2229
+ if (statsLeftWidth + 2 + visibleWidth(withProvider) <= width) {
2230
+ rightSide = withProvider;
2231
+ }
2232
+ }
2233
+
2234
+ const rightSideWidth = visibleWidth(rightSide);
2235
+ let statsLine: string;
2236
+ if (statsLeftWidth + 2 + rightSideWidth <= width) {
2237
+ const pad = " ".repeat(width - statsLeftWidth - rightSideWidth);
2238
+ statsLine = statsLeft + pad + rightSide;
2239
+ } else {
2240
+ const avail = width - statsLeftWidth - 2;
2241
+ if (avail > 0) {
2242
+ const truncRight = truncateToWidth(rightSide, avail, "");
2243
+ const truncRightWidth = visibleWidth(truncRight);
2244
+ const pad = " ".repeat(Math.max(0, width - statsLeftWidth - truncRightWidth));
2245
+ statsLine = statsLeft + pad + truncRight;
2246
+ } else {
2247
+ statsLine = statsLeft;
2248
+ }
2249
+ }
2250
+
2251
+ const dimStatsLeft = th.fg("dim", statsLeft);
2252
+ const remainder = statsLine.slice(statsLeft.length);
2253
+ const dimRemainder = th.fg("dim", remainder);
2254
+
2255
+ const lines = [pwdLine, dimStatsLeft + dimRemainder];
2256
+
2257
+ // ── Line 3 (optional): other extension statuses ──
2258
+ const extensionStatuses = footerData.getExtensionStatuses();
2259
+ // Filter out our own bg-shell status since it's already on line 1
2260
+ const otherStatuses = Array.from(extensionStatuses.entries())
2261
+ .filter(([key]) => key !== "bg-shell")
2262
+ .sort(([a], [b]) => a.localeCompare(b))
2263
+ .map(([, text]) => text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim());
2264
+ if (otherStatuses.length > 0) {
2265
+ lines.push(truncateToWidth(otherStatuses.join(" "), width, th.fg("dim", "...")));
2266
+ }
2267
+
2268
+ return lines;
2269
+ },
2270
+ invalidate() {},
2271
+ dispose() {
2272
+ branchUnsub();
2273
+ footerTui = null;
2274
+ },
2275
+ };
2276
+ });
2277
+ }
2278
+
2279
+ // Periodic maintenance
2280
+ const maintenanceInterval = setInterval(() => {
2281
+ pruneDeadProcesses();
2282
+ refreshWidget();
2283
+ // Persist manifest periodically
2284
+ if (latestCtx) {
2285
+ persistManifest(latestCtx.cwd);
2286
+ }
2287
+ }, 2000);
2288
+
2289
+ // Refresh widget after agent actions and session events
2290
+ for (const event of [
2291
+ "turn_end",
2292
+ "agent_end",
2293
+ "session_start",
2294
+ "session_switch",
2295
+ ] as const) {
2296
+ pi.on(event, async (_event: unknown, ctx: ExtensionContext) => {
2297
+ latestCtx = ctx;
2298
+ refreshWidget();
2299
+ });
2300
+ }
2301
+
2302
+ pi.on("tool_execution_end", async (_event, ctx) => {
2303
+ latestCtx = ctx;
2304
+ refreshWidget();
2305
+ });
2306
+
2307
+ // ── Ctrl+Alt+B shortcut ──────────────────────────────────────────────
2308
+
2309
+ pi.registerShortcut(Key.ctrlAlt("b"), {
2310
+ description: "Open background process manager",
2311
+ handler: async (ctx) => {
2312
+ latestCtx = ctx;
2313
+ await ctx.ui.custom<void>(
2314
+ (tui, theme, _kb, done) => {
2315
+ return new BgManagerOverlay(tui, theme, () => {
2316
+ done();
2317
+ refreshWidget();
2318
+ });
2319
+ },
2320
+ {
2321
+ overlay: true,
2322
+ overlayOptions: {
2323
+ width: "60%",
2324
+ minWidth: 50,
2325
+ maxHeight: "70%",
2326
+ anchor: "center",
2327
+ },
2328
+ },
2329
+ );
2330
+ },
2331
+ });
2332
+
2333
+ // Clean up on shutdown
2334
+ pi.on("session_shutdown", async () => {
2335
+ clearInterval(maintenanceInterval);
2336
+ if (latestCtx) persistManifest(latestCtx.cwd);
2337
+ cleanupAll();
2338
+ });
2339
+ }
2340
+
2341
+ // ── TUI: Process Manager Overlay ───────────────────────────────────────────
2342
+
2343
+ class BgManagerOverlay {
2344
+ private tui: { requestRender: () => void };
2345
+ private theme: Theme;
2346
+ private onClose: () => void;
2347
+ private selected = 0;
2348
+ private mode: "list" | "output" | "events" = "list";
2349
+ private viewingProcess: BgProcess | null = null;
2350
+ private scrollOffset = 0;
2351
+ private cachedWidth?: number;
2352
+ private cachedLines?: string[];
2353
+ private refreshTimer: ReturnType<typeof setInterval>;
2354
+
2355
+ constructor(
2356
+ tui: { requestRender: () => void },
2357
+ theme: Theme,
2358
+ onClose: () => void,
2359
+ ) {
2360
+ this.tui = tui;
2361
+ this.theme = theme;
2362
+ this.onClose = onClose;
2363
+ this.refreshTimer = setInterval(() => {
2364
+ this.invalidate();
2365
+ this.tui.requestRender();
2366
+ }, 1000);
2367
+ }
2368
+
2369
+ private getProcessList(): BgProcess[] {
2370
+ return Array.from(processes.values());
2371
+ }
2372
+
2373
+ selectAndView(index: number): void {
2374
+ const procs = this.getProcessList();
2375
+ if (index >= 0 && index < procs.length) {
2376
+ this.selected = index;
2377
+ this.viewingProcess = procs[index];
2378
+ this.mode = "output";
2379
+ this.scrollOffset = Math.max(0, procs[index].output.length - 20);
2380
+ }
2381
+ }
2382
+
2383
+ handleInput(data: string): void {
2384
+ if (this.mode === "output") {
2385
+ this.handleOutputInput(data);
2386
+ return;
2387
+ }
2388
+ if (this.mode === "events") {
2389
+ this.handleEventsInput(data);
2390
+ return;
2391
+ }
2392
+ this.handleListInput(data);
2393
+ }
2394
+
2395
+ private handleListInput(data: string): void {
2396
+ const procs = this.getProcessList();
2397
+
2398
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrlAlt("b"))) {
2399
+ clearInterval(this.refreshTimer);
2400
+ this.onClose();
2401
+ return;
2402
+ }
2403
+
2404
+ if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
2405
+ if (this.selected > 0) {
2406
+ this.selected--;
2407
+ this.invalidate();
2408
+ this.tui.requestRender();
2409
+ }
2410
+ return;
2411
+ }
2412
+
2413
+ if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
2414
+ if (this.selected < procs.length - 1) {
2415
+ this.selected++;
2416
+ this.invalidate();
2417
+ this.tui.requestRender();
2418
+ }
2419
+ return;
2420
+ }
2421
+
2422
+ if (matchesKey(data, Key.enter)) {
2423
+ const proc = procs[this.selected];
2424
+ if (proc) {
2425
+ this.viewingProcess = proc;
2426
+ this.mode = "output";
2427
+ this.scrollOffset = Math.max(0, proc.output.length - 20);
2428
+ this.invalidate();
2429
+ this.tui.requestRender();
2430
+ }
2431
+ return;
2432
+ }
2433
+
2434
+ // e = view events
2435
+ if (data === "e") {
2436
+ const proc = procs[this.selected];
2437
+ if (proc) {
2438
+ this.viewingProcess = proc;
2439
+ this.mode = "events";
2440
+ this.scrollOffset = Math.max(0, proc.events.length - 15);
2441
+ this.invalidate();
2442
+ this.tui.requestRender();
2443
+ }
2444
+ return;
2445
+ }
2446
+
2447
+ // r = restart
2448
+ if (data === "r") {
2449
+ const proc = procs[this.selected];
2450
+ if (proc) {
2451
+ restartProcess(proc.id).then(() => {
2452
+ this.invalidate();
2453
+ this.tui.requestRender();
2454
+ });
2455
+ }
2456
+ return;
2457
+ }
2458
+
2459
+ // x or d = kill selected
2460
+ if (data === "x" || data === "d") {
2461
+ const proc = procs[this.selected];
2462
+ if (proc && proc.alive) {
2463
+ killProcess(proc.id, "SIGTERM");
2464
+ setTimeout(() => {
2465
+ if (proc.alive) killProcess(proc.id, "SIGKILL");
2466
+ this.invalidate();
2467
+ this.tui.requestRender();
2468
+ }, 300);
2469
+ }
2470
+ return;
2471
+ }
2472
+
2473
+ // X or D = kill all
2474
+ if (data === "X" || data === "D") {
2475
+ cleanupAll();
2476
+ this.selected = 0;
2477
+ this.invalidate();
2478
+ this.tui.requestRender();
2479
+ return;
2480
+ }
2481
+ }
2482
+
2483
+ private handleOutputInput(data: string): void {
2484
+ if (matchesKey(data, Key.escape) || matchesKey(data, "q")) {
2485
+ this.mode = "list";
2486
+ this.viewingProcess = null;
2487
+ this.scrollOffset = 0;
2488
+ this.invalidate();
2489
+ this.tui.requestRender();
2490
+ return;
2491
+ }
2492
+
2493
+ // Tab to switch to events view
2494
+ if (matchesKey(data, Key.tab)) {
2495
+ this.mode = "events";
2496
+ if (this.viewingProcess) {
2497
+ this.scrollOffset = Math.max(0, this.viewingProcess.events.length - 15);
2498
+ }
2499
+ this.invalidate();
2500
+ this.tui.requestRender();
2501
+ return;
2502
+ }
2503
+
2504
+ if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
2505
+ if (this.viewingProcess) {
2506
+ const total = this.viewingProcess.output.length;
2507
+ this.scrollOffset = Math.min(this.scrollOffset + 5, Math.max(0, total - 20));
2508
+ }
2509
+ this.invalidate();
2510
+ this.tui.requestRender();
2511
+ return;
2512
+ }
2513
+
2514
+ if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
2515
+ this.scrollOffset = Math.max(0, this.scrollOffset - 5);
2516
+ this.invalidate();
2517
+ this.tui.requestRender();
2518
+ return;
2519
+ }
2520
+
2521
+ if (data === "G") {
2522
+ if (this.viewingProcess) {
2523
+ const total = this.viewingProcess.output.length;
2524
+ this.scrollOffset = Math.max(0, total - 20);
2525
+ }
2526
+ this.invalidate();
2527
+ this.tui.requestRender();
2528
+ return;
2529
+ }
2530
+
2531
+ if (data === "g") {
2532
+ this.scrollOffset = 0;
2533
+ this.invalidate();
2534
+ this.tui.requestRender();
2535
+ return;
2536
+ }
2537
+ }
2538
+
2539
+ private handleEventsInput(data: string): void {
2540
+ if (matchesKey(data, Key.escape) || matchesKey(data, "q")) {
2541
+ this.mode = "list";
2542
+ this.viewingProcess = null;
2543
+ this.scrollOffset = 0;
2544
+ this.invalidate();
2545
+ this.tui.requestRender();
2546
+ return;
2547
+ }
2548
+
2549
+ // Tab to switch back to output view
2550
+ if (matchesKey(data, Key.tab)) {
2551
+ this.mode = "output";
2552
+ if (this.viewingProcess) {
2553
+ this.scrollOffset = Math.max(0, this.viewingProcess.output.length - 20);
2554
+ }
2555
+ this.invalidate();
2556
+ this.tui.requestRender();
2557
+ return;
2558
+ }
2559
+
2560
+ if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
2561
+ if (this.viewingProcess) {
2562
+ this.scrollOffset = Math.min(this.scrollOffset + 3, Math.max(0, this.viewingProcess.events.length - 10));
2563
+ }
2564
+ this.invalidate();
2565
+ this.tui.requestRender();
2566
+ return;
2567
+ }
2568
+
2569
+ if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
2570
+ this.scrollOffset = Math.max(0, this.scrollOffset - 3);
2571
+ this.invalidate();
2572
+ this.tui.requestRender();
2573
+ return;
2574
+ }
2575
+ }
2576
+
2577
+ render(width: number): string[] {
2578
+ if (this.cachedLines && this.cachedWidth === width) {
2579
+ return this.cachedLines;
2580
+ }
2581
+
2582
+ let lines: string[];
2583
+ if (this.mode === "events") {
2584
+ lines = this.renderEvents(width);
2585
+ } else if (this.mode === "output") {
2586
+ lines = this.renderOutput(width);
2587
+ } else {
2588
+ lines = this.renderList(width);
2589
+ }
2590
+
2591
+ this.cachedWidth = width;
2592
+ this.cachedLines = lines;
2593
+ return lines;
2594
+ }
2595
+
2596
+ private box(inner: string[], width: number): string[] {
2597
+ const th = this.theme;
2598
+ const bdr = (s: string) => th.fg("borderMuted", s);
2599
+ const iw = width - 4;
2600
+ const lines: string[] = [];
2601
+
2602
+ lines.push(bdr("╭" + "─".repeat(width - 2) + "╮"));
2603
+ for (const line of inner) {
2604
+ const truncated = truncateToWidth(line, iw);
2605
+ const pad = Math.max(0, iw - visibleWidth(truncated));
2606
+ lines.push(bdr("│") + " " + truncated + " ".repeat(pad) + " " + bdr("│"));
2607
+ }
2608
+ lines.push(bdr("╰" + "─".repeat(width - 2) + "╯"));
2609
+ return lines;
2610
+ }
2611
+
2612
+ private renderList(width: number): string[] {
2613
+ const th = this.theme;
2614
+ const procs = this.getProcessList();
2615
+ const inner: string[] = [];
2616
+
2617
+ if (procs.length === 0) {
2618
+ inner.push(th.fg("dim", "No background processes."));
2619
+ inner.push("");
2620
+ inner.push(th.fg("dim", "esc close"));
2621
+ return this.box(inner, width);
2622
+ }
2623
+
2624
+ inner.push(th.fg("dim", "Background Processes"));
2625
+ inner.push("");
2626
+
2627
+ for (let i = 0; i < procs.length; i++) {
2628
+ const p = procs[i];
2629
+ const sel = i === this.selected;
2630
+ const pointer = sel ? th.fg("accent", "▸ ") : " ";
2631
+
2632
+ const statusIcon = p.alive
2633
+ ? (p.status === "ready" ? th.fg("success", "●")
2634
+ : p.status === "error" ? th.fg("error", "●")
2635
+ : th.fg("warning", "●"))
2636
+ : th.fg("dim", "○");
2637
+
2638
+ const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt));
2639
+ const name = sel ? th.fg("text", p.label) : th.fg("muted", p.label);
2640
+ const typeTag = th.fg("dim", `[${p.processType}]`);
2641
+ const portInfo = p.ports.length > 0 ? th.fg("dim", ` :${p.ports.join(",")}`) : "";
2642
+ const errBadge = p.recentErrors.length > 0 ? th.fg("error", ` ⚠${p.recentErrors.length}`) : "";
2643
+ const groupTag = p.group ? th.fg("dim", ` {${p.group}}`) : "";
2644
+ const restartBadge = p.restartCount > 0 ? th.fg("warning", ` ↻${p.restartCount}`) : "";
2645
+
2646
+ const status = p.alive ? "" : " " + th.fg("dim", `exit ${p.exitCode}`);
2647
+
2648
+ inner.push(`${pointer}${statusIcon} ${name} ${typeTag} ${uptime}${portInfo}${errBadge}${groupTag}${restartBadge}${status}`);
2649
+ }
2650
+
2651
+ inner.push("");
2652
+ inner.push(th.fg("dim", "↑↓ select · enter output · e events · r restart · x kill · esc close"));
2653
+
2654
+ return this.box(inner, width);
2655
+ }
2656
+
2657
+ private renderOutput(width: number): string[] {
2658
+ const th = this.theme;
2659
+ const p = this.viewingProcess;
2660
+ if (!p) return [""];
2661
+ const inner: string[] = [];
2662
+
2663
+ const statusIcon = p.alive
2664
+ ? (p.status === "ready" ? th.fg("success", "●")
2665
+ : p.status === "error" ? th.fg("error", "●")
2666
+ : th.fg("warning", "●"))
2667
+ : th.fg("dim", "○");
2668
+ const name = th.fg("muted", p.label);
2669
+ const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt));
2670
+ const typeTag = th.fg("dim", `[${p.processType}]`);
2671
+ const portInfo = p.ports.length > 0 ? th.fg("dim", ` :${p.ports.join(",")}`) : "";
2672
+ const tabIndicator = th.fg("accent", "[Output]") + " " + th.fg("dim", "Events");
2673
+
2674
+ inner.push(`${statusIcon} ${name} ${typeTag} ${uptime}${portInfo} ${tabIndicator}`);
2675
+ inner.push("");
2676
+
2677
+ // Unified buffer is already chronologically interleaved
2678
+ const allOutput = p.output;
2679
+
2680
+ const maxVisible = 18;
2681
+ const visible = allOutput.slice(this.scrollOffset, this.scrollOffset + maxVisible);
2682
+
2683
+ if (allOutput.length === 0) {
2684
+ inner.push(th.fg("dim", "(no output)"));
2685
+ } else {
2686
+ for (const entry of visible) {
2687
+ const isError = ERROR_PATTERNS.some(pat => pat.test(entry.line));
2688
+ const isWarning = !isError && WARNING_PATTERNS.some(pat => pat.test(entry.line));
2689
+ const prefix = entry.stream === "stderr" ? th.fg("error", "⚠ ") : "";
2690
+ const color = isError ? "error" : isWarning ? "warning" : "dim";
2691
+ inner.push(prefix + th.fg(color, entry.line));
2692
+ }
2693
+
2694
+ if (allOutput.length > maxVisible) {
2695
+ inner.push("");
2696
+ const pos = `${this.scrollOffset + 1}–${Math.min(this.scrollOffset + maxVisible, allOutput.length)} of ${allOutput.length}`;
2697
+ inner.push(th.fg("dim", pos));
2698
+ }
2699
+ }
2700
+
2701
+ inner.push("");
2702
+ inner.push(th.fg("dim", "↑↓ scroll · g/G top/end · tab events · q back"));
2703
+
2704
+ return this.box(inner, width);
2705
+ }
2706
+
2707
+ private renderEvents(width: number): string[] {
2708
+ const th = this.theme;
2709
+ const p = this.viewingProcess;
2710
+ if (!p) return [""];
2711
+ const inner: string[] = [];
2712
+
2713
+ const statusIcon = p.alive
2714
+ ? (p.status === "ready" ? th.fg("success", "●")
2715
+ : p.status === "error" ? th.fg("error", "●")
2716
+ : th.fg("warning", "●"))
2717
+ : th.fg("dim", "○");
2718
+ const name = th.fg("muted", p.label);
2719
+ const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt));
2720
+ const tabIndicator = th.fg("dim", "Output") + " " + th.fg("accent", "[Events]");
2721
+
2722
+ inner.push(`${statusIcon} ${name} ${uptime} ${tabIndicator}`);
2723
+ inner.push("");
2724
+
2725
+ if (p.events.length === 0) {
2726
+ inner.push(th.fg("dim", "(no events)"));
2727
+ } else {
2728
+ const maxVisible = 15;
2729
+ const visible = p.events.slice(this.scrollOffset, this.scrollOffset + maxVisible);
2730
+
2731
+ for (const ev of visible) {
2732
+ const time = th.fg("dim", formatTimeAgo(ev.timestamp));
2733
+ const typeColor = ev.type === "crashed" || ev.type === "error_detected" ? "error"
2734
+ : ev.type === "ready" || ev.type === "recovered" ? "success"
2735
+ : ev.type === "port_open" ? "accent"
2736
+ : "dim";
2737
+ const typeLabel = th.fg(typeColor, ev.type);
2738
+ inner.push(`${time} ${typeLabel}`);
2739
+ inner.push(` ${th.fg("dim", ev.detail.slice(0, 80))}`);
2740
+ }
2741
+
2742
+ if (p.events.length > maxVisible) {
2743
+ inner.push("");
2744
+ inner.push(th.fg("dim", `${this.scrollOffset + 1}–${Math.min(this.scrollOffset + maxVisible, p.events.length)} of ${p.events.length} events`));
2745
+ }
2746
+ }
2747
+
2748
+ inner.push("");
2749
+ inner.push(th.fg("dim", "↑↓ scroll · tab output · q back"));
2750
+
2751
+ return this.box(inner, width);
2752
+ }
2753
+
2754
+ invalidate(): void {
2755
+ this.cachedWidth = undefined;
2756
+ this.cachedLines = undefined;
2757
+ }
2758
+ }