repoburg 1.3.188 → 1.3.196

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 (265) hide show
  1. package/agent-pane/.prettierignore +5 -0
  2. package/agent-pane/.prettierrc +8 -0
  3. package/agent-pane/backend/coverage/base.css +224 -0
  4. package/agent-pane/backend/coverage/block-navigation.js +87 -0
  5. package/agent-pane/backend/coverage/clover.xml +887 -0
  6. package/agent-pane/backend/coverage/coverage-final.json +10 -0
  7. package/agent-pane/backend/coverage/favicon.png +0 -0
  8. package/agent-pane/backend/coverage/index.html +176 -0
  9. package/agent-pane/backend/coverage/prettify.css +1 -0
  10. package/agent-pane/backend/coverage/prettify.js +2 -0
  11. package/agent-pane/backend/coverage/sort-arrow-sprite.png +0 -0
  12. package/agent-pane/backend/coverage/sorter.js +210 -0
  13. package/agent-pane/backend/coverage/src/codex/codex-provider.ts.html +1402 -0
  14. package/agent-pane/backend/coverage/src/codex/index.html +131 -0
  15. package/agent-pane/backend/coverage/src/codex/item-mapper.ts.html +331 -0
  16. package/agent-pane/backend/coverage/src/db/index.html +131 -0
  17. package/agent-pane/backend/coverage/src/db/index.ts.html +436 -0
  18. package/agent-pane/backend/coverage/src/db/schema.ts.html +175 -0
  19. package/agent-pane/backend/coverage/src/index.html +116 -0
  20. package/agent-pane/backend/coverage/src/index.ts.html +262 -0
  21. package/agent-pane/backend/coverage/src/routes/index.html +146 -0
  22. package/agent-pane/backend/coverage/src/routes/sessions.ts.html +991 -0
  23. package/agent-pane/backend/coverage/src/routes/turns.ts.html +256 -0
  24. package/agent-pane/backend/coverage/src/routes/workspaces.ts.html +406 -0
  25. package/agent-pane/backend/coverage/src/ws/index.html +116 -0
  26. package/agent-pane/backend/coverage/src/ws/index.ts.html +238 -0
  27. package/agent-pane/backend/data.db +0 -0
  28. package/agent-pane/backend/data.db-shm +0 -0
  29. package/agent-pane/backend/data.db-wal +0 -0
  30. package/agent-pane/backend/dist/index.js +70869 -0
  31. package/agent-pane/backend/package-lock.json +4529 -1013
  32. package/agent-pane/backend/package.json +10 -6
  33. package/agent-pane/backend/src/claude/claude-message-mapper.test.ts +163 -0
  34. package/agent-pane/backend/src/claude/claude-message-mapper.ts +154 -0
  35. package/agent-pane/backend/src/claude/claude-provider.test.ts +65 -0
  36. package/agent-pane/backend/src/claude/claude-provider.ts +482 -0
  37. package/agent-pane/backend/src/claude/claude-types.ts +33 -0
  38. package/agent-pane/backend/src/codex/app-server-client.test.ts +183 -0
  39. package/agent-pane/backend/src/codex/app-server-client.ts +225 -0
  40. package/agent-pane/backend/src/codex/app-server-types.ts +259 -0
  41. package/agent-pane/backend/src/codex/codex-provider-runtime-config.test.ts +56 -0
  42. package/agent-pane/backend/src/codex/codex-provider.test.ts +60 -0
  43. package/agent-pane/backend/src/codex/codex-provider.ts +521 -265
  44. package/agent-pane/backend/src/codex/item-mapper.test.ts +221 -0
  45. package/agent-pane/backend/src/codex/item-mapper.ts +35 -8
  46. package/agent-pane/backend/src/codex/shared-app-server.ts +25 -0
  47. package/agent-pane/backend/src/db/index.test.ts +125 -0
  48. package/agent-pane/backend/src/db/index.ts +39 -8
  49. package/agent-pane/backend/src/db/schema.ts +28 -74
  50. package/agent-pane/backend/src/index.ts +29 -2
  51. package/agent-pane/backend/src/integration/__fixtures__/cancel-turn.json +29 -0
  52. package/agent-pane/backend/src/integration/__fixtures__/delta-streaming.json +40 -0
  53. package/agent-pane/backend/src/integration/__fixtures__/failed-turn.json +16 -0
  54. package/agent-pane/backend/src/integration/__fixtures__/mcp-tool-call.json +60 -0
  55. package/agent-pane/backend/src/integration/__fixtures__/missing-item-started.json +34 -0
  56. package/agent-pane/backend/src/integration/__fixtures__/multi-item-turn.json +85 -0
  57. package/agent-pane/backend/src/integration/__fixtures__/multi-turn.json +48 -0
  58. package/agent-pane/backend/src/integration/__fixtures__/simple-turn.json +35 -0
  59. package/agent-pane/backend/src/integration/delta-streaming.test.ts +130 -0
  60. package/agent-pane/backend/src/integration/helpers/app-server-mock.ts +272 -0
  61. package/agent-pane/backend/src/integration/helpers/test-app.ts +108 -0
  62. package/agent-pane/backend/src/integration/helpers/ws-capture.ts +64 -0
  63. package/agent-pane/backend/src/integration/item-lifecycle.test.ts +133 -0
  64. package/agent-pane/backend/src/integration/mcp-tool-call.test.ts +102 -0
  65. package/agent-pane/backend/src/integration/missing-item-started.test.ts +91 -0
  66. package/agent-pane/backend/src/integration/multi-turn.test.ts +121 -0
  67. package/agent-pane/backend/src/integration/session-operations.test.ts +168 -0
  68. package/agent-pane/backend/src/integration/title-generation.test.ts +109 -0
  69. package/agent-pane/backend/src/integration/turn-cancel.test.ts +111 -0
  70. package/agent-pane/backend/src/integration/turn-failure.test.ts +97 -0
  71. package/agent-pane/backend/src/integration/turn-lifecycle.test.ts +141 -0
  72. package/agent-pane/backend/src/providers/index.ts +14 -0
  73. package/agent-pane/backend/src/providers/registry.test.ts +92 -0
  74. package/agent-pane/backend/src/providers/registry.ts +43 -0
  75. package/agent-pane/backend/src/providers/runtime-provider.ts +25 -0
  76. package/agent-pane/backend/src/routes/files.ts +31 -0
  77. package/agent-pane/backend/src/routes/mcp.ts +33 -0
  78. package/agent-pane/backend/src/routes/models.test.ts +92 -0
  79. package/agent-pane/backend/src/routes/models.ts +16 -0
  80. package/agent-pane/backend/src/routes/plugins.ts +40 -0
  81. package/agent-pane/backend/src/routes/providers.test.ts +69 -0
  82. package/agent-pane/backend/src/routes/providers.ts +57 -0
  83. package/agent-pane/backend/src/routes/sessions.test.ts +424 -0
  84. package/agent-pane/backend/src/routes/sessions.ts +295 -42
  85. package/agent-pane/backend/src/routes/skills.test.ts +114 -0
  86. package/agent-pane/backend/src/routes/skills.ts +70 -0
  87. package/agent-pane/backend/src/routes/turns.test.ts +152 -0
  88. package/agent-pane/backend/src/routes/turns.ts +36 -16
  89. package/agent-pane/backend/src/routes/workspaces.test.ts +197 -0
  90. package/agent-pane/backend/src/routes/workspaces.ts +73 -55
  91. package/agent-pane/backend/src/ws/index.test.ts +107 -0
  92. package/agent-pane/backend/src/ws/index.ts +19 -11
  93. package/agent-pane/backend/tsconfig.json +4 -2
  94. package/agent-pane/backend/vitest.config.ts +20 -0
  95. package/agent-pane/eslint.config.js +33 -0
  96. package/agent-pane/frontend/coverage/base.css +224 -0
  97. package/agent-pane/frontend/coverage/block-navigation.js +87 -0
  98. package/agent-pane/frontend/coverage/clover.xml +2962 -0
  99. package/agent-pane/frontend/coverage/coverage-final.json +37 -0
  100. package/agent-pane/frontend/coverage/favicon.png +0 -0
  101. package/agent-pane/frontend/coverage/index.html +251 -0
  102. package/agent-pane/frontend/coverage/prettify.css +1 -0
  103. package/agent-pane/frontend/coverage/prettify.js +2 -0
  104. package/agent-pane/frontend/coverage/sort-arrow-sprite.png +0 -0
  105. package/agent-pane/frontend/coverage/sorter.js +210 -0
  106. package/agent-pane/frontend/coverage/src/App.tsx.html +217 -0
  107. package/agent-pane/frontend/coverage/src/AppInitializer.tsx.html +223 -0
  108. package/agent-pane/frontend/coverage/src/api/client.ts.html +187 -0
  109. package/agent-pane/frontend/coverage/src/api/index.html +161 -0
  110. package/agent-pane/frontend/coverage/src/api/sessions.ts.html +316 -0
  111. package/agent-pane/frontend/coverage/src/api/websocket.ts.html +544 -0
  112. package/agent-pane/frontend/coverage/src/api/workspaces.ts.html +199 -0
  113. package/agent-pane/frontend/coverage/src/components/CollapsibleToolCalls.tsx.html +226 -0
  114. package/agent-pane/frontend/coverage/src/components/CommandInputBar.tsx.html +382 -0
  115. package/agent-pane/frontend/coverage/src/components/CommandPalette.tsx.html +715 -0
  116. package/agent-pane/frontend/coverage/src/components/IdeWorkspace.tsx.html +160 -0
  117. package/agent-pane/frontend/coverage/src/components/ModelSelector.tsx.html +478 -0
  118. package/agent-pane/frontend/coverage/src/components/NewSessionDialog.tsx.html +472 -0
  119. package/agent-pane/frontend/coverage/src/components/RenderArea.tsx.html +574 -0
  120. package/agent-pane/frontend/coverage/src/components/RightPanel.tsx.html +205 -0
  121. package/agent-pane/frontend/coverage/src/components/RuntimeIcon.tsx.html +133 -0
  122. package/agent-pane/frontend/coverage/src/components/SessionTabs.tsx.html +217 -0
  123. package/agent-pane/frontend/coverage/src/components/StatusBar.tsx.html +271 -0
  124. package/agent-pane/frontend/coverage/src/components/StatusDot.tsx.html +109 -0
  125. package/agent-pane/frontend/coverage/src/components/Toast.tsx.html +313 -0
  126. package/agent-pane/frontend/coverage/src/components/TurnContent.tsx.html +1216 -0
  127. package/agent-pane/frontend/coverage/src/components/WorkspaceSidebar.tsx.html +1138 -0
  128. package/agent-pane/frontend/coverage/src/components/index.html +326 -0
  129. package/agent-pane/frontend/coverage/src/components/panels/ChangesPanel.tsx.html +247 -0
  130. package/agent-pane/frontend/coverage/src/components/panels/LogsPanel.tsx.html +160 -0
  131. package/agent-pane/frontend/coverage/src/components/panels/MetricsPanel.tsx.html +427 -0
  132. package/agent-pane/frontend/coverage/src/components/panels/TurnsPanel.tsx.html +397 -0
  133. package/agent-pane/frontend/coverage/src/components/panels/index.html +161 -0
  134. package/agent-pane/frontend/coverage/src/constants/index.html +116 -0
  135. package/agent-pane/frontend/coverage/src/constants/models.ts.html +406 -0
  136. package/agent-pane/frontend/coverage/src/hooks/index.html +146 -0
  137. package/agent-pane/frontend/coverage/src/hooks/useEscapeKey.ts.html +124 -0
  138. package/agent-pane/frontend/coverage/src/hooks/useOutsideClick.ts.html +142 -0
  139. package/agent-pane/frontend/coverage/src/hooks/useSessionStatus.ts.html +187 -0
  140. package/agent-pane/frontend/coverage/src/index.html +146 -0
  141. package/agent-pane/frontend/coverage/src/main.tsx.html +115 -0
  142. package/agent-pane/frontend/coverage/src/store/index.html +116 -0
  143. package/agent-pane/frontend/coverage/src/store/store.ts.html +2404 -0
  144. package/agent-pane/frontend/coverage/src/test/component-helpers.ts.html +190 -0
  145. package/agent-pane/frontend/coverage/src/test/fixtures.ts.html +367 -0
  146. package/agent-pane/frontend/coverage/src/test/index.html +131 -0
  147. package/agent-pane/frontend/coverage/src/types/index.html +116 -0
  148. package/agent-pane/frontend/coverage/src/types/index.ts.html +466 -0
  149. package/agent-pane/frontend/coverage/src/utils/changes.ts.html +202 -0
  150. package/agent-pane/frontend/coverage/src/utils/duration.ts.html +127 -0
  151. package/agent-pane/frontend/coverage/src/utils/index.html +131 -0
  152. package/agent-pane/frontend/dist/assets/index-Bg0PF2aV.css +1 -0
  153. package/agent-pane/frontend/dist/assets/index-BlybeX5e.js +281 -0
  154. package/agent-pane/frontend/dist/index.html +19 -0
  155. package/agent-pane/frontend/package-lock.json +2434 -213
  156. package/agent-pane/frontend/package.json +10 -3
  157. package/agent-pane/frontend/src/App.test.tsx +74 -0
  158. package/agent-pane/frontend/src/App.tsx +1 -1
  159. package/agent-pane/frontend/src/AppInitializer.test.tsx +142 -0
  160. package/agent-pane/frontend/src/AppInitializer.tsx +1 -1
  161. package/agent-pane/frontend/src/api/client.test.ts +126 -0
  162. package/agent-pane/frontend/src/api/client.ts +22 -29
  163. package/agent-pane/frontend/src/api/models.ts +7 -0
  164. package/agent-pane/frontend/src/api/providers.ts +12 -0
  165. package/agent-pane/frontend/src/api/sessions.test.ts +241 -0
  166. package/agent-pane/frontend/src/api/sessions.ts +43 -1
  167. package/agent-pane/frontend/src/api/skills.ts +12 -0
  168. package/agent-pane/frontend/src/api/websocket.test.ts +288 -0
  169. package/agent-pane/frontend/src/api/websocket.ts +27 -2
  170. package/agent-pane/frontend/src/api/workspaces.test.ts +112 -0
  171. package/agent-pane/frontend/src/api/workspaces.ts +17 -3
  172. package/agent-pane/frontend/src/components/CollapsibleToolCalls.test.tsx +49 -0
  173. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +2 -5
  174. package/agent-pane/frontend/src/components/CommandInputBar.test.tsx +133 -0
  175. package/agent-pane/frontend/src/components/CommandInputBar.tsx +296 -17
  176. package/agent-pane/frontend/src/components/CommandPalette.test.tsx +230 -0
  177. package/agent-pane/frontend/src/components/CommandPalette.tsx +26 -23
  178. package/agent-pane/frontend/src/components/IdeWorkspace.tsx +1 -1
  179. package/agent-pane/frontend/src/components/ModelSelector.test.tsx +129 -0
  180. package/agent-pane/frontend/src/components/ModelSelector.tsx +191 -0
  181. package/agent-pane/frontend/src/components/NewSessionDialog.test.tsx +95 -0
  182. package/agent-pane/frontend/src/components/NewSessionDialog.tsx +58 -13
  183. package/agent-pane/frontend/src/components/RenderArea.test.tsx +160 -0
  184. package/agent-pane/frontend/src/components/RenderArea.tsx +18 -25
  185. package/agent-pane/frontend/src/components/RightPanel.test.tsx +43 -0
  186. package/agent-pane/frontend/src/components/RightPanel.tsx +1 -1
  187. package/agent-pane/frontend/src/components/RuntimeIcon.test.tsx +32 -0
  188. package/agent-pane/frontend/src/components/RuntimeIcon.tsx +1 -1
  189. package/agent-pane/frontend/src/components/SessionTabs.test.tsx +72 -0
  190. package/agent-pane/frontend/src/components/SessionTabs.tsx +12 -12
  191. package/agent-pane/frontend/src/components/StatusBar.test.tsx +56 -0
  192. package/agent-pane/frontend/src/components/StatusBar.tsx +25 -31
  193. package/agent-pane/frontend/src/components/StatusDot.test.tsx +25 -0
  194. package/agent-pane/frontend/src/components/StatusDot.tsx +2 -7
  195. package/agent-pane/frontend/src/components/Toast.test.tsx +79 -0
  196. package/agent-pane/frontend/src/components/Toast.tsx +2 -6
  197. package/agent-pane/frontend/src/components/TurnContent.test.tsx +287 -0
  198. package/agent-pane/frontend/src/components/TurnContent.tsx +107 -24
  199. package/agent-pane/frontend/src/components/WorkspaceSidebar.test.tsx +297 -0
  200. package/agent-pane/frontend/src/components/WorkspaceSidebar.tsx +33 -32
  201. package/agent-pane/frontend/src/components/input/OptionSubPopup.tsx +32 -0
  202. package/agent-pane/frontend/src/components/input/SkillMentionPopup.test.tsx +63 -0
  203. package/agent-pane/frontend/src/components/input/SkillMentionPopup.tsx +78 -0
  204. package/agent-pane/frontend/src/components/input/SlashPopup.test.tsx +96 -0
  205. package/agent-pane/frontend/src/components/input/SlashPopup.tsx +63 -0
  206. package/agent-pane/frontend/src/components/panels/ChangesPanel.test.tsx +61 -0
  207. package/agent-pane/frontend/src/components/panels/ChangesPanel.tsx +12 -39
  208. package/agent-pane/frontend/src/components/panels/LogsPanel.test.tsx +58 -0
  209. package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +1 -1
  210. package/agent-pane/frontend/src/components/panels/MetricsPanel.test.tsx +71 -0
  211. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +63 -31
  212. package/agent-pane/frontend/src/components/panels/TurnsPanel.test.tsx +83 -0
  213. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +66 -7
  214. package/agent-pane/frontend/src/constants/models.test.ts +97 -0
  215. package/agent-pane/frontend/src/constants/models.ts +129 -0
  216. package/agent-pane/frontend/src/hooks/useEscapeKey.test.ts +42 -0
  217. package/agent-pane/frontend/src/hooks/useEscapeKey.ts +13 -0
  218. package/agent-pane/frontend/src/hooks/useOutsideClick.test.ts +92 -0
  219. package/agent-pane/frontend/src/hooks/useOutsideClick.ts +19 -0
  220. package/agent-pane/frontend/src/hooks/useProvider.ts +44 -0
  221. package/agent-pane/frontend/src/hooks/useSessionStatus.test.ts +114 -0
  222. package/agent-pane/frontend/src/hooks/useSessionStatus.ts +34 -0
  223. package/agent-pane/frontend/src/main.test.tsx +37 -0
  224. package/agent-pane/frontend/src/main.tsx +1 -1
  225. package/agent-pane/frontend/src/store/sessionSlice.ts +417 -0
  226. package/agent-pane/frontend/src/store/store.test.ts +1284 -0
  227. package/agent-pane/frontend/src/store/store.ts +16 -617
  228. package/agent-pane/frontend/src/store/uiSlice.ts +83 -0
  229. package/agent-pane/frontend/src/store/workspaceSlice.ts +77 -0
  230. package/agent-pane/frontend/src/store/wsSlice.ts +292 -0
  231. package/agent-pane/frontend/src/styles/app.css +113 -7
  232. package/agent-pane/frontend/src/test/component-helpers.ts +36 -0
  233. package/agent-pane/frontend/src/test/fixtures.ts +96 -0
  234. package/agent-pane/frontend/src/test-setup.ts +5 -0
  235. package/agent-pane/frontend/src/types/index.test.ts +128 -0
  236. package/agent-pane/frontend/src/types/index.ts +80 -104
  237. package/agent-pane/frontend/src/utils/changes.test.ts +81 -0
  238. package/agent-pane/frontend/src/utils/changes.ts +39 -0
  239. package/agent-pane/frontend/src/utils/duration.test.ts +71 -0
  240. package/agent-pane/frontend/src/utils/duration.ts +14 -0
  241. package/agent-pane/frontend/tsconfig.json +4 -1
  242. package/agent-pane/frontend/vite.config.ts +6 -0
  243. package/agent-pane/frontend/vitest.config.ts +29 -0
  244. package/agent-pane/package-lock.json +1527 -0
  245. package/agent-pane/package.json +19 -0
  246. package/agent-pane/shared/provider-types.ts +53 -0
  247. package/agent-pane/shared/types.ts +107 -0
  248. package/agentpane-branch-session.png +0 -0
  249. package/agentpane-expanded-all.png +0 -0
  250. package/agentpane-fixed-rendering.png +0 -0
  251. package/agentpane-focus-check.png +0 -0
  252. package/agentpane-initial.png +0 -0
  253. package/agentpane-mcp-detail.png +0 -0
  254. package/agentpane-no-focus-ring.png +0 -0
  255. package/agentpane-soft-focus.png +0 -0
  256. package/backend/dist/src/llm-provider/ollama-llm.provider.js +1 -0
  257. package/backend/dist/src/llm-provider/ollama-llm.provider.js.map +1 -1
  258. package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
  259. package/data.db +0 -0
  260. package/empty-state.png +0 -0
  261. package/frontend-home.png +0 -0
  262. package/model-selector-open.png +0 -0
  263. package/package.json +1 -1
  264. package/slash-popup-working.png +0 -0
  265. package/agent-pane/frontend/src/components/DiffViewer.tsx +0 -27
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Claude provider — implements the RuntimeProvider interface.
3
+ *
4
+ * Uses `@anthropic-ai/claude-agent-sdk` `query()` function directly.
5
+ * The SDK spawns the Claude CLI internally — no manual subprocess.
6
+ *
7
+ * Same WebSocket event protocol as Codex:
8
+ * turn:started, item:started, item:completed, item:delta,
9
+ * turn:completed, turn:tokenUsage, turn:failed, turn:cancelled
10
+ *
11
+ * Frontend needs zero changes.
12
+ */
13
+
14
+ import { query } from '@anthropic-ai/claude-agent-sdk';
15
+ import type { SDKMessage, Options, ModelInfo as SdkModelInfo, Query } from '@anthropic-ai/claude-agent-sdk';
16
+ import type { ModelInfo, TokenUsage } from '@agentpane/shared';
17
+ import { v4 as uuidv4 } from 'uuid';
18
+ import type Database from 'better-sqlite3';
19
+ import { getDb } from '../db/index.ts';
20
+ import { broadcast } from '../ws/index.ts';
21
+ import { mapAssistantContent, mapPartialDelta, mapUsage, type ItemDescriptor } from './claude-message-mapper.ts';
22
+ import { EFFORT_LABELS, type ClaudeSessionEntry } from './claude-types.ts';
23
+ import type { Session, Turn, Item, ItemStatus } from '../db/schema.ts';
24
+ import type { RuntimeProvider } from '../providers/runtime-provider.ts';
25
+
26
+ /** Per-session Claude query instances. */
27
+ const sessionMap = new Map<string, ClaudeSessionEntry>();
28
+
29
+ /** Sessions currently generating titles — prevents duplicate throwaway queries. */
30
+ const titleGenerating = new Set<string>();
31
+
32
+ /** Cached model list from the SDK. */
33
+ let cachedModels: ModelInfo[] | null = null;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // DB helpers (same pattern as codex-provider)
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Update the running state and status of a session. */
40
+ function setSessionStatus(
41
+ db: Database.Database,
42
+ sessionId: string,
43
+ isRunning: 0 | 1,
44
+ status: string,
45
+ now: string,
46
+ hasUnreviewed = false,
47
+ ): void {
48
+ if (hasUnreviewed) {
49
+ db.prepare('UPDATE sessions SET is_running = ?, status = ?, has_unreviewed = 1, updated_at = ? WHERE id = ?').run(
50
+ isRunning, status, now, sessionId,
51
+ );
52
+ } else {
53
+ db.prepare('UPDATE sessions SET is_running = ?, status = ?, updated_at = ? WHERE id = ?').run(
54
+ isRunning, status, now, sessionId,
55
+ );
56
+ }
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // startTurn
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /** Start a Claude turn. Fire-and-forget — streams events via WS. */
64
+ async function startTurn(session: Session, turn: Turn): Promise<void> {
65
+ const db = getDb();
66
+ const now = new Date().toISOString();
67
+
68
+ // Update turn + session status
69
+ db.prepare('UPDATE turns SET status = ?, updated_at = ? WHERE id = ?').run('running', now, turn.id);
70
+ setSessionStatus(db, session.id, 1, 'running', now);
71
+ broadcast('turn:started', { sessionId: session.id, turnId: turn.id });
72
+
73
+ // Build options
74
+ const options: Options = {
75
+ cwd: session.cwd ?? process.cwd(),
76
+ model: session.model ?? undefined,
77
+ permissionMode: 'bypassPermissions',
78
+ allowDangerouslySkipPermissions: true,
79
+ includePartialMessages: true,
80
+ ...(process.env.CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.CLAUDE_BIN } : {}),
81
+ ...(session.model_reasoning_effort ? { effort: session.model_reasoning_effort as Options['effort'] } : {}),
82
+ ...(session.external_thread_id ? { resume: session.external_thread_id } : {}),
83
+ };
84
+
85
+ let q;
86
+ try {
87
+ q = query({ prompt: turn.user_prompt, options });
88
+ } catch (err) {
89
+ const errMsg = `Failed to start Claude query: ${(err as Error).message}`;
90
+ const now2 = new Date().toISOString();
91
+ db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?').run('failed', errMsg, now2, turn.id);
92
+ setSessionStatus(db, session.id, 0, 'failed', now2);
93
+ broadcast('turn:failed', { sessionId: session.id, turnId: turn.id, error: errMsg });
94
+ return;
95
+ }
96
+
97
+ const entry: ClaudeSessionEntry = { query: q, sessionId: null, turnId: turn.id };
98
+ sessionMap.set(session.id, entry);
99
+
100
+ // Iterate the async generator
101
+ try {
102
+ for await (const message of q) {
103
+ await handleMessage(message, session.id, turn.id, entry, db);
104
+ }
105
+
106
+ // If turn is still running after generator ends, mark as completed
107
+ const currentTurn = db.prepare('SELECT status FROM turns WHERE id = ?').get(turn.id) as { status: string } | undefined;
108
+ if (currentTurn && currentTurn.status === 'running') {
109
+ completeTurn(db, session.id, turn.id, null);
110
+ }
111
+ } catch (err) {
112
+ const errMsg = (err as Error).message;
113
+ const now2 = new Date().toISOString();
114
+ db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?').run('failed', errMsg, now2, turn.id);
115
+ setSessionStatus(db, session.id, 0, 'failed', now2);
116
+ broadcast('turn:failed', { sessionId: session.id, turnId: turn.id, error: errMsg });
117
+ } finally {
118
+ // Remove from session map if turn is done (not just idle)
119
+ if (entry.turnId === null) {
120
+ sessionMap.delete(session.id);
121
+ }
122
+ }
123
+ }
124
+
125
+ /** Handle a single SDK message — dispatch to the correct handler. */
126
+ async function handleMessage(
127
+ message: SDKMessage,
128
+ sessionId: string,
129
+ turnId: string,
130
+ entry: ClaudeSessionEntry,
131
+ db: Database.Database,
132
+ ): Promise<void> {
133
+ switch (message.type) {
134
+ case 'system': {
135
+ if ('subtype' in message && message.subtype === 'init') {
136
+ // Save Claude session ID for resume
137
+ entry.sessionId = message.session_id;
138
+ const now = new Date().toISOString();
139
+ db.prepare('UPDATE sessions SET external_thread_id = ?, updated_at = ? WHERE id = ?').run(
140
+ message.session_id, now, sessionId,
141
+ );
142
+ }
143
+ break;
144
+ }
145
+
146
+ case 'assistant': {
147
+ handleAssistantMessage(message, sessionId, turnId, db);
148
+ break;
149
+ }
150
+
151
+ case 'stream_event': {
152
+ handleStreamEvent(message, sessionId, turnId);
153
+ break;
154
+ }
155
+
156
+ case 'result': {
157
+ handleResultMessage(message, sessionId, turnId, db);
158
+ break;
159
+ }
160
+
161
+ // Skip user messages — tool outputs not captured in initial implementation.
162
+ default:
163
+ break;
164
+ }
165
+ }
166
+
167
+ /** Handle SDKAssistantMessage — map content blocks to items. */
168
+ function handleAssistantMessage(
169
+ message: SDKMessage & { type: 'assistant' },
170
+ sessionId: string,
171
+ turnId: string,
172
+ db: Database.Database,
173
+ ): void {
174
+ const content = message.message.content;
175
+ const messageId = message.uuid;
176
+ const descriptors = mapAssistantContent(content, messageId);
177
+
178
+ for (const desc of descriptors) {
179
+ upsertItem(db, desc, sessionId, turnId, 'completed', 'item:completed');
180
+
181
+ // Update turn-level fields
182
+ const now = new Date().toISOString();
183
+ if (desc.type === 'agent_message') {
184
+ const text = (desc.data as { text: string }).text;
185
+ db.prepare('UPDATE turns SET agent_response = ?, updated_at = ? WHERE id = ?').run(text, now, turnId);
186
+ }
187
+ if (desc.type === 'reasoning') {
188
+ const text = (desc.data as { text: string }).text;
189
+ if (text) {
190
+ db.prepare('UPDATE turns SET thoughts = ?, updated_at = ? WHERE id = ?').run(text, now, turnId);
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ /** Handle SDKPartialAssistantMessage — broadcast streaming deltas. */
197
+ function handleStreamEvent(
198
+ message: SDKMessage & { type: 'stream_event' },
199
+ sessionId: string,
200
+ turnId: string,
201
+ ): void {
202
+ const delta = mapPartialDelta(message.event);
203
+ if (!delta) return;
204
+
205
+ // Broadcast text deltas to frontend — no DB write per delta (performance).
206
+ if (delta.kind === 'text') {
207
+ broadcast('item:delta', {
208
+ sessionId,
209
+ turnId,
210
+ itemId: message.uuid,
211
+ delta: delta.text,
212
+ });
213
+ }
214
+ // thinking_delta not broadcast as item:delta — reasoning captured from complete thinking block.
215
+ }
216
+
217
+ /** Handle SDKResultMessage — complete the turn. */
218
+ function handleResultMessage(
219
+ message: SDKMessage & { type: 'result' },
220
+ sessionId: string,
221
+ turnId: string,
222
+ db: Database.Database,
223
+ ): void {
224
+ if (message.subtype === 'success') {
225
+ const usage = mapUsage(message.usage);
226
+ completeTurn(db, sessionId, turnId, usage);
227
+ } else {
228
+ // Error result
229
+ const errorMsg = message.errors?.join('; ') ?? 'Claude turn failed';
230
+ const now = new Date().toISOString();
231
+ db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?').run('failed', errorMsg, now, turnId);
232
+ setSessionStatus(db, sessionId, 0, 'failed', now);
233
+ broadcast('turn:failed', { sessionId, turnId, error: errorMsg });
234
+ }
235
+
236
+ // Generate title after turn completes
237
+ syncThreadTitle(sessionId);
238
+ }
239
+
240
+ /** Complete a turn — set status, broadcast turn:completed. */
241
+ function completeTurn(db: Database.Database, sessionId: string, turnId: string, usage: TokenUsage | null): void {
242
+ const now = new Date().toISOString();
243
+ if (usage) {
244
+ db.prepare('UPDATE turns SET status = ?, token_usage = ?, updated_at = ? WHERE id = ?').run(
245
+ 'completed', JSON.stringify(usage), now, turnId,
246
+ );
247
+ broadcast('turn:completed', { sessionId, turnId, usage, hasUnreviewed: true });
248
+ } else {
249
+ db.prepare('UPDATE turns SET status = ?, updated_at = ? WHERE id = ?').run('completed', now, turnId);
250
+ broadcast('turn:completed', { sessionId, turnId, hasUnreviewed: true });
251
+ }
252
+ setSessionStatus(db, sessionId, 0, 'active', now, true);
253
+ }
254
+
255
+ /** Insert or update an item in the DB and broadcast. */
256
+ function upsertItem(
257
+ db: Database.Database,
258
+ desc: ItemDescriptor,
259
+ sessionId: string,
260
+ turnId: string,
261
+ status: ItemStatus,
262
+ broadcastEvent: string,
263
+ ): void {
264
+ const now = new Date().toISOString();
265
+
266
+ // Try to update existing row
267
+ const result = db
268
+ .prepare('UPDATE items SET data = ?, status = ?, updated_at = ? WHERE external_item_id = ? AND turn_id = ?')
269
+ .run(JSON.stringify(desc.data), status, now, desc.externalId, turnId);
270
+
271
+ let itemId: string;
272
+ let createdAt: string;
273
+
274
+ if (result.changes === 0) {
275
+ itemId = uuidv4();
276
+ createdAt = now;
277
+ db.prepare(
278
+ 'INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
279
+ ).run(itemId, turnId, desc.externalId, desc.type as Item['type'], JSON.stringify(desc.data), status, now, now);
280
+ } else {
281
+ const row = db
282
+ .prepare('SELECT id, created_at FROM items WHERE external_item_id = ? AND turn_id = ?')
283
+ .get(desc.externalId, turnId) as { id: string; created_at: string };
284
+ itemId = row.id;
285
+ createdAt = row.created_at;
286
+ }
287
+
288
+ const wsItem: Item = {
289
+ id: itemId,
290
+ turn_id: turnId,
291
+ external_item_id: desc.externalId,
292
+ type: desc.type as Item['type'],
293
+ data: desc.data,
294
+ status,
295
+ created_at: createdAt,
296
+ updated_at: now,
297
+ };
298
+
299
+ broadcast(broadcastEvent, { sessionId, turnId, item: wsItem });
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // interruptTurn
304
+ // ---------------------------------------------------------------------------
305
+
306
+ /** Cancel a running turn by session ID. Returns true if a turn was cancelled. */
307
+ async function interruptTurn(sessionId: string): Promise<boolean> {
308
+ const entry = sessionMap.get(sessionId);
309
+ if (!entry || !entry.turnId) return false;
310
+
311
+ try {
312
+ await entry.query.interrupt();
313
+
314
+ const db = getDb();
315
+ const now = new Date().toISOString();
316
+ db.prepare('UPDATE turns SET status = ?, updated_at = ? WHERE id = ?').run('aborted', now, entry.turnId);
317
+ setSessionStatus(db, sessionId, 0, 'active', now);
318
+ broadcast('turn:cancelled', { sessionId, turnId: entry.turnId });
319
+
320
+ entry.turnId = null;
321
+ return true;
322
+ } catch {
323
+ return false;
324
+ }
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // clearThread / clearAllThreads
329
+ // ---------------------------------------------------------------------------
330
+
331
+ /** Remove a session's Claude query from the in-memory map. */
332
+ function clearThread(sessionId: string): void {
333
+ const entry = sessionMap.get(sessionId);
334
+ if (entry) {
335
+ entry.query.close();
336
+ sessionMap.delete(sessionId);
337
+ }
338
+ }
339
+
340
+ /** Clear all session entries. Used in test cleanup. */
341
+ export function clearAllThreads(): void {
342
+ for (const [, entry] of sessionMap) {
343
+ entry.query.close();
344
+ }
345
+ sessionMap.clear();
346
+ titleGenerating.clear();
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // getModels
351
+ // ---------------------------------------------------------------------------
352
+
353
+ /** Fetch available models from the Claude SDK and map to AgentPane ModelInfo[]. */
354
+ async function getModels(): Promise<ModelInfo[]> {
355
+ if (cachedModels) return cachedModels;
356
+
357
+ try {
358
+ const q = query({
359
+ prompt: '',
360
+ options: {
361
+ cwd: process.cwd(),
362
+ permissionMode: 'bypassPermissions',
363
+ allowDangerouslySkipPermissions: true,
364
+ },
365
+ });
366
+
367
+ const initResult = await q.initializationResult();
368
+ q.close();
369
+
370
+ cachedModels = (initResult.models ?? []).map((m: SdkModelInfo) => ({
371
+ slug: m.value,
372
+ displayName: m.displayName,
373
+ description: m.description,
374
+ defaultReasoning: m.supportedEffortLevels?.[0] ?? 'medium',
375
+ reasoningLevels: (m.supportedEffortLevels ?? []).map((e) => ({
376
+ effort: e,
377
+ label: EFFORT_LABELS[e] ?? e,
378
+ })),
379
+ }));
380
+
381
+ return cachedModels;
382
+ } catch {
383
+ return [];
384
+ }
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // shutdown
389
+ // ---------------------------------------------------------------------------
390
+
391
+ /** Graceful shutdown — close all query instances. */
392
+ async function shutdown(): Promise<void> {
393
+ clearAllThreads();
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // Title generation
398
+ // ---------------------------------------------------------------------------
399
+
400
+ /** Generate a title for the session using a throwaway Claude query. */
401
+ function syncThreadTitle(sessionId: string): void {
402
+ const db = getDb();
403
+ const session = db.prepare('SELECT title FROM sessions WHERE id = ?').get(sessionId) as
404
+ | { title: string | null }
405
+ | undefined;
406
+ if (!session || session.title !== null) return;
407
+ if (titleGenerating.has(sessionId)) return;
408
+
409
+ const turn = db
410
+ .prepare('SELECT user_prompt, agent_response FROM turns WHERE session_id = ? ORDER BY created_at ASC LIMIT 1')
411
+ .get(sessionId) as { user_prompt: string; agent_response: string | null } | undefined;
412
+ if (!turn) return;
413
+
414
+ titleGenerating.add(sessionId);
415
+ generateTitleAsync(sessionId, turn.user_prompt, turn.agent_response).finally(() => {
416
+ titleGenerating.delete(sessionId);
417
+ });
418
+ }
419
+
420
+ /** Create a throwaway Claude query, ask for a title, update the session. */
421
+ async function generateTitleAsync(
422
+ sessionId: string,
423
+ userPrompt: string,
424
+ agentResponse: string | null,
425
+ ): Promise<void> {
426
+ let q: Query | null = null;
427
+ try {
428
+ const truncatedPrompt = userPrompt.slice(0, 500);
429
+ const truncatedResponse = (agentResponse ?? '').slice(0, 500);
430
+ const titlePrompt = `Generate a concise 3-5 word title for this conversation. Reply with only the title, nothing else.\n\nUser asked: "${truncatedPrompt}"\n\nAgent responded: "${truncatedResponse}"`;
431
+
432
+ q = query({
433
+ prompt: titlePrompt,
434
+ options: {
435
+ cwd: process.cwd(),
436
+ permissionMode: 'bypassPermissions',
437
+ allowDangerouslySkipPermissions: true,
438
+ },
439
+ });
440
+
441
+ let titleResponse = '';
442
+
443
+ for await (const message of q) {
444
+ if (message.type === 'assistant') {
445
+ for (const block of message.message.content) {
446
+ if (block.type === 'text') {
447
+ titleResponse += block.text;
448
+ }
449
+ }
450
+ }
451
+ }
452
+
453
+ const cleanTitle = titleResponse.trim().replace(/^["']|["']$/g, '').slice(0, 100);
454
+
455
+ if (cleanTitle) {
456
+ const db = getDb();
457
+ const now = new Date().toISOString();
458
+ db.prepare('UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?').run(cleanTitle, now, sessionId);
459
+ broadcast('session:title', { sessionId, title: cleanTitle });
460
+ }
461
+ } catch {
462
+ // Silently skip on error — session keeps null title
463
+ } finally {
464
+ if (q) {
465
+ q.close();
466
+ }
467
+ }
468
+ }
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // Provider export
472
+ // ---------------------------------------------------------------------------
473
+
474
+ /** Claude provider — implements RuntimeProvider interface. */
475
+ export const claudeProvider: RuntimeProvider = {
476
+ startTurn,
477
+ interruptTurn,
478
+ clearThread,
479
+ clearAllThreads,
480
+ getModels,
481
+ shutdown,
482
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Type re-exports and interfaces for the Claude provider.
3
+ *
4
+ * Re-exports needed types from `@anthropic-ai/claude-agent-sdk` so the
5
+ * provider file does not import the SDK directly.
6
+ */
7
+
8
+ import type {
9
+ Query,
10
+ SDKMessage,
11
+ Options,
12
+ ModelInfo as SdkModelInfo,
13
+ SDKControlInitializeResponse,
14
+ EffortLevel,
15
+ } from '@anthropic-ai/claude-agent-sdk';
16
+
17
+ export type { Query, SDKMessage, Options, EffortLevel, SdkModelInfo, SDKControlInitializeResponse };
18
+
19
+ /** Per-session Claude query instance + state. */
20
+ export interface ClaudeSessionEntry {
21
+ query: Query;
22
+ sessionId: string | null; // Claude session ID from SDKSystemMessage init
23
+ turnId: string | null; // current AgentPane turn ID
24
+ }
25
+
26
+ /** Map SDK effort values to UI labels. */
27
+ export const EFFORT_LABELS: Record<string, string> = {
28
+ low: 'Low',
29
+ medium: 'Medium',
30
+ high: 'High',
31
+ xhigh: 'Extra High',
32
+ max: 'Max',
33
+ };
@@ -0,0 +1,183 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { PassThrough } from 'node:stream';
3
+ import { EventEmitter } from 'node:events';
4
+
5
+ // Mock child_process.spawn to return a fake process with controllable streams
6
+ const mockStdin = new PassThrough();
7
+ const mockStdout = new PassThrough();
8
+ const mockStderr = new PassThrough();
9
+ const mockProc = new EventEmitter() as EventEmitter & {
10
+ stdin: PassThrough;
11
+ stdout: PassThrough;
12
+ stderr: PassThrough;
13
+ kill: ReturnType<typeof vi.fn>;
14
+ pid: number;
15
+ };
16
+ mockProc.stdin = mockStdin;
17
+ mockProc.stdout = mockStdout;
18
+ mockProc.stderr = mockStderr;
19
+ mockProc.kill = vi.fn();
20
+ mockProc.pid = 12345;
21
+
22
+ vi.mock('node:child_process', () => ({
23
+ spawn: vi.fn(() => mockProc),
24
+ }));
25
+
26
+ import { AppServerClient } from './app-server-client.ts';
27
+
28
+ // Capture lines written to stdin
29
+ let stdinLines: string[] = [];
30
+ mockStdin.on('data', (chunk: Buffer) => {
31
+ const text = chunk.toString();
32
+ for (const line of text.split('\n')) {
33
+ if (line.trim()) stdinLines.push(line);
34
+ }
35
+ });
36
+
37
+ /** Push a JSON-RPC response line to the mock stdout. */
38
+ function pushResponse(id: number, result: unknown): void {
39
+ mockStdout.push(JSON.stringify({ id, result }) + '\n');
40
+ }
41
+
42
+ /** Push a JSON-RPC notification to the mock stdout. */
43
+ function pushNotification(method: string, params: unknown): void {
44
+ mockStdout.push(JSON.stringify({ method, params }) + '\n');
45
+ }
46
+
47
+ describe('AppServerClient', () => {
48
+ let client: AppServerClient;
49
+
50
+ beforeEach(() => {
51
+ vi.clearAllMocks();
52
+ stdinLines = [];
53
+ mockProc.removeAllListeners();
54
+ mockProc.kill = vi.fn();
55
+ // Re-attach exit handler support
56
+ client = new AppServerClient({ cwd: '/tmp' });
57
+ });
58
+
59
+ afterEach(async () => {
60
+ await client.close();
61
+ });
62
+
63
+ it('initialize sends request and notification', async () => {
64
+ const initPromise = client.initialize();
65
+
66
+ // Wait for the initialize request to be written
67
+ await new Promise((r) => setTimeout(r, 10));
68
+
69
+ // First line should be the initialize request
70
+ const initReq = JSON.parse(stdinLines[0]);
71
+ expect(initReq.method).toBe('initialize');
72
+ expect(initReq.id).toBe(1);
73
+ expect(initReq.params.clientInfo.name).toBe('agentpane');
74
+
75
+ // Send initialize response
76
+ pushResponse(1, {
77
+ userAgent: 'test/0.147.0',
78
+ codexHome: '/tmp/.codex',
79
+ platformFamily: 'unix',
80
+ platformOs: 'macos',
81
+ });
82
+
83
+ const result = await initPromise;
84
+ expect(result.userAgent).toBe('test/0.147.0');
85
+
86
+ // Second line should be the initialized notification
87
+ const initNotify = JSON.parse(stdinLines[1]);
88
+ expect(initNotify.method).toBe('initialized');
89
+ expect(initNotify.id).toBeUndefined();
90
+ });
91
+
92
+ it('request resolves with result when matching response arrives', async () => {
93
+ const promise = client.request('model/list', {});
94
+
95
+ await new Promise((r) => setTimeout(r, 10));
96
+ const req = JSON.parse(stdinLines[0]);
97
+ expect(req.method).toBe('model/list');
98
+
99
+ pushResponse(req.id, { data: [{ id: 'gpt-5', displayName: 'GPT-5' }], nextCursor: null });
100
+
101
+ const result = await promise;
102
+ expect(result).toEqual({ data: [{ id: 'gpt-5', displayName: 'GPT-5' }], nextCursor: null });
103
+ });
104
+
105
+ it('request rejects on error response', async () => {
106
+ const promise = client.request('unknown/method', {});
107
+
108
+ await new Promise((r) => setTimeout(r, 10));
109
+ const req = JSON.parse(stdinLines[0]);
110
+
111
+ mockStdout.push(JSON.stringify({ id: req.id, error: { code: -32600, message: 'Invalid request' } }) + '\n');
112
+
113
+ await expect(promise).rejects.toThrow('JSON-RPC error -32600: Invalid request');
114
+ });
115
+
116
+ it('notify sends notification without expecting response', async () => {
117
+ client.notify('initialized');
118
+
119
+ await new Promise((r) => setTimeout(r, 10));
120
+ const msg = JSON.parse(stdinLines[0]);
121
+ expect(msg.method).toBe('initialized');
122
+ expect(msg.id).toBeUndefined();
123
+ });
124
+
125
+ it('onNotification handler is called when notification arrives', async () => {
126
+ const handler = vi.fn();
127
+ client.onNotification('item/completed', handler);
128
+
129
+ pushNotification('item/completed', { threadId: 't1', turnId: 'turn1', item: { id: 'i1', type: 'agent_message' } });
130
+
131
+ await new Promise((r) => setTimeout(r, 10));
132
+ expect(handler).toHaveBeenCalledTimes(1);
133
+ expect(handler).toHaveBeenCalledWith({
134
+ threadId: 't1',
135
+ turnId: 'turn1',
136
+ item: { id: 'i1', type: 'agent_message' },
137
+ });
138
+ });
139
+
140
+ it('process exit rejects all pending requests', async () => {
141
+ const promise1 = client.request('slow/method', {});
142
+ const promise2 = client.request('another/method', {});
143
+
144
+ await new Promise((r) => setTimeout(r, 10));
145
+
146
+ // Simulate process exit
147
+ mockProc.emit('exit', 1, null);
148
+
149
+ await expect(promise1).rejects.toThrow('App-server process exited');
150
+ await expect(promise2).rejects.toThrow('App-server process exited');
151
+ });
152
+
153
+ it('malformed JSON is ignored without crash', async () => {
154
+ pushNotification('item/completed', { item: { id: 'i1' } });
155
+
156
+ // Push malformed JSON line
157
+ mockStdout.push('not valid json\n');
158
+
159
+ // Should not crash — next valid message still works
160
+ const handler = vi.fn();
161
+ client.onNotification('test/method', handler);
162
+ pushNotification('test/method', { ok: true });
163
+
164
+ await new Promise((r) => setTimeout(r, 10));
165
+ expect(handler).toHaveBeenCalledTimes(1);
166
+ });
167
+
168
+ it('close rejects pending requests and kills process', async () => {
169
+ const promise = client.request('slow/method', {});
170
+
171
+ await new Promise((r) => setTimeout(r, 10));
172
+ await client.close();
173
+
174
+ await expect(promise).rejects.toThrow('AppServerClient closed');
175
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
176
+ });
177
+
178
+ it('isClosed returns true after close', async () => {
179
+ expect(client.isClosed()).toBe(false);
180
+ await client.close();
181
+ expect(client.isClosed()).toBe(true);
182
+ });
183
+ });