@ilya-lesikov/pi-pi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/3p/pi-ask-user/index.ts +1835 -0
  2. package/3p/pi-ask-user/package.json +50 -0
  3. package/3p/pi-ask-user/single-select-layout.ts +203 -0
  4. package/3p/pi-lsp/extensions/lsp/client.ts +542 -0
  5. package/3p/pi-lsp/extensions/lsp/config.ts +135 -0
  6. package/3p/pi-lsp/extensions/lsp/effects/command.ts +42 -0
  7. package/3p/pi-lsp/extensions/lsp/effects/filesystem.ts +50 -0
  8. package/3p/pi-lsp/extensions/lsp/effects/runtime.ts +21 -0
  9. package/3p/pi-lsp/extensions/lsp/errors.ts +113 -0
  10. package/3p/pi-lsp/extensions/lsp/formatting.ts +315 -0
  11. package/3p/pi-lsp/extensions/lsp/index.ts +231 -0
  12. package/3p/pi-lsp/extensions/lsp/protocol.ts +209 -0
  13. package/3p/pi-lsp/extensions/lsp/retry.ts +39 -0
  14. package/3p/pi-lsp/extensions/lsp/tools/programs.ts +293 -0
  15. package/3p/pi-lsp/extensions/lsp/tools.ts +89 -0
  16. package/3p/pi-lsp/extensions/lsp/types.ts +243 -0
  17. package/3p/pi-lsp/package.json +54 -0
  18. package/3p/pi-plannotator/AGENTS.md +583 -0
  19. package/3p/pi-plannotator/apps/pi-extension/README.md +228 -0
  20. package/3p/pi-plannotator/apps/pi-extension/assistant-message.ts +128 -0
  21. package/3p/pi-plannotator/apps/pi-extension/config.test.ts +166 -0
  22. package/3p/pi-plannotator/apps/pi-extension/config.ts +318 -0
  23. package/3p/pi-plannotator/apps/pi-extension/current-pi-session.ts +147 -0
  24. package/3p/pi-plannotator/apps/pi-extension/index.ts +1279 -0
  25. package/3p/pi-plannotator/apps/pi-extension/package.json +56 -0
  26. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.test.ts +13 -0
  27. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +616 -0
  28. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +342 -0
  29. package/3p/pi-plannotator/apps/pi-extension/plannotator.json +12 -0
  30. package/3p/pi-plannotator/apps/pi-extension/server/agent-jobs.ts +515 -0
  31. package/3p/pi-plannotator/apps/pi-extension/server/ai-runtime.ts +169 -0
  32. package/3p/pi-plannotator/apps/pi-extension/server/annotations.ts +85 -0
  33. package/3p/pi-plannotator/apps/pi-extension/server/external-annotations.ts +189 -0
  34. package/3p/pi-plannotator/apps/pi-extension/server/handlers.ts +210 -0
  35. package/3p/pi-plannotator/apps/pi-extension/server/helpers.ts +78 -0
  36. package/3p/pi-plannotator/apps/pi-extension/server/ide.ts +46 -0
  37. package/3p/pi-plannotator/apps/pi-extension/server/integrations.ts +195 -0
  38. package/3p/pi-plannotator/apps/pi-extension/server/network.test.ts +158 -0
  39. package/3p/pi-plannotator/apps/pi-extension/server/network.ts +268 -0
  40. package/3p/pi-plannotator/apps/pi-extension/server/pr.ts +126 -0
  41. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +64 -0
  42. package/3p/pi-plannotator/apps/pi-extension/server/reference.ts +362 -0
  43. package/3p/pi-plannotator/apps/pi-extension/server/serverAnnotate.ts +199 -0
  44. package/3p/pi-plannotator/apps/pi-extension/server/serverPlan.ts +505 -0
  45. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +1229 -0
  46. package/3p/pi-plannotator/apps/pi-extension/server/vcs.ts +121 -0
  47. package/3p/pi-plannotator/apps/pi-extension/server.test.ts +966 -0
  48. package/3p/pi-plannotator/apps/pi-extension/server.ts +44 -0
  49. package/3p/pi-plannotator/apps/pi-extension/tool-scope.test.ts +88 -0
  50. package/3p/pi-plannotator/apps/pi-extension/tool-scope.ts +39 -0
  51. package/3p/pi-plannotator/apps/pi-extension/tsconfig.json +16 -0
  52. package/3p/pi-plannotator/apps/pi-extension/vendor.sh +48 -0
  53. package/3p/pi-plannotator/package.json +53 -0
  54. package/3p/pi-plannotator/packages/ai/ai.test.ts +1407 -0
  55. package/3p/pi-plannotator/packages/ai/base-session.ts +94 -0
  56. package/3p/pi-plannotator/packages/ai/context.ts +250 -0
  57. package/3p/pi-plannotator/packages/ai/endpoints.ts +326 -0
  58. package/3p/pi-plannotator/packages/ai/index.ts +105 -0
  59. package/3p/pi-plannotator/packages/ai/package.json +21 -0
  60. package/3p/pi-plannotator/packages/ai/provider.ts +103 -0
  61. package/3p/pi-plannotator/packages/ai/providers/claude-agent-sdk.ts +447 -0
  62. package/3p/pi-plannotator/packages/ai/providers/codex-sdk.ts +430 -0
  63. package/3p/pi-plannotator/packages/ai/providers/command-path.ts +115 -0
  64. package/3p/pi-plannotator/packages/ai/providers/opencode-sdk.ts +546 -0
  65. package/3p/pi-plannotator/packages/ai/providers/pi-events.ts +110 -0
  66. package/3p/pi-plannotator/packages/ai/providers/pi-sdk-node.ts +425 -0
  67. package/3p/pi-plannotator/packages/ai/providers/pi-sdk.ts +469 -0
  68. package/3p/pi-plannotator/packages/ai/session-manager.ts +195 -0
  69. package/3p/pi-plannotator/packages/ai/tsconfig.json +15 -0
  70. package/3p/pi-plannotator/packages/ai/types.ts +379 -0
  71. package/3p/pi-plannotator/packages/server/agent-jobs.ts +538 -0
  72. package/3p/pi-plannotator/packages/server/agent-review-message.test.ts +135 -0
  73. package/3p/pi-plannotator/packages/server/agent-review-message.ts +243 -0
  74. package/3p/pi-plannotator/packages/server/ai-runtime.ts +108 -0
  75. package/3p/pi-plannotator/packages/server/annotate.ts +401 -0
  76. package/3p/pi-plannotator/packages/server/browser.test.ts +87 -0
  77. package/3p/pi-plannotator/packages/server/browser.ts +245 -0
  78. package/3p/pi-plannotator/packages/server/claude-review.ts +352 -0
  79. package/3p/pi-plannotator/packages/server/code-nav.ts +73 -0
  80. package/3p/pi-plannotator/packages/server/codex-review-schema.json +41 -0
  81. package/3p/pi-plannotator/packages/server/codex-review.ts +330 -0
  82. package/3p/pi-plannotator/packages/server/config.ts +8 -0
  83. package/3p/pi-plannotator/packages/server/draft.ts +1 -0
  84. package/3p/pi-plannotator/packages/server/editor-annotations.ts +76 -0
  85. package/3p/pi-plannotator/packages/server/external-annotations.test.ts +18 -0
  86. package/3p/pi-plannotator/packages/server/external-annotations.ts +207 -0
  87. package/3p/pi-plannotator/packages/server/git.ts +141 -0
  88. package/3p/pi-plannotator/packages/server/goal-setup.test.ts +55 -0
  89. package/3p/pi-plannotator/packages/server/goal-setup.ts +248 -0
  90. package/3p/pi-plannotator/packages/server/ide.ts +43 -0
  91. package/3p/pi-plannotator/packages/server/image.test.ts +63 -0
  92. package/3p/pi-plannotator/packages/server/image.ts +66 -0
  93. package/3p/pi-plannotator/packages/server/index.ts +648 -0
  94. package/3p/pi-plannotator/packages/server/integrations.test.ts +172 -0
  95. package/3p/pi-plannotator/packages/server/integrations.ts +214 -0
  96. package/3p/pi-plannotator/packages/server/jj.test.ts +69 -0
  97. package/3p/pi-plannotator/packages/server/jj.ts +86 -0
  98. package/3p/pi-plannotator/packages/server/p4.ts +417 -0
  99. package/3p/pi-plannotator/packages/server/package.json +40 -0
  100. package/3p/pi-plannotator/packages/server/path-utils.ts +18 -0
  101. package/3p/pi-plannotator/packages/server/pr.ts +146 -0
  102. package/3p/pi-plannotator/packages/server/project.test.ts +115 -0
  103. package/3p/pi-plannotator/packages/server/project.ts +45 -0
  104. package/3p/pi-plannotator/packages/server/reference-handlers.ts +404 -0
  105. package/3p/pi-plannotator/packages/server/remote.test.ts +150 -0
  106. package/3p/pi-plannotator/packages/server/remote.ts +74 -0
  107. package/3p/pi-plannotator/packages/server/repo.ts +80 -0
  108. package/3p/pi-plannotator/packages/server/resolve-file.test.ts +310 -0
  109. package/3p/pi-plannotator/packages/server/review-workspace.test.ts +1032 -0
  110. package/3p/pi-plannotator/packages/server/review-workspace.ts +48 -0
  111. package/3p/pi-plannotator/packages/server/review.ts +1318 -0
  112. package/3p/pi-plannotator/packages/server/sessions.ts +111 -0
  113. package/3p/pi-plannotator/packages/server/share-url.ts +53 -0
  114. package/3p/pi-plannotator/packages/server/shared-handlers.test.ts +43 -0
  115. package/3p/pi-plannotator/packages/server/shared-handlers.ts +177 -0
  116. package/3p/pi-plannotator/packages/server/storage.test.ts +176 -0
  117. package/3p/pi-plannotator/packages/server/storage.ts +17 -0
  118. package/3p/pi-plannotator/packages/server/tour/tour-review.test.ts +146 -0
  119. package/3p/pi-plannotator/packages/server/tour/tour-review.ts +604 -0
  120. package/3p/pi-plannotator/packages/server/tsconfig.json +15 -0
  121. package/3p/pi-plannotator/packages/server/vcs.test.ts +48 -0
  122. package/3p/pi-plannotator/packages/server/vcs.ts +80 -0
  123. package/3p/pi-plannotator/packages/shared/agent-jobs.ts +132 -0
  124. package/3p/pi-plannotator/packages/shared/agents.ts +53 -0
  125. package/3p/pi-plannotator/packages/shared/annotate-args.test.ts +386 -0
  126. package/3p/pi-plannotator/packages/shared/annotate-args.ts +107 -0
  127. package/3p/pi-plannotator/packages/shared/at-reference.test.ts +99 -0
  128. package/3p/pi-plannotator/packages/shared/at-reference.ts +52 -0
  129. package/3p/pi-plannotator/packages/shared/checklist.ts +52 -0
  130. package/3p/pi-plannotator/packages/shared/code-file.test.ts +112 -0
  131. package/3p/pi-plannotator/packages/shared/code-file.ts +41 -0
  132. package/3p/pi-plannotator/packages/shared/code-nav.test.ts +515 -0
  133. package/3p/pi-plannotator/packages/shared/code-nav.ts +436 -0
  134. package/3p/pi-plannotator/packages/shared/compress.ts +51 -0
  135. package/3p/pi-plannotator/packages/shared/config.ts +262 -0
  136. package/3p/pi-plannotator/packages/shared/crypto.test.ts +172 -0
  137. package/3p/pi-plannotator/packages/shared/crypto.ts +97 -0
  138. package/3p/pi-plannotator/packages/shared/data-dir.ts +42 -0
  139. package/3p/pi-plannotator/packages/shared/diff-paths.test.ts +30 -0
  140. package/3p/pi-plannotator/packages/shared/diff-paths.ts +137 -0
  141. package/3p/pi-plannotator/packages/shared/draft.ts +64 -0
  142. package/3p/pi-plannotator/packages/shared/external-annotation.ts +397 -0
  143. package/3p/pi-plannotator/packages/shared/extract-code-paths.test.ts +59 -0
  144. package/3p/pi-plannotator/packages/shared/extract-code-paths.ts +66 -0
  145. package/3p/pi-plannotator/packages/shared/favicon.ts +5 -0
  146. package/3p/pi-plannotator/packages/shared/feedback-templates.test.ts +65 -0
  147. package/3p/pi-plannotator/packages/shared/feedback-templates.ts +29 -0
  148. package/3p/pi-plannotator/packages/shared/goal-setup.test.ts +231 -0
  149. package/3p/pi-plannotator/packages/shared/goal-setup.ts +336 -0
  150. package/3p/pi-plannotator/packages/shared/html-to-markdown.test.ts +62 -0
  151. package/3p/pi-plannotator/packages/shared/html-to-markdown.ts +32 -0
  152. package/3p/pi-plannotator/packages/shared/improvement-hooks.test.ts +135 -0
  153. package/3p/pi-plannotator/packages/shared/improvement-hooks.ts +115 -0
  154. package/3p/pi-plannotator/packages/shared/integrations-common.ts +243 -0
  155. package/3p/pi-plannotator/packages/shared/jj-core.test.ts +236 -0
  156. package/3p/pi-plannotator/packages/shared/jj-core.ts +433 -0
  157. package/3p/pi-plannotator/packages/shared/package.json +55 -0
  158. package/3p/pi-plannotator/packages/shared/pfm-reminder.test.ts +88 -0
  159. package/3p/pi-plannotator/packages/shared/pfm-reminder.ts +80 -0
  160. package/3p/pi-plannotator/packages/shared/pr-github.ts +661 -0
  161. package/3p/pi-plannotator/packages/shared/pr-gitlab.test.ts +202 -0
  162. package/3p/pi-plannotator/packages/shared/pr-gitlab.ts +620 -0
  163. package/3p/pi-plannotator/packages/shared/pr-provider.test.ts +266 -0
  164. package/3p/pi-plannotator/packages/shared/pr-provider.ts +123 -0
  165. package/3p/pi-plannotator/packages/shared/pr-stack.test.ts +104 -0
  166. package/3p/pi-plannotator/packages/shared/pr-stack.ts +194 -0
  167. package/3p/pi-plannotator/packages/shared/pr-types.ts +326 -0
  168. package/3p/pi-plannotator/packages/shared/project.ts +71 -0
  169. package/3p/pi-plannotator/packages/shared/prompts-integration.test.ts +421 -0
  170. package/3p/pi-plannotator/packages/shared/prompts.test.ts +504 -0
  171. package/3p/pi-plannotator/packages/shared/prompts.ts +247 -0
  172. package/3p/pi-plannotator/packages/shared/reference-common.ts +87 -0
  173. package/3p/pi-plannotator/packages/shared/repo.ts +71 -0
  174. package/3p/pi-plannotator/packages/shared/resolve-file.test.ts +113 -0
  175. package/3p/pi-plannotator/packages/shared/resolve-file.ts +509 -0
  176. package/3p/pi-plannotator/packages/shared/review-args.test.ts +64 -0
  177. package/3p/pi-plannotator/packages/shared/review-args.ts +85 -0
  178. package/3p/pi-plannotator/packages/shared/review-core.test.ts +286 -0
  179. package/3p/pi-plannotator/packages/shared/review-core.ts +895 -0
  180. package/3p/pi-plannotator/packages/shared/review-workspace-node.ts +230 -0
  181. package/3p/pi-plannotator/packages/shared/review-workspace.ts +436 -0
  182. package/3p/pi-plannotator/packages/shared/semantic-diff-types.ts +76 -0
  183. package/3p/pi-plannotator/packages/shared/semantic-diff.test.ts +322 -0
  184. package/3p/pi-plannotator/packages/shared/semantic-diff.ts +520 -0
  185. package/3p/pi-plannotator/packages/shared/storage.ts +378 -0
  186. package/3p/pi-plannotator/packages/shared/tour.ts +61 -0
  187. package/3p/pi-plannotator/packages/shared/tsconfig.json +15 -0
  188. package/3p/pi-plannotator/packages/shared/types.ts +29 -0
  189. package/3p/pi-plannotator/packages/shared/url-to-markdown.test.ts +177 -0
  190. package/3p/pi-plannotator/packages/shared/url-to-markdown.ts +351 -0
  191. package/3p/pi-plannotator/packages/shared/vcs-core.test.ts +332 -0
  192. package/3p/pi-plannotator/packages/shared/vcs-core.ts +482 -0
  193. package/3p/pi-plannotator/packages/shared/worktree-pool.test.ts +162 -0
  194. package/3p/pi-plannotator/packages/shared/worktree-pool.ts +103 -0
  195. package/3p/pi-plannotator/packages/shared/worktree.ts +119 -0
  196. package/3p/pi-subagents/package.json +50 -0
  197. package/3p/pi-subagents/src/agent-manager.ts +413 -0
  198. package/3p/pi-subagents/src/agent-runner.ts +502 -0
  199. package/3p/pi-subagents/src/agent-types.ts +248 -0
  200. package/3p/pi-subagents/src/context.ts +58 -0
  201. package/3p/pi-subagents/src/cross-extension-rpc.ts +135 -0
  202. package/3p/pi-subagents/src/custom-agents.ts +137 -0
  203. package/3p/pi-subagents/src/default-agents.ts +144 -0
  204. package/3p/pi-subagents/src/env.ts +33 -0
  205. package/3p/pi-subagents/src/group-join.ts +141 -0
  206. package/3p/pi-subagents/src/index.ts +1811 -0
  207. package/3p/pi-subagents/src/invocation-config.ts +40 -0
  208. package/3p/pi-subagents/src/memory.ts +165 -0
  209. package/3p/pi-subagents/src/model-resolver.ts +81 -0
  210. package/3p/pi-subagents/src/output-file.ts +77 -0
  211. package/3p/pi-subagents/src/prompts.ts +85 -0
  212. package/3p/pi-subagents/src/skill-loader.ts +79 -0
  213. package/3p/pi-subagents/src/types.ts +111 -0
  214. package/3p/pi-subagents/src/ui/agent-widget.ts +496 -0
  215. package/3p/pi-subagents/src/ui/conversation-viewer.ts +250 -0
  216. package/3p/pi-subagents/src/worktree.ts +162 -0
  217. package/3p/pi-tasks/package.json +52 -0
  218. package/3p/pi-tasks/src/auto-clear.ts +91 -0
  219. package/3p/pi-tasks/src/index.ts +1150 -0
  220. package/3p/pi-tasks/src/process-tracker.ts +140 -0
  221. package/3p/pi-tasks/src/reminder-cadence.ts +90 -0
  222. package/3p/pi-tasks/src/task-store.ts +324 -0
  223. package/3p/pi-tasks/src/tasks-config.ts +27 -0
  224. package/3p/pi-tasks/src/types.ts +40 -0
  225. package/3p/pi-tasks/src/ui/settings-menu.ts +152 -0
  226. package/3p/pi-tasks/src/ui/task-widget.ts +296 -0
  227. package/AGENTS.md +28 -0
  228. package/LICENSE +201 -0
  229. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +77 -0
  230. package/extensions/orchestrator/agents/code-reviewer.ts +89 -0
  231. package/extensions/orchestrator/agents/explore.ts +33 -0
  232. package/extensions/orchestrator/agents/librarian.ts +43 -0
  233. package/extensions/orchestrator/agents/plan-reviewer.ts +75 -0
  234. package/extensions/orchestrator/agents/planner.ts +61 -0
  235. package/extensions/orchestrator/agents/registry.ts +156 -0
  236. package/extensions/orchestrator/agents/task.ts +50 -0
  237. package/extensions/orchestrator/agents/tool-routing.ts +84 -0
  238. package/extensions/orchestrator/ast-search.ts +85 -0
  239. package/extensions/orchestrator/cbm.ts +330 -0
  240. package/extensions/orchestrator/command-handlers.test.ts +163 -0
  241. package/extensions/orchestrator/command-handlers.ts +116 -0
  242. package/extensions/orchestrator/commands.test.ts +81 -0
  243. package/extensions/orchestrator/commands.ts +75 -0
  244. package/extensions/orchestrator/config.test.ts +221 -0
  245. package/extensions/orchestrator/config.ts +230 -0
  246. package/extensions/orchestrator/context.test.ts +293 -0
  247. package/extensions/orchestrator/context.ts +206 -0
  248. package/extensions/orchestrator/event-handlers.test.ts +190 -0
  249. package/extensions/orchestrator/event-handlers.ts +1416 -0
  250. package/extensions/orchestrator/exa.ts +104 -0
  251. package/extensions/orchestrator/flant-infra.ts +486 -0
  252. package/extensions/orchestrator/index.ts +80 -0
  253. package/extensions/orchestrator/integration.test.ts +1214 -0
  254. package/extensions/orchestrator/orchestrator.test.ts +252 -0
  255. package/extensions/orchestrator/orchestrator.ts +538 -0
  256. package/extensions/orchestrator/phases/brainstorm.test.ts +15 -0
  257. package/extensions/orchestrator/phases/brainstorm.ts +273 -0
  258. package/extensions/orchestrator/phases/implementation.ts +40 -0
  259. package/extensions/orchestrator/phases/machine.test.ts +293 -0
  260. package/extensions/orchestrator/phases/machine.ts +209 -0
  261. package/extensions/orchestrator/phases/planning.ts +255 -0
  262. package/extensions/orchestrator/phases/review.ts +193 -0
  263. package/extensions/orchestrator/plannotator.ts +56 -0
  264. package/extensions/orchestrator/pp-menu.ts +866 -0
  265. package/extensions/orchestrator/state.test.ts +343 -0
  266. package/extensions/orchestrator/state.ts +237 -0
  267. package/extensions/orchestrator/validate-artifacts.test.ts +88 -0
  268. package/extensions/orchestrator/validate-artifacts.ts +272 -0
  269. package/extensions/orchestrator/vendor.d.ts +26 -0
  270. package/package.json +73 -0
  271. package/scripts/postinstall.sh +18 -0
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Agent Jobs — Bun server handler.
3
+ *
4
+ * Manages background agent processes (spawn, monitor, kill) and exposes
5
+ * HTTP routes + SSE broadcasting for job status updates.
6
+ *
7
+ * Mirrors packages/server/external-annotations.ts in structure.
8
+ * Server-agnostic: takes a mode, server URL getter, and cwd getter.
9
+ */
10
+
11
+ import { formatClaudeLogEvent } from "./claude-review";
12
+ import {
13
+ type AgentJobInfo,
14
+ type AgentJobEvent,
15
+ type AgentCapability,
16
+ type AgentCapabilities,
17
+ isTerminalStatus,
18
+ jobSource,
19
+ serializeAgentSSEEvent,
20
+ AGENT_HEARTBEAT_COMMENT,
21
+ AGENT_HEARTBEAT_INTERVAL_MS,
22
+ } from "@plannotator/shared/agent-jobs";
23
+
24
+ export type { AgentJobInfo, AgentJobEvent, AgentCapabilities } from "@plannotator/shared/agent-jobs";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Handler interface
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export interface AgentJobHandler {
31
+ handle: (
32
+ req: Request,
33
+ url: URL,
34
+ options?: { disableIdleTimeout?: () => void },
35
+ ) => Promise<Response | null>;
36
+ /** Kill all running jobs — call on server shutdown. */
37
+ killAll: () => void;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Route prefixes
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const BASE = "/api/agents";
45
+ const JOBS = `${BASE}/jobs`;
46
+ const JOBS_STREAM = `${JOBS}/stream`;
47
+ const CAPABILITIES = `${BASE}/capabilities`;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Factory
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export interface AgentJobHandlerOptions {
54
+ /** Which server mode this handler is mounted in. */
55
+ mode: "plan" | "review" | "annotate";
56
+ /** Returns the server's base URL (e.g., "http://localhost:12345"). Late-bound. */
57
+ getServerUrl: () => string;
58
+ /** Returns the working directory for spawned processes. */
59
+ getCwd: () => string;
60
+ /**
61
+ * Build the command server-side for a given provider.
62
+ * Return an object with the command to spawn (and optional output path for result ingestion).
63
+ * Return null to reject or fall through to frontend-supplied command.
64
+ */
65
+ buildCommand?: (provider: string, config?: Record<string, unknown>) => Promise<{
66
+ command: string[];
67
+ outputPath?: string;
68
+ captureStdout?: boolean;
69
+ stdinPrompt?: string;
70
+ cwd?: string;
71
+ label?: string;
72
+ /** The full prompt text for display in the detail panel. */
73
+ prompt?: string;
74
+ /** Underlying engine used (e.g., "claude" or "codex"). Stored on AgentJobInfo for UI display. */
75
+ engine?: string;
76
+ /** Model used (e.g., "sonnet", "opus"). Stored on AgentJobInfo for UI display. */
77
+ model?: string;
78
+ /** Claude --effort level. */
79
+ effort?: string;
80
+ /** Codex reasoning effort level. */
81
+ reasoningEffort?: string;
82
+ /** Whether Codex fast mode was enabled. */
83
+ fastMode?: boolean;
84
+ /** PR URL at launch time — used to attribute findings to the correct PR. */
85
+ prUrl?: string;
86
+ /** PR diff scope at launch time — "layer" or "full-stack". */
87
+ diffScope?: string;
88
+ /** Diff context snapshot at launch (stored on AgentJobInfo for per-job "Copy All"). */
89
+ diffContext?: AgentJobInfo["diffContext"];
90
+ } | null>;
91
+ /**
92
+ * Called after a job process exits with exit code 0.
93
+ * Use for result ingestion (e.g., reading an output file and pushing annotations).
94
+ */
95
+ onJobComplete?: (job: AgentJobInfo, meta: { outputPath?: string; stdout?: string; cwd?: string }) => void | Promise<void>;
96
+ }
97
+
98
+ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJobHandler {
99
+ const { mode, getServerUrl, getCwd } = options;
100
+
101
+ // --- State ---
102
+ const jobs = new Map<string, { info: AgentJobInfo; proc: ReturnType<typeof Bun.spawn> | null }>();
103
+ const jobOutputPaths = new Map<string, string>();
104
+ const subscribers = new Set<ReadableStreamDefaultController>();
105
+ const encoder = new TextEncoder();
106
+ let version = 0;
107
+
108
+ // --- Capability detection (run once) ---
109
+ const capabilities: AgentCapability[] = [
110
+ { id: "claude", name: "Claude Code", available: !!Bun.which("claude") },
111
+ { id: "codex", name: "Codex CLI", available: !!Bun.which("codex") },
112
+ { id: "tour", name: "Code Tour", available: !!Bun.which("claude") || !!Bun.which("codex") },
113
+ ];
114
+ const capabilitiesResponse: AgentCapabilities = {
115
+ mode,
116
+ providers: capabilities,
117
+ available: capabilities.some((c) => c.available),
118
+ };
119
+
120
+ // --- SSE broadcasting ---
121
+ function broadcast(event: AgentJobEvent): void {
122
+ version++;
123
+ const data = encoder.encode(serializeAgentSSEEvent(event));
124
+ for (const controller of subscribers) {
125
+ try {
126
+ controller.enqueue(data);
127
+ } catch {
128
+ subscribers.delete(controller);
129
+ }
130
+ }
131
+ }
132
+
133
+ // --- Process lifecycle ---
134
+ function spawnJob(
135
+ provider: string,
136
+ command: string[],
137
+ label: string,
138
+ outputPath?: string,
139
+ spawnOptions?: { captureStdout?: boolean; stdinPrompt?: string; cwd?: string; prompt?: string; engine?: string; model?: string; effort?: string; reasoningEffort?: string; fastMode?: boolean; prUrl?: string; diffScope?: string; diffContext?: AgentJobInfo["diffContext"] },
140
+ ): AgentJobInfo {
141
+ const id = crypto.randomUUID();
142
+ const source = jobSource(id);
143
+
144
+ const info: AgentJobInfo = {
145
+ id,
146
+ source,
147
+ provider,
148
+ label,
149
+ status: "starting",
150
+ startedAt: Date.now(),
151
+ command,
152
+ cwd: getCwd(),
153
+ ...(spawnOptions?.engine && { engine: spawnOptions.engine }),
154
+ ...(spawnOptions?.model && { model: spawnOptions.model }),
155
+ ...(spawnOptions?.effort && { effort: spawnOptions.effort }),
156
+ ...(spawnOptions?.reasoningEffort && { reasoningEffort: spawnOptions.reasoningEffort }),
157
+ ...(spawnOptions?.fastMode && { fastMode: spawnOptions.fastMode }),
158
+ ...(spawnOptions?.prUrl && { prUrl: spawnOptions.prUrl }),
159
+ ...(spawnOptions?.diffScope && { diffScope: spawnOptions.diffScope }),
160
+ ...(spawnOptions?.diffContext && { diffContext: spawnOptions.diffContext }),
161
+ };
162
+
163
+ let proc: ReturnType<typeof Bun.spawn> | null = null;
164
+
165
+ try {
166
+ const spawnCwd = spawnOptions?.cwd ?? getCwd();
167
+ const captureStdout = spawnOptions?.captureStdout ?? false;
168
+
169
+ const hasStdinPrompt = !!spawnOptions?.stdinPrompt;
170
+
171
+ proc = Bun.spawn(command, {
172
+ cwd: spawnCwd,
173
+ stdin: hasStdinPrompt ? "pipe" : undefined,
174
+ stdout: captureStdout ? "pipe" : "ignore",
175
+ stderr: "pipe",
176
+ env: {
177
+ ...process.env,
178
+ PLANNOTATOR_AGENT_SOURCE: source,
179
+ PLANNOTATOR_API_URL: getServerUrl(),
180
+ },
181
+ });
182
+
183
+ // Write prompt to stdin and close (for providers that read prompt from stdin)
184
+ if (hasStdinPrompt && proc.stdin) {
185
+ const sink = proc.stdin as import("bun").FileSink;
186
+ sink.write(spawnOptions!.stdinPrompt!);
187
+ sink.end();
188
+ }
189
+
190
+ info.status = "running";
191
+ info.cwd = spawnCwd;
192
+ if (spawnOptions?.prompt) info.prompt = spawnOptions.prompt;
193
+ jobs.set(id, { info, proc });
194
+ if (outputPath) jobOutputPaths.set(id, outputPath);
195
+ if (spawnOptions?.cwd) jobOutputPaths.set(`${id}:cwd`, spawnOptions.cwd);
196
+ broadcast({ type: "job:started", job: { ...info } });
197
+
198
+ // Drain stderr: capture tail for error reporting + broadcast live log deltas
199
+ let stderrBuf = "";
200
+ let logPending = "";
201
+ let logFlushTimer: ReturnType<typeof setTimeout> | null = null;
202
+
203
+ if (proc.stderr && typeof proc.stderr !== "number") {
204
+ (async () => {
205
+ try {
206
+ const reader = proc!.stderr as unknown as AsyncIterable<Uint8Array>;
207
+ for await (const chunk of reader) {
208
+ const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
209
+ stderrBuf = (stderrBuf + text).slice(-500);
210
+ logPending += text;
211
+
212
+ if (!logFlushTimer) {
213
+ logFlushTimer = setTimeout(() => {
214
+ if (logPending) {
215
+ broadcast({ type: "job:log", jobId: id, delta: logPending });
216
+ logPending = "";
217
+ }
218
+ logFlushTimer = null;
219
+ }, 200);
220
+ }
221
+ }
222
+ // Flush remaining on stream close
223
+ if (logFlushTimer) { clearTimeout(logFlushTimer); logFlushTimer = null; }
224
+ if (logPending) {
225
+ broadcast({ type: "job:log", jobId: id, delta: logPending });
226
+ logPending = "";
227
+ }
228
+ } catch {
229
+ // Stream closed or already consumed
230
+ }
231
+ })();
232
+ }
233
+
234
+ // Drain stdout when capturing (for providers that return results on stdout)
235
+ let stdoutBuf = "";
236
+ const stdoutDone = (captureStdout && proc.stdout && typeof proc.stdout !== "number")
237
+ ? (async () => {
238
+ try {
239
+ const reader = proc!.stdout as unknown as AsyncIterable<Uint8Array>;
240
+ for await (const chunk of reader) {
241
+ const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
242
+ stdoutBuf += text;
243
+
244
+ // Forward JSONL lines as log events (skip result events)
245
+ const lines = text.split('\n');
246
+ for (const line of lines) {
247
+ if (!line.trim()) continue;
248
+ // Claude: format JSONL into readable text. Tour jobs with the
249
+ // Claude engine also stream Claude JSONL, so key off engine too.
250
+ if (provider === "claude" || spawnOptions?.engine === "claude") {
251
+ const formatted = formatClaudeLogEvent(line);
252
+ if (formatted !== null) {
253
+ broadcast({ type: "job:log", jobId: id, delta: formatted + '\n' });
254
+ }
255
+ continue;
256
+ }
257
+ try {
258
+ const event = JSON.parse(line);
259
+ if (event.type === 'result') continue; // handled in onJobComplete
260
+ } catch { /* not JSON — forward as raw log */ }
261
+ broadcast({ type: "job:log", jobId: id, delta: line + '\n' });
262
+ }
263
+ }
264
+ } catch {
265
+ // Stream closed
266
+ }
267
+ })()
268
+ : Promise.resolve();
269
+
270
+ // Monitor process exit
271
+ proc.exited.then(async (exitCode) => {
272
+ // Wait for stdout to drain — grace period in case the pipe doesn't close cleanly.
273
+ // The process is dead; if the stream hasn't flushed in 2s, the runtime has a bug.
274
+ await Promise.race([stdoutDone, new Promise(r => setTimeout(r, 2000))]);
275
+ const entry = jobs.get(id);
276
+ if (!entry || isTerminalStatus(entry.info.status)) return;
277
+
278
+ entry.info.endedAt = Date.now();
279
+ entry.info.exitCode = exitCode;
280
+ entry.info.status = exitCode === 0 ? "done" : "failed";
281
+
282
+ if (exitCode !== 0 && stderrBuf) {
283
+ entry.info.error = stderrBuf;
284
+ }
285
+
286
+ // Ingest results before broadcasting completion so annotations arrive first
287
+ const outputPath = jobOutputPaths.get(id);
288
+ const jobCwd = jobOutputPaths.get(`${id}:cwd`);
289
+ if (exitCode === 0 && options.onJobComplete) {
290
+ try {
291
+ await options.onJobComplete(entry.info, {
292
+ outputPath,
293
+ stdout: captureStdout ? stdoutBuf : undefined,
294
+ cwd: jobCwd,
295
+ });
296
+ } catch {
297
+ // Result ingestion failure shouldn't prevent job completion broadcast
298
+ }
299
+ }
300
+ jobOutputPaths.delete(id);
301
+ jobOutputPaths.delete(`${id}:cwd`);
302
+
303
+ broadcast({ type: "job:completed", job: { ...entry.info } });
304
+ }).catch(() => {
305
+ // Guard against unhandled rejection from unexpected runtime errors
306
+ });
307
+ } catch (err) {
308
+ // Spawn itself failed (e.g., command not found).
309
+ // Broadcast started (so hook adds the job), then completed (so it updates to failed).
310
+ jobs.set(id, { info, proc: null });
311
+ broadcast({ type: "job:started", job: { ...info } });
312
+
313
+ info.status = "failed";
314
+ info.endedAt = Date.now();
315
+ info.error = err instanceof Error ? err.message : String(err);
316
+ broadcast({ type: "job:completed", job: { ...info } });
317
+ }
318
+
319
+ return { ...info };
320
+ }
321
+
322
+ function killJob(id: string): boolean {
323
+ const entry = jobs.get(id);
324
+ if (!entry || isTerminalStatus(entry.info.status)) return false;
325
+
326
+ if (entry.proc) {
327
+ try {
328
+ entry.proc.kill();
329
+ } catch {
330
+ // Process may have already exited
331
+ }
332
+ }
333
+
334
+ entry.info.status = "killed";
335
+ entry.info.endedAt = Date.now();
336
+ jobOutputPaths.delete(id);
337
+ jobOutputPaths.delete(`${id}:cwd`);
338
+ broadcast({ type: "job:completed", job: { ...entry.info } });
339
+ return true;
340
+ }
341
+
342
+ function killAll(): number {
343
+ let count = 0;
344
+ for (const [id, entry] of jobs) {
345
+ if (!isTerminalStatus(entry.info.status)) {
346
+ killJob(id);
347
+ count++;
348
+ }
349
+ }
350
+ return count;
351
+ }
352
+
353
+ function getAllJobs(): AgentJobInfo[] {
354
+ return Array.from(jobs.values()).map((e) => ({ ...e.info }));
355
+ }
356
+
357
+ // --- HTTP handler ---
358
+ return {
359
+ killAll,
360
+
361
+ async handle(
362
+ req: Request,
363
+ url: URL,
364
+ handlerOptions?: { disableIdleTimeout?: () => void },
365
+ ): Promise<Response | null> {
366
+ // --- GET /api/agents/capabilities ---
367
+ if (url.pathname === CAPABILITIES && req.method === "GET") {
368
+ return Response.json(capabilitiesResponse);
369
+ }
370
+
371
+ // --- SSE stream ---
372
+ if (url.pathname === JOBS_STREAM && req.method === "GET") {
373
+ handlerOptions?.disableIdleTimeout?.();
374
+
375
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
376
+ let ctrl: ReadableStreamDefaultController;
377
+
378
+ const stream = new ReadableStream({
379
+ start(controller) {
380
+ ctrl = controller;
381
+
382
+ // Send current state as snapshot
383
+ const snapshot: AgentJobEvent = {
384
+ type: "snapshot",
385
+ jobs: getAllJobs(),
386
+ };
387
+ controller.enqueue(encoder.encode(serializeAgentSSEEvent(snapshot)));
388
+
389
+ subscribers.add(controller);
390
+
391
+ // Heartbeat to keep connection alive
392
+ heartbeatTimer = setInterval(() => {
393
+ try {
394
+ controller.enqueue(encoder.encode(AGENT_HEARTBEAT_COMMENT));
395
+ } catch {
396
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
397
+ subscribers.delete(controller);
398
+ }
399
+ }, AGENT_HEARTBEAT_INTERVAL_MS);
400
+ },
401
+ cancel() {
402
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
403
+ subscribers.delete(ctrl);
404
+ },
405
+ });
406
+
407
+ return new Response(stream, {
408
+ headers: {
409
+ "Content-Type": "text/event-stream",
410
+ "Cache-Control": "no-cache",
411
+ Connection: "keep-alive",
412
+ },
413
+ });
414
+ }
415
+
416
+ // --- GET /api/agents/jobs (snapshot / polling fallback) ---
417
+ if (url.pathname === JOBS && req.method === "GET") {
418
+ const since = url.searchParams.get("since");
419
+ if (since !== null) {
420
+ const sinceVersion = parseInt(since, 10);
421
+ if (!isNaN(sinceVersion) && sinceVersion === version) {
422
+ return new Response(null, { status: 304 });
423
+ }
424
+ }
425
+ return Response.json({ jobs: getAllJobs(), version });
426
+ }
427
+
428
+ // --- POST /api/agents/jobs (launch) ---
429
+ if (url.pathname === JOBS && req.method === "POST") {
430
+ try {
431
+ const body = await req.json();
432
+ const provider = typeof body.provider === "string" ? body.provider : "";
433
+ let rawCommand = Array.isArray(body.command) ? body.command : [];
434
+ let command = rawCommand.filter((c: unknown): c is string => typeof c === "string");
435
+ let label = typeof body.label === "string" ? body.label : `${provider} agent`;
436
+ let outputPath: string | undefined;
437
+
438
+ // Validate provider is a known, available capability
439
+ const cap = capabilities.find((c) => c.id === provider);
440
+ if (!cap || !cap.available) {
441
+ return Response.json(
442
+ { error: `Unknown or unavailable provider: ${provider}` },
443
+ { status: 400 },
444
+ );
445
+ }
446
+
447
+ // Try server-side command building for known providers
448
+ let captureStdout = false;
449
+ let stdinPrompt: string | undefined;
450
+ let spawnCwd: string | undefined;
451
+ let promptText: string | undefined;
452
+ let jobEngine: string | undefined;
453
+ let jobModel: string | undefined;
454
+ let jobEffort: string | undefined;
455
+ let jobReasoningEffort: string | undefined;
456
+ let jobFastMode: boolean | undefined;
457
+ let jobPrUrl: string | undefined;
458
+ let jobDiffScope: string | undefined;
459
+ let jobDiffContext: AgentJobInfo["diffContext"] | undefined;
460
+ if (options.buildCommand) {
461
+ // Thread config from POST body to buildCommand
462
+ const config: Record<string, unknown> = {};
463
+ if (typeof body.engine === "string") config.engine = body.engine;
464
+ if (typeof body.model === "string") config.model = body.model;
465
+ if (typeof body.reasoningEffort === "string") config.reasoningEffort = body.reasoningEffort;
466
+ if (typeof body.effort === "string") config.effort = body.effort;
467
+ if (body.fastMode === true) config.fastMode = true;
468
+ const built = await options.buildCommand(provider, Object.keys(config).length > 0 ? config : undefined);
469
+ if (built) {
470
+ command = built.command;
471
+ outputPath = built.outputPath;
472
+ captureStdout = built.captureStdout ?? false;
473
+ stdinPrompt = built.stdinPrompt;
474
+ spawnCwd = built.cwd;
475
+ promptText = built.prompt;
476
+ if (built.label) label = built.label;
477
+ jobEngine = built.engine;
478
+ jobModel = built.model;
479
+ jobEffort = built.effort;
480
+ jobReasoningEffort = built.reasoningEffort;
481
+ jobFastMode = built.fastMode;
482
+ jobPrUrl = built.prUrl;
483
+ jobDiffScope = built.diffScope;
484
+ jobDiffContext = built.diffContext;
485
+ }
486
+ }
487
+
488
+ if (command.length === 0) {
489
+ return Response.json(
490
+ { error: 'Missing "command" array' },
491
+ { status: 400 },
492
+ );
493
+ }
494
+
495
+ const job = spawnJob(provider, command, label, outputPath, {
496
+ captureStdout,
497
+ stdinPrompt,
498
+ cwd: spawnCwd,
499
+ prompt: promptText,
500
+ engine: jobEngine,
501
+ model: jobModel,
502
+ effort: jobEffort,
503
+ reasoningEffort: jobReasoningEffort,
504
+ fastMode: jobFastMode,
505
+ prUrl: jobPrUrl,
506
+ diffScope: jobDiffScope,
507
+ diffContext: jobDiffContext,
508
+ });
509
+ return Response.json({ job }, { status: 201 });
510
+ } catch {
511
+ return Response.json({ error: "Invalid JSON" }, { status: 400 });
512
+ }
513
+ }
514
+
515
+ // --- DELETE /api/agents/jobs/:id (kill one) ---
516
+ if (url.pathname.startsWith(JOBS + "/") && url.pathname !== JOBS_STREAM && req.method === "DELETE") {
517
+ const id = url.pathname.slice(JOBS.length + 1);
518
+ if (!id) {
519
+ return Response.json({ error: "Missing job ID" }, { status: 400 });
520
+ }
521
+ const found = killJob(id);
522
+ if (!found) {
523
+ return Response.json({ error: "Job not found or already terminal" }, { status: 404 });
524
+ }
525
+ return Response.json({ ok: true });
526
+ }
527
+
528
+ // --- DELETE /api/agents/jobs (kill all) ---
529
+ if (url.pathname === JOBS && req.method === "DELETE") {
530
+ const count = killAll();
531
+ return Response.json({ ok: true, killed: count });
532
+ }
533
+
534
+ // Not handled
535
+ return null;
536
+ },
537
+ };
538
+ }
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildAgentReviewUserMessage, buildAgentReviewUserMessageForTarget, getLocalDiffInstruction } from "./agent-review-message";
3
+ import { buildClaudeCommand } from "./claude-review";
4
+
5
+ const patch = "diff --git a/src/large.ts b/src/large.ts\n+const value = 1;\n";
6
+
7
+ describe("buildAgentReviewUserMessage", () => {
8
+ test("builds Git local review instructions without inlining the patch", () => {
9
+ const cases = [
10
+ ["uncommitted", "current code changes"],
11
+ ["staged", "git diff --staged"],
12
+ ["unstaged", "unstaged code changes"],
13
+ ["last-commit", "git diff HEAD~1..HEAD"],
14
+ ["branch", "git diff origin/main..HEAD"],
15
+ ["merge-base", "git merge-base origin/main HEAD"],
16
+ ["all", "All files are shown as additions"],
17
+ ] as const;
18
+
19
+ for (const [diffType, expected] of cases) {
20
+ const message = buildAgentReviewUserMessage(patch, diffType, { defaultBranch: "origin/main" });
21
+ expect(message).toContain(expected);
22
+ expect(message).not.toContain(patch);
23
+ }
24
+ });
25
+
26
+ test("builds JJ local review instructions without inlining the patch", () => {
27
+ const cases = [
28
+ ["jj-current", "jj diff --git -r @"],
29
+ ["jj-last", "jj diff --git -r @-"],
30
+ ["jj-line", "jj diff --git --from 'heads(::@ & ::(trunk()))' --to @"],
31
+ ["jj-all", "jj diff --git --from 'root()' --to @"],
32
+ ] as const;
33
+
34
+ for (const [diffType, command] of cases) {
35
+ const message = buildAgentReviewUserMessage(patch, diffType, { defaultBranch: "trunk()" });
36
+ expect(message).toContain(command);
37
+ expect(message).toContain("Provide prioritized, actionable findings.");
38
+ expect(message).not.toContain(patch);
39
+ }
40
+ });
41
+
42
+ test("uses selected JJ compare target for line-of-work instructions", () => {
43
+ const message = buildAgentReviewUserMessage(patch, "jj-line", { defaultBranch: "feature-base@origin" });
44
+
45
+ expect(message).toContain("the JJ line of work against `feature-base@origin`");
46
+ expect(message).toContain('remote_bookmarks(exact:"feature-base", exact:"origin")');
47
+ });
48
+
49
+ test("shell-quotes JJ line-of-work revsets with single quotes", () => {
50
+ const message = buildAgentReviewUserMessage(patch, "jj-line", { defaultBranch: "feature'base" });
51
+
52
+ expect(message).toContain("'heads(::@ & ::(bookmarks(exact:\"feature'\\''base\")))'");
53
+ expect(message).not.toContain(patch);
54
+ });
55
+
56
+ test("normalizes worktree diff types using the encoded subtype", () => {
57
+ const message = buildAgentReviewUserMessage(patch, "worktree:/tmp/repo:staged", { defaultBranch: "origin/main" });
58
+
59
+ expect(message).toContain("git diff --staged");
60
+ expect(message).not.toContain(patch);
61
+ });
62
+
63
+ test("falls back to the inline patch for unknown local diff types", () => {
64
+ const message = buildAgentReviewUserMessage(patch, "p4-default");
65
+
66
+ expect(message).toContain("Review the following code changes");
67
+ expect(message).toContain(patch);
68
+ });
69
+
70
+ test("builds workspace review instructions with prefixed paths and inline patch", () => {
71
+ const message = buildAgentReviewUserMessageForTarget({
72
+ kind: "workspace",
73
+ patch,
74
+ workspace: {
75
+ root: "/tmp/workspace",
76
+ repos: [
77
+ { label: "api", cwd: "/tmp/workspace/api", changed: true, vcsType: "git", gitRef: "Uncommitted changes" },
78
+ { label: "web", cwd: "/tmp/workspace/web", changed: true, vcsType: "jj", gitRef: "Uncommitted changes" },
79
+ ],
80
+ },
81
+ });
82
+
83
+ expect(message).toContain("multiple nested VCS repositories");
84
+ expect(message).toContain("workspace root: /tmp/workspace");
85
+ expect(message).toContain("api/src/file.ts");
86
+ expect(message).toContain("must exactly match the path shown in the diff");
87
+ expect(message).toContain("web/src/file.ts");
88
+ expect(message).toContain("Do not use bare repo-relative paths like `src/file.ts`");
89
+ expect(message).toContain("do not use absolute filesystem paths");
90
+ expect(message).toContain("- api/ [git, changed] -> /tmp/workspace/api");
91
+ expect(message).toContain("- web/ [jj, changed] -> /tmp/workspace/web");
92
+ expect(message).toContain("git -C <child-repo-folder>");
93
+ expect(message).toContain("JJ child repos");
94
+ expect(message).toContain(patch);
95
+ });
96
+
97
+ test("discloses failed child repositories in workspace review instructions", () => {
98
+ const message = buildAgentReviewUserMessageForTarget({
99
+ kind: "workspace",
100
+ patch,
101
+ workspace: {
102
+ root: "/tmp/workspace",
103
+ repos: [
104
+ { label: "api", cwd: "/tmp/workspace/api", changed: true, vcsType: "git", gitRef: "Uncommitted changes" },
105
+ { label: "web", cwd: "/tmp/workspace/web", changed: false, error: "Git workspace not found." },
106
+ ],
107
+ },
108
+ });
109
+
110
+ expect(message).toContain("partial workspace review");
111
+ expect(message).toContain("- web/ [failed] -> /tmp/workspace/web - error: Git workspace not found.");
112
+ });
113
+ });
114
+
115
+ describe("getLocalDiffInstruction", () => {
116
+ test("returns null for non-local diff types", () => {
117
+ expect(getLocalDiffInstruction("p4-default")).toBeNull();
118
+ });
119
+ });
120
+
121
+ describe("buildClaudeCommand", () => {
122
+ test("allows read-only JJ commands", () => {
123
+ const command = buildClaudeCommand("review").command;
124
+ const allowedTools = command[command.indexOf("--allowedTools") + 1];
125
+
126
+ expect(allowedTools).toContain("Bash(jj status:*)");
127
+ expect(allowedTools).toContain("Bash(jj diff:*)");
128
+ expect(allowedTools).toContain("Bash(jj log:*)");
129
+ expect(allowedTools).toContain("Bash(jj show:*)");
130
+ expect(allowedTools).toContain("Bash(jj file show:*)");
131
+ expect(allowedTools).toContain("Bash(jj cat:*)");
132
+ expect(allowedTools).toContain("Bash(jj bookmark list:*)");
133
+ expect(allowedTools).toContain("Bash(git -C:*)");
134
+ });
135
+ });