gsd-pi 2.10.12 → 2.12.0

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