kcg-code 0.0.0-stage → 0.2.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 (307) hide show
  1. package/README.md +230 -2
  2. package/bin/kcgcode.ts +13 -0
  3. package/bun-env.d.ts +19 -0
  4. package/kcg-code.config.example.json +3 -0
  5. package/package.json +84 -4
  6. package/public/logo.svg +1 -0
  7. package/public/manifest.json +18 -0
  8. package/public/sw.js +49 -0
  9. package/src/App.tsx +39 -0
  10. package/src/__tests__/manifest.test.ts +61 -0
  11. package/src/build-config.ts +9 -0
  12. package/src/cli/__tests__/daemon.test.ts +84 -0
  13. package/src/cli/__tests__/dashboard.test.ts +224 -0
  14. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  15. package/src/cli/__tests__/port.test.ts +86 -0
  16. package/src/cli/args.ts +186 -0
  17. package/src/cli/attach.ts +154 -0
  18. package/src/cli/commands.ts +327 -0
  19. package/src/cli/daemon.ts +275 -0
  20. package/src/cli/dashboard.tsx +1060 -0
  21. package/src/cli/index.ts +47 -0
  22. package/src/cli/keep-awake.ts +148 -0
  23. package/src/cli/port.ts +92 -0
  24. package/src/cli/theme.ts +100 -0
  25. package/src/components/auth/AuthGate.tsx +182 -0
  26. package/src/components/auth/AvatarEditor.tsx +236 -0
  27. package/src/components/auth/LockScreen.tsx +714 -0
  28. package/src/components/auth/ProfileAvatar.tsx +60 -0
  29. package/src/components/auth/WelcomeScreen.tsx +672 -0
  30. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  31. package/src/components/auth/avatar-icons.tsx +114 -0
  32. package/src/components/layout/AppShell.tsx +147 -0
  33. package/src/components/layout/AppSidebar.tsx +419 -0
  34. package/src/components/layout/SearchDialog.tsx +189 -0
  35. package/src/components/layout/SidebarAccount.tsx +111 -0
  36. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  37. package/src/components/layout/ThemeToggle.tsx +67 -0
  38. package/src/components/projects/FolderBrowser.tsx +142 -0
  39. package/src/components/projects/HomeChat.tsx +287 -0
  40. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  41. package/src/components/projects/NewProjectDialog.tsx +249 -0
  42. package/src/components/projects/ProjectInstructions.tsx +218 -0
  43. package/src/components/projects/ProjectRow.tsx +58 -0
  44. package/src/components/projects/ProjectSidePanel.tsx +514 -0
  45. package/src/components/sessions/AgentLogo.tsx +42 -0
  46. package/src/components/sessions/AgentModeList.tsx +265 -0
  47. package/src/components/sessions/AgentPicker.tsx +132 -0
  48. package/src/components/sessions/AssistantTurn.tsx +145 -0
  49. package/src/components/sessions/AttachmentTile.tsx +161 -0
  50. package/src/components/sessions/CodeBlock.tsx +107 -0
  51. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  52. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  53. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
  54. package/src/components/sessions/ContextPanel.tsx +661 -0
  55. package/src/components/sessions/DropPlate.tsx +165 -0
  56. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  57. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  58. package/src/components/sessions/ImageLightbox.tsx +173 -0
  59. package/src/components/sessions/MarkdownContent.tsx +187 -0
  60. package/src/components/sessions/Mascot.tsx +47 -0
  61. package/src/components/sessions/MessageActions.tsx +60 -0
  62. package/src/components/sessions/MessageRail.tsx +121 -0
  63. package/src/components/sessions/ModelPicker.tsx +247 -0
  64. package/src/components/sessions/ModelSearchList.tsx +231 -0
  65. package/src/components/sessions/NewSessionDialog.tsx +172 -0
  66. package/src/components/sessions/PendingAttachments.tsx +127 -0
  67. package/src/components/sessions/PromptCard.tsx +1085 -0
  68. package/src/components/sessions/PromptPanel.tsx +112 -0
  69. package/src/components/sessions/SessionComposer.tsx +641 -0
  70. package/src/components/sessions/SessionHeader.tsx +176 -0
  71. package/src/components/sessions/SessionList.tsx +503 -0
  72. package/src/components/sessions/SessionRow.tsx +237 -0
  73. package/src/components/sessions/SessionTimeline.tsx +275 -0
  74. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  75. package/src/components/sessions/SessionView.tsx +240 -0
  76. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  77. package/src/components/sessions/UserMessage.tsx +177 -0
  78. package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
  79. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  80. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  81. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  82. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  83. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  84. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  85. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  86. package/src/components/settings/AboutGroup.tsx +170 -0
  87. package/src/components/settings/AccountGroup.tsx +122 -0
  88. package/src/components/settings/DevicesGroup.tsx +151 -0
  89. package/src/components/settings/LhrRemoteAccess.tsx +257 -0
  90. package/src/components/settings/PasskeysGroup.tsx +551 -0
  91. package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
  92. package/src/components/settings/SecurityGroup.tsx +566 -0
  93. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  94. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  95. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  96. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  97. package/src/components/settings/prefs.tsx +333 -0
  98. package/src/components/skills/InstallNotifications.tsx +326 -0
  99. package/src/components/skills/InstallSkillDialog.tsx +390 -0
  100. package/src/components/skills/SkillCards.tsx +254 -0
  101. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  102. package/src/components/ui/adw-dialog.tsx +114 -0
  103. package/src/components/ui/adw-toggle-group.tsx +75 -0
  104. package/src/components/ui/alert-dialog.tsx +179 -0
  105. package/src/components/ui/alert.tsx +60 -0
  106. package/src/components/ui/badge.tsx +46 -0
  107. package/src/components/ui/bubble.tsx +125 -0
  108. package/src/components/ui/button.tsx +62 -0
  109. package/src/components/ui/card.tsx +75 -0
  110. package/src/components/ui/collapsible.tsx +19 -0
  111. package/src/components/ui/dialog.tsx +142 -0
  112. package/src/components/ui/dropdown-menu.tsx +225 -0
  113. package/src/components/ui/empty.tsx +94 -0
  114. package/src/components/ui/field.tsx +234 -0
  115. package/src/components/ui/input-group.tsx +161 -0
  116. package/src/components/ui/input.tsx +21 -0
  117. package/src/components/ui/label.tsx +19 -0
  118. package/src/components/ui/message-scroller.tsx +131 -0
  119. package/src/components/ui/message.tsx +85 -0
  120. package/src/components/ui/popover.tsx +46 -0
  121. package/src/components/ui/search-entry.tsx +70 -0
  122. package/src/components/ui/select.tsx +172 -0
  123. package/src/components/ui/separator.tsx +28 -0
  124. package/src/components/ui/sheet.tsx +138 -0
  125. package/src/components/ui/sidebar.tsx +699 -0
  126. package/src/components/ui/skeleton.tsx +15 -0
  127. package/src/components/ui/sonner.tsx +45 -0
  128. package/src/components/ui/spinner.tsx +16 -0
  129. package/src/components/ui/switch.tsx +32 -0
  130. package/src/components/ui/textarea.tsx +18 -0
  131. package/src/components/ui/tooltip.tsx +51 -0
  132. package/src/config.ts +114 -0
  133. package/src/db.ts +146 -0
  134. package/src/frontend.tsx +51 -0
  135. package/src/hooks/__tests__/useMention.test.ts +79 -0
  136. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  137. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  138. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  139. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  140. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  141. package/src/hooks/use-mobile.ts +16 -0
  142. package/src/hooks/useAgentPicker.ts +101 -0
  143. package/src/hooks/useMediaQuery.ts +49 -0
  144. package/src/hooks/useMention.ts +236 -0
  145. package/src/hooks/useProjects.ts +88 -0
  146. package/src/hooks/useResyncOnReconnect.ts +28 -0
  147. package/src/hooks/useRouter.ts +92 -0
  148. package/src/hooks/useSessionChat.ts +427 -0
  149. package/src/hooks/useSessionContext.ts +106 -0
  150. package/src/hooks/useSessionList.ts +327 -0
  151. package/src/hooks/useSidebarData.ts +108 -0
  152. package/src/hooks/useSmoothText.ts +122 -0
  153. package/src/hooks/useTheme.ts +164 -0
  154. package/src/hooks/useWebSocket.ts +196 -0
  155. package/src/index.css +27 -0
  156. package/src/index.html +14 -0
  157. package/src/index.ts +49 -0
  158. package/src/lib/__tests__/attachments.test.ts +58 -0
  159. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  160. package/src/lib/__tests__/collapsible.test.ts +101 -0
  161. package/src/lib/__tests__/composer.test.ts +118 -0
  162. package/src/lib/__tests__/highlight.test.ts +42 -0
  163. package/src/lib/__tests__/home-chat.test.ts +64 -0
  164. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  165. package/src/lib/__tests__/model-picker.test.ts +205 -0
  166. package/src/lib/__tests__/onboarding.test.ts +41 -0
  167. package/src/lib/__tests__/permission-info.test.ts +61 -0
  168. package/src/lib/__tests__/project-extensions.test.ts +83 -0
  169. package/src/lib/__tests__/project-overview.test.ts +188 -0
  170. package/src/lib/__tests__/references.test.ts +121 -0
  171. package/src/lib/__tests__/routes.test.ts +134 -0
  172. package/src/lib/__tests__/session-selection.test.ts +41 -0
  173. package/src/lib/__tests__/session-summary.test.ts +115 -0
  174. package/src/lib/__tests__/session-title.test.ts +58 -0
  175. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  176. package/src/lib/__tests__/skill-installs.test.ts +135 -0
  177. package/src/lib/__tests__/skills-catalog.test.ts +124 -0
  178. package/src/lib/__tests__/tone.test.ts +37 -0
  179. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  180. package/src/lib/agent-types.ts +49 -0
  181. package/src/lib/api.ts +306 -0
  182. package/src/lib/attachments.ts +203 -0
  183. package/src/lib/auth.ts +92 -0
  184. package/src/lib/avatar-presets.ts +31 -0
  185. package/src/lib/collapsible.ts +75 -0
  186. package/src/lib/composer.ts +89 -0
  187. package/src/lib/data-events.ts +17 -0
  188. package/src/lib/headlines.ts +30 -0
  189. package/src/lib/highlight.ts +186 -0
  190. package/src/lib/layout.ts +20 -0
  191. package/src/lib/lock-screen.ts +54 -0
  192. package/src/lib/model-picker.ts +133 -0
  193. package/src/lib/onboarding.ts +38 -0
  194. package/src/lib/passkeys.ts +113 -0
  195. package/src/lib/pending-prompt.ts +53 -0
  196. package/src/lib/permission-info.ts +101 -0
  197. package/src/lib/project-extensions.ts +136 -0
  198. package/src/lib/project-overview.ts +123 -0
  199. package/src/lib/prompts.ts +87 -0
  200. package/src/lib/references.ts +106 -0
  201. package/src/lib/routes.ts +124 -0
  202. package/src/lib/session-handoff.ts +25 -0
  203. package/src/lib/session-selection.ts +40 -0
  204. package/src/lib/session-status.ts +45 -0
  205. package/src/lib/session-summary.ts +68 -0
  206. package/src/lib/session-title.ts +63 -0
  207. package/src/lib/sidebar-groups.ts +78 -0
  208. package/src/lib/skill-installs.ts +362 -0
  209. package/src/lib/skills-catalog.ts +138 -0
  210. package/src/lib/time.ts +22 -0
  211. package/src/lib/tone.ts +80 -0
  212. package/src/lib/turns.ts +320 -0
  213. package/src/lib/utils.ts +6 -0
  214. package/src/logo.svg +31 -0
  215. package/src/pages/projects/ProjectDetailPage.tsx +131 -0
  216. package/src/pages/projects/ProjectsPage.tsx +97 -0
  217. package/src/pages/sessions/SessionPage.tsx +122 -0
  218. package/src/pages/settings/SettingsPage.tsx +366 -0
  219. package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
  220. package/src/pages/skills/SkillsPage.tsx +745 -0
  221. package/src/paths.ts +121 -0
  222. package/src/runtime.ts +24 -0
  223. package/src/server/__tests__/app.test.ts +1044 -0
  224. package/src/server/__tests__/auth.test.ts +370 -0
  225. package/src/server/__tests__/config.test.ts +101 -0
  226. package/src/server/__tests__/db.test.ts +485 -0
  227. package/src/server/__tests__/host.test.ts +14 -0
  228. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  229. package/src/server/app.ts +427 -0
  230. package/src/server/db/auth.ts +311 -0
  231. package/src/server/db/messages.ts +50 -0
  232. package/src/server/db/projects.ts +106 -0
  233. package/src/server/db/prompts.ts +78 -0
  234. package/src/server/db/rows.ts +150 -0
  235. package/src/server/db/schema.ts +221 -0
  236. package/src/server/db/sessions.ts +239 -0
  237. package/src/server/db/tunnel.ts +124 -0
  238. package/src/server/host.ts +12 -0
  239. package/src/server/result.ts +15 -0
  240. package/src/server/routes/auth-guard.ts +156 -0
  241. package/src/server/routes/auth.routes.ts +287 -0
  242. package/src/server/routes/helpers.ts +165 -0
  243. package/src/server/routes/meta.routes.ts +24 -0
  244. package/src/server/routes/passkeys.routes.ts +161 -0
  245. package/src/server/routes/projects.routes.ts +167 -0
  246. package/src/server/routes/sessions.routes.ts +197 -0
  247. package/src/server/routes/skills.routes.ts +136 -0
  248. package/src/server/routes/tunnel.routes.ts +132 -0
  249. package/src/server/routes/types.ts +35 -0
  250. package/src/server/routes/uploads.routes.ts +54 -0
  251. package/src/server/routes/workspace.routes.ts +127 -0
  252. package/src/server/services/__tests__/app-info.test.ts +18 -0
  253. package/src/server/services/__tests__/attachments.test.ts +159 -0
  254. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  255. package/src/server/services/__tests__/auth.test.ts +341 -0
  256. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  257. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  258. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  259. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  260. package/src/server/services/__tests__/git-info.test.ts +74 -0
  261. package/src/server/services/__tests__/opencode.test.ts +320 -0
  262. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  263. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  264. package/src/server/services/__tests__/session-manager.test.ts +3509 -0
  265. package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
  266. package/src/server/services/__tests__/skills-registry.test.ts +365 -0
  267. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  268. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  269. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  270. package/src/server/services/__tests__/workspace.test.ts +249 -0
  271. package/src/server/services/app-info.ts +29 -0
  272. package/src/server/services/attachments.ts +193 -0
  273. package/src/server/services/auth-passkeys.ts +459 -0
  274. package/src/server/services/auth.ts +633 -0
  275. package/src/server/services/claude-code-client.ts +1093 -0
  276. package/src/server/services/claude-code-translate.ts +370 -0
  277. package/src/server/services/claude-mcp.ts +122 -0
  278. package/src/server/services/claude-skills.ts +102 -0
  279. package/src/server/services/frpc-installer.ts +183 -0
  280. package/src/server/services/git-info.ts +87 -0
  281. package/src/server/services/opencode-client.ts +714 -0
  282. package/src/server/services/opencode-server.ts +319 -0
  283. package/src/server/services/opencode-sse.ts +82 -0
  284. package/src/server/services/project-manager.ts +147 -0
  285. package/src/server/services/sandbox.ts +83 -0
  286. package/src/server/services/session-events.ts +256 -0
  287. package/src/server/services/session-manager.ts +1439 -0
  288. package/src/server/services/skill-install-jobs.ts +275 -0
  289. package/src/server/services/skills-registry.ts +480 -0
  290. package/src/server/services/tunnel-api.ts +207 -0
  291. package/src/server/services/tunnel-config.ts +45 -0
  292. package/src/server/services/tunnel-lhr.ts +387 -0
  293. package/src/server/services/tunnel.ts +633 -0
  294. package/src/server/services/turn-stream.ts +339 -0
  295. package/src/server/services/websocket-gateway.ts +374 -0
  296. package/src/server/services/workspace.ts +223 -0
  297. package/src/server/services/ws-transport.ts +121 -0
  298. package/src/types/context.ts +69 -0
  299. package/src/types/index.ts +13 -0
  300. package/src/types/message.ts +58 -0
  301. package/src/types/project.ts +16 -0
  302. package/src/types/prompt.ts +118 -0
  303. package/src/types/session.ts +52 -0
  304. package/src/types/turns.ts +47 -0
  305. package/src/ws-protocol.ts +93 -0
  306. package/styles/globals.css +311 -0
  307. package/tsconfig.json +37 -0
@@ -0,0 +1,1093 @@
1
+ /**
2
+ * Claude_Code_Client — backend Claude Code lewat Claude Agent SDK.
3
+ *
4
+ * Claude Code tidak punya mode server HTTP seperti `opencode serve`; SDK
5
+ * menjalankan CLI `claude` sebagai subprocess per Session (`query()` dengan
6
+ * streaming input). Modul ini membungkusnya di balik kontrak yang SAMA
7
+ * dengan `OpenCodeClient`/`OpenCodeServerManager`, sehingga Session_Manager,
8
+ * TurnStream, dan kartu Interactive_Prompt dipakai ulang apa adanya:
9
+ *
10
+ * - "Server" per Project hanyalah objek in-process (tanpa port); proses
11
+ * `claude` di-spawn malas saat prompt pertama sebuah Session dan ditutup
12
+ * setelah idle lama — prompt berikutnya melanjutkan via `resume`.
13
+ * - Pesan SDK diterjemahkan ke event gaya opencode (`claude-code-translate`)
14
+ * lalu dipancarkan ke subscriber seperti SSE `GET /event`.
15
+ * - `canUseTool` -> event `permission.asked` / `question.asked`; Promise-nya
16
+ * ditahan sampai `replyPermission` / `replyQuestion` dipanggil.
17
+ * - Konfigurasi mengikuti Claude Code user (`settingSources` user + project
18
+ * + local): auth, custom inference (`env` di settings.json), model
19
+ * default, MCP, skill, CLAUDE.md — sama seperti `claude` di terminal.
20
+ * - Akhir turn = `session_state_changed: idle` (otoritatif, diaktifkan lewat
21
+ * `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS`); fallback ke pesan `result`.
22
+ */
23
+ import { randomUUID } from "node:crypto";
24
+ import { existsSync, statSync } from "node:fs";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import type {
28
+ CanUseTool,
29
+ McpServerStatus,
30
+ ModelInfo,
31
+ Options,
32
+ PermissionMode,
33
+ PermissionResult,
34
+ PermissionUpdate,
35
+ SDKMessage,
36
+ SDKUserMessage,
37
+ } from "@anthropic-ai/claude-agent-sdk";
38
+ import {
39
+ deleteSession as sdkDeleteSession,
40
+ getSessionInfo as sdkGetSessionInfo,
41
+ query as sdkQuery,
42
+ } from "@anthropic-ai/claude-agent-sdk";
43
+ import type { SessionModel } from "../../types";
44
+ import type { Result, SimpleResult } from "../result";
45
+ import { ClaudeTurnTranslator, permissionName, permissionTarget } from "./claude-code-translate";
46
+ import { mergeClaudeMcp, readClaudeMcpConfig } from "./claude-mcp";
47
+ import { claudeGlobalSkillsDir, listClaudeSkills } from "./claude-skills";
48
+ import type {
49
+ AgentOption,
50
+ McpServerInfo,
51
+ ModelOption,
52
+ OpenCodeClient,
53
+ OpenCodeEvent,
54
+ OpenCodeFileChange,
55
+ OpenCodeFileRef,
56
+ OpenCodePermissionReply,
57
+ OpenCodeTodo,
58
+ } from "./opencode-client";
59
+ import { OC_REQUEST_GONE } from "./opencode-client";
60
+ import type { OpenCodeServerHandle, OpenCodeServerManager } from "./opencode-server";
61
+
62
+ /** `providerID` model Claude Code di `SessionModel` / pemilih model. */
63
+ export const CLAUDE_PROVIDER_ID = "claude-code";
64
+ /** Proses `claude` yang idle selama ini ditutup (prompt berikutnya resume). */
65
+ export const CLAUDE_IDLE_CLOSE_MS = 10 * 60_000;
66
+ /** Batas tunggu request kontrol SDK (daftar model, status MCP, interrupt). */
67
+ const CONTROL_TIMEOUT_MS = 30_000;
68
+ /** Cache daftar model (spawn proses hanya untuk daftar itu mahal). */
69
+ const MODELS_CACHE_MS = 5 * 60_000;
70
+ /** Baris stderr terakhir yang disimpan untuk pesan error crash. */
71
+ const STDERR_TAIL_LINES = 20;
72
+
73
+ /**
74
+ * Mode permission yang ditawarkan sebagai "agent" di pemilih mode composer.
75
+ * `default` = ikuti `permissions.defaultMode` di settings Claude Code user.
76
+ */
77
+ export const CLAUDE_AGENTS: AgentOption[] = [
78
+ { name: "default", mode: "primary", description: "Follow your Claude Code permission settings" },
79
+ { name: "acceptEdits", mode: "primary", description: "Auto-approve file edits" },
80
+ { name: "plan", mode: "primary", description: "Plan only — no edits or commands" },
81
+ ];
82
+
83
+ const PERMISSION_MODES = new Set<string>(["default", "acceptEdits", "plan"]);
84
+
85
+ /** Subset `Query` SDK yang dipakai (di-mock pada test). */
86
+ export interface ClaudeQueryLike extends AsyncIterable<SDKMessage> {
87
+ interrupt(): Promise<unknown>;
88
+ setModel(model?: string): Promise<void>;
89
+ setPermissionMode(mode: PermissionMode): Promise<void>;
90
+ supportedModels(): Promise<ModelInfo[]>;
91
+ mcpServerStatus(): Promise<McpServerStatus[]>;
92
+ close(): void;
93
+ }
94
+
95
+ export type ClaudeQueryFn = (params: {
96
+ prompt: AsyncIterable<SDKUserMessage>;
97
+ options: Options;
98
+ }) => ClaudeQueryLike;
99
+
100
+ /** Akses transcript Session di `~/.claude/projects` (di-mock pada test). */
101
+ export interface ClaudeTranscripts {
102
+ exists(sessionId: string, dir: string): Promise<boolean>;
103
+ remove(sessionId: string, dir: string): Promise<void>;
104
+ }
105
+
106
+ export interface ClaudeCodeClientOptions {
107
+ query?: ClaudeQueryFn;
108
+ transcripts?: ClaudeTranscripts;
109
+ /** Lingkungan proses `claude` (default `process.env`). */
110
+ env?: Record<string, string | undefined>;
111
+ /** Path binary `claude`; default binary bawaan SDK. */
112
+ executablePath?: string;
113
+ idleCloseMs?: number;
114
+ /** Jeda pengecekan ulang transcript setelah proses ditutup saat hapus. */
115
+ deleteSettleMs?: number;
116
+ now?: () => number;
117
+ setTimeoutFn?: (cb: () => void, ms: number) => unknown;
118
+ clearTimeoutFn?: (handle: unknown) => void;
119
+ }
120
+
121
+ export interface ClaudeCodeClient extends OpenCodeClient {
122
+ /** Tutup seluruh proses `claude` milik Project ini. */
123
+ close(): Promise<void>;
124
+ }
125
+
126
+ const defaultTranscripts: ClaudeTranscripts = {
127
+ async exists(sessionId, dir) {
128
+ try {
129
+ return (await sdkGetSessionInfo(sessionId, { dir })) !== undefined;
130
+ } catch {
131
+ return false;
132
+ }
133
+ },
134
+ async remove(sessionId, dir) {
135
+ await sdkDeleteSession(sessionId, { dir });
136
+ },
137
+ };
138
+
139
+ /** Antrean pesan user untuk streaming input `query()`. */
140
+ class InputQueue<T> implements AsyncIterable<T> {
141
+ private readonly items: T[] = [];
142
+ private waiter: ((r: IteratorResult<T>) => void) | null = null;
143
+ private closed = false;
144
+
145
+ push(item: T): void {
146
+ if (this.closed) return;
147
+ const waiter = this.waiter;
148
+ if (waiter) {
149
+ this.waiter = null;
150
+ waiter({ value: item, done: false });
151
+ } else {
152
+ this.items.push(item);
153
+ }
154
+ }
155
+
156
+ close(): void {
157
+ this.closed = true;
158
+ const waiter = this.waiter;
159
+ this.waiter = null;
160
+ waiter?.({ value: undefined, done: true });
161
+ }
162
+
163
+ [Symbol.asyncIterator](): AsyncIterator<T> {
164
+ return {
165
+ next: () => {
166
+ const item = this.items.shift();
167
+ if (item !== undefined) return Promise.resolve({ value: item, done: false });
168
+ if (this.closed) return Promise.resolve({ value: undefined, done: true });
169
+ return new Promise((resolve) => {
170
+ this.waiter = resolve;
171
+ });
172
+ },
173
+ };
174
+ }
175
+ }
176
+
177
+ /** Satu Session Claude Code (id = UUID session Claude Code). */
178
+ interface LiveSession {
179
+ id: string;
180
+ query: ClaudeQueryLike | null;
181
+ input: InputQueue<SDKUserMessage> | null;
182
+ translator: ClaudeTurnTranslator;
183
+ /** Model / mode / instruksi yang berlaku pada proses aktif. */
184
+ model: string | null;
185
+ mode: string | null;
186
+ system: string | null;
187
+ /** Transcript sudah ada di disk -> proses berikutnya pakai `resume`. */
188
+ persisted: boolean;
189
+ /** Turn sedang berjalan (dari `session_state_changed`). */
190
+ running: boolean;
191
+ /** CLI memancarkan `session_state_changed` (bila tidak: fallback `result`). */
192
+ sawState: boolean;
193
+ /** Idle berikutnya milik turn yang di-interrupt — jangan tutup turn baru. */
194
+ skipNextIdle: boolean;
195
+ /** Selesai saat turn yang di-interrupt benar-benar berhenti (idle). */
196
+ interruptSettled: { promise: Promise<void>; resolve: () => void } | null;
197
+ /** Error turn sudah dilaporkan (hindari banner ganda dari `result`). */
198
+ turnErrored: boolean;
199
+ /** Proses sengaja ditutup (bukan crash). */
200
+ closing: boolean;
201
+ todos: OpenCodeTodo[];
202
+ stderr: string[];
203
+ idleTimer: unknown;
204
+ /** Selesai saat stream proses aktif berakhir (proses keluar). */
205
+ pumpDone: Promise<void> | null;
206
+ }
207
+
208
+ interface PendingAsk {
209
+ sessionId: string;
210
+ kind: "permission" | "question";
211
+ input: Record<string, unknown>;
212
+ suggestions?: PermissionUpdate[];
213
+ allowAlways: boolean;
214
+ resolve: (result: PermissionResult) => void;
215
+ }
216
+
217
+ function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
218
+ let timer: ReturnType<typeof setTimeout> | undefined;
219
+ return Promise.race([
220
+ p,
221
+ new Promise<T>((_, reject) => {
222
+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), ms);
223
+ }),
224
+ ]).finally(() => clearTimeout(timer));
225
+ }
226
+
227
+ /** Status MCP SDK -> status gaya opencode yang dipakai UI. */
228
+ function mcpStatus(status: string): McpServerInfo["status"] {
229
+ switch (status) {
230
+ case "connected":
231
+ return "connected";
232
+ case "failed":
233
+ return "failed";
234
+ case "needs-auth":
235
+ return "needs_auth";
236
+ case "disabled":
237
+ return "disabled";
238
+ default:
239
+ return "unknown";
240
+ }
241
+ }
242
+
243
+ /** Gambar & PDF dikirim inline ke model; mime lain dirujuk lewat path. */
244
+ const INLINE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
245
+
246
+ /**
247
+ * Bangun konten pesan user: teks + gambar/PDF base64. Referensi `@file` dan
248
+ * lampiran non-inline ditulis sebagai daftar path absolut agar Claude
249
+ * membacanya sendiri dengan tool Read.
250
+ */
251
+ async function buildUserContent(
252
+ text: string,
253
+ files: OpenCodeFileRef[],
254
+ ): Promise<SDKUserMessage["message"]["content"]> {
255
+ const blocks: Exclude<SDKUserMessage["message"]["content"], string> = [];
256
+ const referenced: string[] = [];
257
+ for (const f of files) {
258
+ let absPath: string;
259
+ try {
260
+ absPath = fileURLToPath(f.url);
261
+ } catch {
262
+ continue;
263
+ }
264
+ if (INLINE_IMAGE_MIMES.has(f.mime) || f.mime === "application/pdf") {
265
+ try {
266
+ const data = Buffer.from(await Bun.file(absPath).arrayBuffer()).toString("base64");
267
+ if (f.mime === "application/pdf") {
268
+ blocks.push({
269
+ type: "document",
270
+ source: { type: "base64", media_type: "application/pdf", data },
271
+ });
272
+ } else {
273
+ blocks.push({
274
+ type: "image",
275
+ source: {
276
+ type: "base64",
277
+ media_type: f.mime as "image/png" | "image/jpeg" | "image/gif" | "image/webp",
278
+ data,
279
+ },
280
+ });
281
+ }
282
+ continue;
283
+ } catch {
284
+ // Gagal dibaca -> rujuk path saja.
285
+ }
286
+ }
287
+ referenced.push(`- ${f.filename}: ${absPath}`);
288
+ }
289
+ const fullText =
290
+ referenced.length === 0
291
+ ? text
292
+ : [text, "Referenced files:", ...referenced].filter((l) => l !== "").join("\n");
293
+ if (blocks.length === 0) return fullText;
294
+ return fullText.trim() === "" ? blocks : [{ type: "text", text: fullText }, ...blocks];
295
+ }
296
+
297
+ /** Jalankan `git` (argumen array, tanpa shell); stdout mentah atau null. */
298
+ async function runGit(cwd: string, args: string[]): Promise<string | null> {
299
+ try {
300
+ const proc = Bun.spawn(["git", ...args], {
301
+ cwd,
302
+ stdout: "pipe",
303
+ stderr: "ignore",
304
+ stdin: "ignore",
305
+ });
306
+ const timer = setTimeout(() => proc.kill(), 5000);
307
+ const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
308
+ clearTimeout(timer);
309
+ return code === 0 ? out : null;
310
+ } catch {
311
+ return null;
312
+ }
313
+ }
314
+
315
+ /** File yang berubah di worktree (`git status` + `git diff --numstat HEAD`). */
316
+ export async function gitFileStatus(cwd: string): Promise<OpenCodeFileChange[]> {
317
+ const status = await runGit(cwd, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
318
+ if (status === null) return [];
319
+ const counts = new Map<string, { added: number; removed: number }>();
320
+ const numstat = await runGit(cwd, ["diff", "--numstat", "-z", "HEAD"]);
321
+ if (numstat !== null) {
322
+ // Format -z: "added\tremoved\tpath\0" (rename: "a\tr\t\0old\0new\0").
323
+ const tokens = numstat.split("\0");
324
+ for (let i = 0; i < tokens.length; i++) {
325
+ const [a, r, p] = (tokens[i] ?? "").split("\t");
326
+ if (a === undefined || r === undefined) continue;
327
+ let file = p ?? "";
328
+ if (file === "") {
329
+ i += 2;
330
+ file = tokens[i] ?? "";
331
+ }
332
+ if (file === "") continue;
333
+ counts.set(file, {
334
+ added: Number.parseInt(a, 10) || 0,
335
+ removed: Number.parseInt(r, 10) || 0,
336
+ });
337
+ }
338
+ }
339
+ const out: OpenCodeFileChange[] = [];
340
+ const entries = status.split("\0");
341
+ for (let i = 0; i < entries.length; i++) {
342
+ const entry = entries[i] ?? "";
343
+ if (entry.length < 4) continue;
344
+ const xy = entry.slice(0, 2);
345
+ const file = entry.slice(3);
346
+ // Rename/copy: entri berikutnya adalah path asal — lewati.
347
+ if (xy[0] === "R" || xy[0] === "C") i += 1;
348
+ const kind: OpenCodeFileChange["status"] =
349
+ xy === "??" || xy.includes("A") ? "added" : xy.includes("D") ? "deleted" : "modified";
350
+ const c = counts.get(file) ?? { added: 0, removed: 0 };
351
+ out.push({ path: file, added: c.added, removed: c.removed, status: kind });
352
+ }
353
+ return out;
354
+ }
355
+
356
+ /** Peringkat path untuk autocomplete `@file` (null = tidak cocok). */
357
+ function matchScore(file: string, q: string): number | null {
358
+ if (q === "") return file.length;
359
+ const lower = file.toLowerCase();
360
+ const base = lower.slice(lower.lastIndexOf("/") + 1);
361
+ if (base.startsWith(q)) return file.length;
362
+ if (base.includes(q)) return 1000 + file.length;
363
+ if (lower.includes(q)) return 2000 + file.length;
364
+ return null;
365
+ }
366
+
367
+ export function createClaudeCodeClient(
368
+ cwd: string,
369
+ opts: ClaudeCodeClientOptions = {},
370
+ ): ClaudeCodeClient {
371
+ const runQuery: ClaudeQueryFn =
372
+ opts.query ?? ((params) => sdkQuery(params) as unknown as ClaudeQueryLike);
373
+ const transcripts = opts.transcripts ?? defaultTranscripts;
374
+ const baseEnv = opts.env ?? process.env;
375
+ const idleCloseMs = opts.idleCloseMs ?? CLAUDE_IDLE_CLOSE_MS;
376
+ const deleteSettleMs = opts.deleteSettleMs ?? 1000;
377
+ const now = opts.now ?? Date.now;
378
+ const setTimeoutFn = opts.setTimeoutFn ?? ((cb: () => void, ms: number) => setTimeout(cb, ms));
379
+ const clearTimeoutFn =
380
+ opts.clearTimeoutFn ??
381
+ ((handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>));
382
+
383
+ const listeners = new Set<(ev: OpenCodeEvent) => void>();
384
+ const sessions = new Map<string, LiveSession>();
385
+ const pending = new Map<string, PendingAsk>();
386
+ /** Status MCP terakhir dari pesan `system/init`. */
387
+ let lastMcp: McpServerInfo[] = [];
388
+ let modelsCache: { at: number; data: ModelOption[] } | null = null;
389
+ let fileCache: { at: number; files: string[] } | null = null;
390
+
391
+ function emit(ev: OpenCodeEvent): void {
392
+ for (const cb of listeners) {
393
+ try {
394
+ cb(ev);
395
+ } catch (e) {
396
+ console.warn("[kcg-code] claude-code event handler gagal:", e);
397
+ }
398
+ }
399
+ }
400
+
401
+ function getOrCreate(id: string, persisted: boolean): LiveSession {
402
+ let live = sessions.get(id);
403
+ if (!live) {
404
+ live = {
405
+ id,
406
+ query: null,
407
+ input: null,
408
+ translator: new ClaudeTurnTranslator(id, emit, now),
409
+ model: null,
410
+ mode: null,
411
+ system: null,
412
+ persisted,
413
+ running: false,
414
+ sawState: false,
415
+ skipNextIdle: false,
416
+ interruptSettled: null,
417
+ turnErrored: false,
418
+ closing: false,
419
+ todos: [],
420
+ stderr: [],
421
+ idleTimer: undefined,
422
+ pumpDone: null,
423
+ };
424
+ sessions.set(id, live);
425
+ }
426
+ return live;
427
+ }
428
+
429
+ function sharedOptions(): Options {
430
+ return {
431
+ cwd,
432
+ // Ikuti konfigurasi Claude Code user apa adanya (auth, custom
433
+ // inference, model, MCP, CLAUDE.md) — konsisten dengan `claude` CLI.
434
+ settingSources: ["user", "project", "local"],
435
+ env: { ...baseEnv, CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1" },
436
+ ...(opts.executablePath ? { pathToClaudeCodeExecutable: opts.executablePath } : {}),
437
+ };
438
+ }
439
+
440
+ function cancelIdleClose(live: LiveSession): void {
441
+ if (live.idleTimer !== undefined) {
442
+ clearTimeoutFn(live.idleTimer);
443
+ live.idleTimer = undefined;
444
+ }
445
+ }
446
+
447
+ /** Tutup proses `claude` Session (sengaja — bukan crash). */
448
+ function closeQuery(live: LiveSession): void {
449
+ cancelIdleClose(live);
450
+ if (!live.query) return;
451
+ live.closing = true;
452
+ live.input?.close();
453
+ try {
454
+ live.query.close();
455
+ } catch {
456
+ // Proses sudah mati.
457
+ }
458
+ live.query = null;
459
+ live.input = null;
460
+ live.running = false;
461
+ settleInterrupt(live);
462
+ }
463
+
464
+ function scheduleIdleClose(live: LiveSession): void {
465
+ cancelIdleClose(live);
466
+ live.idleTimer = setTimeoutFn(() => {
467
+ live.idleTimer = undefined;
468
+ if (!live.running) closeQuery(live);
469
+ }, idleCloseMs);
470
+ }
471
+
472
+ /** Tolak seluruh ask yang menunggu untuk Session (abort/close). */
473
+ function dropPending(sessionId: string, message: string): void {
474
+ for (const [id, ask] of pending) {
475
+ if (ask.sessionId !== sessionId) continue;
476
+ pending.delete(id);
477
+ ask.resolve({ behavior: "deny", message, interrupt: true });
478
+ }
479
+ }
480
+
481
+ function settleInterrupt(live: LiveSession): void {
482
+ live.skipNextIdle = false;
483
+ live.interruptSettled?.resolve();
484
+ live.interruptSettled = null;
485
+ }
486
+
487
+ function emitIdle(live: LiveSession): void {
488
+ live.running = false;
489
+ if (live.skipNextIdle) {
490
+ // Idle milik turn yang di-interrupt: turn lokal sudah dibuang.
491
+ settleInterrupt(live);
492
+ return;
493
+ }
494
+ emit({ type: "session.idle", sessionID: live.id });
495
+ }
496
+
497
+ function emitError(live: LiveSession, message: string): void {
498
+ emit({
499
+ type: "session.error",
500
+ sessionID: live.id,
501
+ error: { name: "UnknownError", data: { message } },
502
+ });
503
+ }
504
+
505
+ function handleMessage(live: LiveSession, msg: SDKMessage): void {
506
+ const m = msg as unknown as Record<string, unknown>;
507
+ // Tanda hidup apa pun -> reset timer idle turn (tool lama, retry, dll).
508
+ if (live.running)
509
+ emit({ type: "session.status", sessionID: live.id, status: { type: "busy" } });
510
+
511
+ if (m.type === "system") {
512
+ if (m.subtype === "init") {
513
+ live.persisted = true;
514
+ const servers = Array.isArray(m.mcp_servers) ? m.mcp_servers : [];
515
+ lastMcp = servers
516
+ .filter((s): s is { name: string; status: string } => typeof s?.name === "string")
517
+ .map((s) => ({ name: s.name, status: mcpStatus(String(s.status)), error: null }));
518
+ return;
519
+ }
520
+ if (m.subtype === "session_state_changed") {
521
+ live.sawState = true;
522
+ if (m.state === "running") {
523
+ live.running = true;
524
+ cancelIdleClose(live);
525
+ } else if (m.state === "idle") {
526
+ emitIdle(live);
527
+ scheduleIdleClose(live);
528
+ }
529
+ return;
530
+ }
531
+ }
532
+
533
+ if (m.type === "assistant" && typeof m.error === "string") {
534
+ // Error API (auth, rate limit, model tidak ada) dilaporkan CLI sebagai
535
+ // pesan assistant sintetis — tampilkan sebagai error, bukan jawaban.
536
+ const content = (m.message as { content?: unknown } | undefined)?.content;
537
+ const text = Array.isArray(content)
538
+ ? content
539
+ .map((b) => (b as { text?: unknown })?.text)
540
+ .filter((t): t is string => typeof t === "string")
541
+ .join("\n")
542
+ : "";
543
+ live.turnErrored = true;
544
+ emitError(live, text.trim() || `Claude Code error: ${m.error.replace(/_/g, " ")}`);
545
+ return;
546
+ }
547
+
548
+ if (m.type === "result") {
549
+ // Turn yang di-interrupt sudah dibuang Session_Manager: hasilnya
550
+ // (biasanya error_during_execution) bukan kegagalan yang perlu banner.
551
+ const aborted = live.skipNextIdle;
552
+ if (!aborted && !live.turnErrored && (m.subtype !== "success" || m.is_error === true)) {
553
+ const errors = Array.isArray(m.errors) ? m.errors.filter((e) => typeof e === "string") : [];
554
+ const message =
555
+ errors.join("\n") ||
556
+ (typeof m.result === "string" && m.result) ||
557
+ `Claude Code stopped: ${String(m.subtype).replace(/_/g, " ")}`;
558
+ emitError(live, message);
559
+ }
560
+ live.turnErrored = false;
561
+ // CLI lama tanpa `session_state_changed`: `result` menutup turn.
562
+ if (!live.sawState) {
563
+ emitIdle(live);
564
+ scheduleIdleClose(live);
565
+ }
566
+ return;
567
+ }
568
+
569
+ const effects = live.translator.handle(m);
570
+ if (effects.todos) live.todos = effects.todos;
571
+ }
572
+
573
+ /** Baca stream proses sampai selesai; crash dilaporkan sebagai error turn. */
574
+ async function pump(live: LiveSession, q: ClaudeQueryLike): Promise<void> {
575
+ try {
576
+ for await (const msg of q) {
577
+ if (live.query !== q) break;
578
+ handleMessage(live, msg);
579
+ }
580
+ } catch (e) {
581
+ if (live.query === q && !live.closing) {
582
+ const tail = live.stderr.slice(-5).join("\n").trim();
583
+ const detail = (e as Error).message ?? String(e);
584
+ emitError(live, `Claude Code exited unexpectedly: ${detail}${tail ? `\n${tail}` : ""}`);
585
+ }
586
+ } finally {
587
+ if (live.query === q) {
588
+ const wasRunning = live.running;
589
+ live.query = null;
590
+ live.input?.close();
591
+ live.input = null;
592
+ dropPending(live.id, "Claude Code process ended.");
593
+ if (wasRunning && !live.closing) emitIdle(live);
594
+ live.running = false;
595
+ settleInterrupt(live);
596
+ }
597
+ live.closing = false;
598
+ }
599
+ }
600
+
601
+ async function canUseTool(
602
+ live: LiveSession,
603
+ toolName: string,
604
+ input: Record<string, unknown>,
605
+ options: Parameters<CanUseTool>[2],
606
+ ): Promise<PermissionResult> {
607
+ const isQuestion = toolName === "AskUserQuestion";
608
+ const requestID = `${isQuestion ? "que" : "per"}_${randomUUID()}`;
609
+ const result = new Promise<PermissionResult>((resolve) => {
610
+ pending.set(requestID, {
611
+ sessionId: live.id,
612
+ kind: isQuestion ? "question" : "permission",
613
+ input,
614
+ suggestions: options.suggestions,
615
+ allowAlways: options.suppressAlwaysAllowRule !== true,
616
+ resolve,
617
+ });
618
+ options.signal.addEventListener(
619
+ "abort",
620
+ () => {
621
+ if (!pending.delete(requestID)) return;
622
+ resolve({ behavior: "deny", message: "Aborted.", interrupt: true });
623
+ },
624
+ { once: true },
625
+ );
626
+ });
627
+
628
+ if (isQuestion) {
629
+ const questions = Array.isArray(input.questions) ? input.questions : [];
630
+ emit({
631
+ type: "question.asked",
632
+ sessionID: live.id,
633
+ requestID,
634
+ questions: questions.map((q) => {
635
+ const item = (q ?? {}) as Record<string, unknown>;
636
+ return {
637
+ question: item.question,
638
+ header: item.header,
639
+ options: item.options,
640
+ multiple: item.multiSelect === true,
641
+ custom: true,
642
+ };
643
+ }),
644
+ });
645
+ } else {
646
+ // Akses di luar direktori project dilaporkan CLI lewat `blockedPath`.
647
+ const blocked = typeof options.blockedPath === "string" ? options.blockedPath : null;
648
+ const target = blocked ?? permissionTarget(input);
649
+ emit({
650
+ type: "permission.asked",
651
+ sessionID: live.id,
652
+ requestID,
653
+ permission: blocked ? "external_directory" : permissionName(toolName),
654
+ patterns: target ? [target] : [],
655
+ });
656
+ }
657
+ return result;
658
+ }
659
+
660
+ /** Spawn proses `claude` untuk Session (resume bila transcript sudah ada). */
661
+ async function startQuery(
662
+ live: LiveSession,
663
+ model: string | null,
664
+ mode: string | null,
665
+ system: string | null,
666
+ ): Promise<void> {
667
+ if (!live.persisted) live.persisted = await transcripts.exists(live.id, cwd);
668
+ const input = new InputQueue<SDKUserMessage>();
669
+ const options: Options = {
670
+ ...sharedOptions(),
671
+ includePartialMessages: true,
672
+ ...(live.persisted ? { resume: live.id } : { sessionId: live.id }),
673
+ ...(model ? { model } : {}),
674
+ // Tanpa mode eksplisit: ikuti `permissions.defaultMode` user.
675
+ ...(mode && mode !== "default" ? { permissionMode: mode as PermissionMode } : {}),
676
+ ...(system
677
+ ? {
678
+ systemPrompt: {
679
+ type: "preset" as const,
680
+ preset: "claude_code" as const,
681
+ append: system,
682
+ },
683
+ }
684
+ : {}),
685
+ canUseTool: (toolName, toolInput, o) => canUseTool(live, toolName, toolInput, o),
686
+ stderr: (data) => {
687
+ live.stderr.push(...data.split("\n").filter((l) => l.trim() !== ""));
688
+ if (live.stderr.length > STDERR_TAIL_LINES)
689
+ live.stderr.splice(0, live.stderr.length - STDERR_TAIL_LINES);
690
+ },
691
+ };
692
+ const q = runQuery({ prompt: input, options });
693
+ live.query = q;
694
+ live.input = input;
695
+ live.model = model;
696
+ live.mode = mode;
697
+ live.system = system;
698
+ live.closing = false;
699
+ live.sawState = false;
700
+ live.stderr = [];
701
+ live.pumpDone = pump(live, q);
702
+ }
703
+
704
+ function selectedModel(model?: SessionModel | null): string | null {
705
+ if (!model || model.providerID !== CLAUDE_PROVIDER_ID) return null;
706
+ return model.modelID === "default" ? null : model.modelID;
707
+ }
708
+
709
+ function selectedMode(agent?: string | null): string | null {
710
+ return agent && PERMISSION_MODES.has(agent) ? agent : null;
711
+ }
712
+
713
+ async function promptAsync(
714
+ sessionId: string,
715
+ text: string,
716
+ model?: SessionModel | null,
717
+ files: OpenCodeFileRef[] = [],
718
+ agent?: string | null,
719
+ system?: string | null,
720
+ ): Promise<Result<null>> {
721
+ const live = getOrCreate(sessionId, false);
722
+ const wantModel = selectedModel(model);
723
+ const wantMode = selectedMode(agent);
724
+ const wantSystem = system?.trim() ? system : null;
725
+ try {
726
+ const content = await buildUserContent(text, files);
727
+ // Prompt tepat setelah interrupt: tunggu turn lama benar-benar berhenti
728
+ // agar idle-nya tidak menutup turn baru ini.
729
+ if (live.interruptSettled) {
730
+ await withTimeout(live.interruptSettled.promise, 15_000, "interrupt").catch(() =>
731
+ settleInterrupt(live),
732
+ );
733
+ }
734
+ cancelIdleClose(live);
735
+ // Instruksi Project hanya bisa dipasang saat proses dibuat: bila
736
+ // berubah dan tidak ada turn berjalan, proses diganti (resume).
737
+ if (live.query && live.system !== wantSystem && !live.running) closeQuery(live);
738
+ if (!live.query) {
739
+ await startQuery(live, wantModel, wantMode, wantSystem);
740
+ } else {
741
+ const q = live.query;
742
+ if (wantModel !== live.model) {
743
+ await withTimeout(q.setModel(wantModel ?? undefined), CONTROL_TIMEOUT_MS, "setModel");
744
+ live.model = wantModel;
745
+ }
746
+ if (wantMode !== live.mode) {
747
+ await withTimeout(
748
+ q.setPermissionMode((wantMode ?? "default") as PermissionMode),
749
+ CONTROL_TIMEOUT_MS,
750
+ "setPermissionMode",
751
+ );
752
+ live.mode = wantMode;
753
+ }
754
+ }
755
+ live.turnErrored = false;
756
+ live.translator.beginTurn();
757
+ live.input?.push({
758
+ type: "user",
759
+ message: { role: "user", content },
760
+ parent_tool_use_id: null,
761
+ });
762
+ return { ok: true, data: null };
763
+ } catch (e) {
764
+ return { ok: false, error: `Failed to start Claude Code: ${(e as Error).message}` };
765
+ }
766
+ }
767
+
768
+ /** Query sementara (tanpa prompt) untuk request kontrol saat tidak ada proses. */
769
+ async function withAnyQuery<T>(fn: (q: ClaudeQueryLike) => Promise<T>): Promise<T> {
770
+ for (const live of sessions.values()) {
771
+ if (live.query) return withTimeout(fn(live.query), CONTROL_TIMEOUT_MS, "control request");
772
+ }
773
+ const input = new InputQueue<SDKUserMessage>();
774
+ const q = runQuery({ prompt: input, options: { ...sharedOptions(), persistSession: false } });
775
+ // Stream harus dikonsumsi agar respons kontrol diproses.
776
+ const drain = (async () => {
777
+ try {
778
+ for await (const _ of q) {
779
+ // abaikan
780
+ }
781
+ } catch {
782
+ // proses ditutup
783
+ }
784
+ })();
785
+ try {
786
+ return await withTimeout(fn(q), CONTROL_TIMEOUT_MS, "control request");
787
+ } finally {
788
+ input.close();
789
+ q.close();
790
+ void drain;
791
+ }
792
+ }
793
+
794
+ async function listModels(): Promise<Result<ModelOption[]>> {
795
+ if (modelsCache && now() - modelsCache.at < MODELS_CACHE_MS) {
796
+ return { ok: true, data: modelsCache.data };
797
+ }
798
+ try {
799
+ const models = await withAnyQuery((q) => q.supportedModels());
800
+ const data = models.map((m) => ({
801
+ providerID: CLAUDE_PROVIDER_ID,
802
+ providerName: "Claude Code",
803
+ modelID: m.value,
804
+ name: m.displayName || m.value,
805
+ }));
806
+ modelsCache = { at: now(), data };
807
+ return { ok: true, data };
808
+ } catch (e) {
809
+ return { ok: false, error: `CC_MODELS_FAILED: ${(e as Error).message}` };
810
+ }
811
+ }
812
+
813
+ async function listMcp(): Promise<Result<McpServerInfo[]>> {
814
+ for (const live of sessions.values()) {
815
+ if (!live.query) continue;
816
+ try {
817
+ const status = await withTimeout(
818
+ live.query.mcpServerStatus(),
819
+ CONTROL_TIMEOUT_MS,
820
+ "mcpServerStatus",
821
+ );
822
+ lastMcp = status.map((s) => ({
823
+ name: s.name,
824
+ status: mcpStatus(s.status),
825
+ error: s.error ?? null,
826
+ }));
827
+ } catch {
828
+ // pakai status terakhir
829
+ }
830
+ break;
831
+ }
832
+ // Server terkonfigurasi tetap tampil walau belum ada proses `claude`
833
+ // (status `unknown` sampai proses berjalan dan melaporkannya).
834
+ const configured = await readClaudeMcpConfig(cwd, baseEnv);
835
+ return { ok: true, data: mergeClaudeMcp(configured, lastMcp) };
836
+ }
837
+
838
+ async function projectFiles(): Promise<string[]> {
839
+ if (fileCache && now() - fileCache.at < 10_000) return fileCache.files;
840
+ const out = await runGit(cwd, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
841
+ let files: string[];
842
+ if (out !== null) {
843
+ files = out.split("\0").filter((f) => f !== "");
844
+ } else {
845
+ files = [];
846
+ const glob = new Bun.Glob("**/*");
847
+ for await (const f of glob.scan({ cwd, onlyFiles: true })) {
848
+ if (f.startsWith("node_modules/") || f.startsWith(".git/")) continue;
849
+ files.push(f);
850
+ if (files.length >= 20_000) break;
851
+ }
852
+ }
853
+ fileCache = { at: now(), files };
854
+ return files;
855
+ }
856
+
857
+ async function findFiles(q: string): Promise<Result<string[]>> {
858
+ try {
859
+ const needle = q.trim().toLowerCase();
860
+ const scored: { file: string; score: number }[] = [];
861
+ for (const file of await projectFiles()) {
862
+ const score = matchScore(file, needle);
863
+ if (score !== null) scored.push({ file, score });
864
+ }
865
+ scored.sort((a, b) => a.score - b.score);
866
+ return { ok: true, data: scored.slice(0, 50).map((s) => s.file) };
867
+ } catch (e) {
868
+ return { ok: false, error: `CC_FIND_FILES_FAILED: ${(e as Error).message}` };
869
+ }
870
+ }
871
+
872
+ function takePending(requestId: string, kind: PendingAsk["kind"]): PendingAsk | null {
873
+ const ask = pending.get(requestId);
874
+ if (!ask || ask.kind !== kind) return null;
875
+ pending.delete(requestId);
876
+ return ask;
877
+ }
878
+
879
+ async function replyPermission(
880
+ requestId: string,
881
+ reply: OpenCodePermissionReply,
882
+ ): Promise<Result<unknown>> {
883
+ const ask = takePending(requestId, "permission");
884
+ if (!ask) return { ok: false, error: OC_REQUEST_GONE };
885
+ if (reply === "reject") {
886
+ ask.resolve({ behavior: "deny", message: "The user denied this tool call." });
887
+ } else {
888
+ // "Always allow" berlaku selama Session (seperti opencode) — tidak
889
+ // menulis aturan ke file settings user dari satu ketukan di HP.
890
+ const updatedPermissions =
891
+ reply === "always" && ask.allowAlways
892
+ ? ask.suggestions?.map((s) => ({ ...s, destination: "session" as const }))
893
+ : undefined;
894
+ ask.resolve({
895
+ behavior: "allow",
896
+ updatedInput: ask.input,
897
+ ...(updatedPermissions?.length ? { updatedPermissions } : {}),
898
+ });
899
+ }
900
+ return { ok: true, data: null };
901
+ }
902
+
903
+ async function replyQuestion(requestId: string, answers: string[][]): Promise<Result<unknown>> {
904
+ const ask = takePending(requestId, "question");
905
+ if (!ask) return { ok: false, error: OC_REQUEST_GONE };
906
+ const questions = Array.isArray(ask.input.questions) ? ask.input.questions : [];
907
+ // Format jawaban AskUserQuestion: teks pertanyaan -> label (multi-select
908
+ // digabung koma).
909
+ const map: Record<string, string> = {};
910
+ questions.forEach((q, i) => {
911
+ const question = (q as { question?: unknown } | null)?.question;
912
+ if (typeof question === "string") map[question] = (answers[i] ?? []).join(", ");
913
+ });
914
+ ask.resolve({ behavior: "allow", updatedInput: { ...ask.input, answers: map } });
915
+ return { ok: true, data: null };
916
+ }
917
+
918
+ async function rejectQuestion(requestId: string): Promise<Result<unknown>> {
919
+ const ask = takePending(requestId, "question");
920
+ if (!ask) return { ok: false, error: OC_REQUEST_GONE };
921
+ ask.resolve({ behavior: "deny", message: "The user dismissed the question." });
922
+ return { ok: true, data: null };
923
+ }
924
+
925
+ async function abortSession(sessionId: string): Promise<Result<unknown>> {
926
+ const live = sessions.get(sessionId);
927
+ if (!live?.query || !live.running) return { ok: true, data: null };
928
+ live.skipNextIdle = true;
929
+ if (!live.interruptSettled) {
930
+ let resolve = () => {};
931
+ const promise = new Promise<void>((r) => {
932
+ resolve = r;
933
+ });
934
+ live.interruptSettled = { promise, resolve };
935
+ }
936
+ dropPending(sessionId, "Interrupted by the user.");
937
+ try {
938
+ await withTimeout(live.query.interrupt(), CONTROL_TIMEOUT_MS, "interrupt");
939
+ return { ok: true, data: null };
940
+ } catch (e) {
941
+ return { ok: false, error: `CC_ABORT_FAILED: ${(e as Error).message}` };
942
+ }
943
+ }
944
+
945
+ async function deleteSession(sessionId: string): Promise<SimpleResult> {
946
+ const live = sessions.get(sessionId);
947
+ const wasLive = live?.query != null;
948
+ if (live) {
949
+ dropPending(sessionId, "Session deleted.");
950
+ const done = live.pumpDone;
951
+ closeQuery(live);
952
+ sessions.delete(sessionId);
953
+ // CLI menulis metadata (judul, biaya) ke transcript saat keluar —
954
+ // tunggu proses berhenti agar file tidak muncul lagi setelah dihapus.
955
+ if (wasLive && done) await withTimeout(done, 5000, "close").catch(() => {});
956
+ }
957
+ try {
958
+ if (await transcripts.exists(sessionId, cwd)) await transcripts.remove(sessionId, cwd);
959
+ if (wasLive) {
960
+ await Bun.sleep(deleteSettleMs);
961
+ if (await transcripts.exists(sessionId, cwd)) await transcripts.remove(sessionId, cwd);
962
+ }
963
+ return { ok: true };
964
+ } catch (e) {
965
+ return { ok: false, error: `CC_DELETE_FAILED: ${(e as Error).message}` };
966
+ }
967
+ }
968
+
969
+ return {
970
+ async createSession() {
971
+ // Proses `claude` baru di-spawn saat prompt pertama (`sessionId` ini).
972
+ const id = randomUUID();
973
+ getOrCreate(id, false);
974
+ return { ok: true, data: { id, directory: cwd } };
975
+ },
976
+ async getSession(sessionId) {
977
+ if (sessions.has(sessionId)) return { ok: true };
978
+ if (await transcripts.exists(sessionId, cwd)) {
979
+ getOrCreate(sessionId, true);
980
+ return { ok: true };
981
+ }
982
+ return { ok: false, error: "SESSION_NOT_FOUND" };
983
+ },
984
+ listModels,
985
+ async listAgents() {
986
+ return { ok: true, data: CLAUDE_AGENTS };
987
+ },
988
+ listMcp,
989
+ async listSkills() {
990
+ try {
991
+ return { ok: true, data: await listClaudeSkills(cwd, claudeGlobalSkillsDir(baseEnv)) };
992
+ } catch (e) {
993
+ return { ok: false, error: `CC_SKILLS_FAILED: ${(e as Error).message}` };
994
+ }
995
+ },
996
+ async disposeInstance() {
997
+ // Skill/config dibaca ulang tiap proses `claude` baru.
998
+ modelsCache = null;
999
+ return { ok: true };
1000
+ },
1001
+ deleteSession,
1002
+ async sendMessage() {
1003
+ return { ok: false, error: "CC_SYNC_MESSAGE_UNSUPPORTED" };
1004
+ },
1005
+ promptAsync,
1006
+ findFiles,
1007
+ async listTodos(sessionId) {
1008
+ return { ok: true, data: sessions.get(sessionId)?.todos ?? [] };
1009
+ },
1010
+ async fileStatus() {
1011
+ return { ok: true, data: await gitFileStatus(cwd) };
1012
+ },
1013
+ replyPermission,
1014
+ replyQuestion,
1015
+ rejectQuestion,
1016
+ abortSession,
1017
+ subscribeEvents(cb) {
1018
+ listeners.add(cb);
1019
+ return () => {
1020
+ listeners.delete(cb);
1021
+ };
1022
+ },
1023
+ async health() {
1024
+ return true;
1025
+ },
1026
+ async close() {
1027
+ for (const live of sessions.values()) {
1028
+ dropPending(live.id, "Claude Code backend stopped.");
1029
+ closeQuery(live);
1030
+ }
1031
+ },
1032
+ };
1033
+ }
1034
+
1035
+ export interface ClaudeCodeServerManagerOptions {
1036
+ clientFactory?: (cwd: string) => ClaudeCodeClient;
1037
+ }
1038
+
1039
+ /**
1040
+ * "Server" Claude Code per Project — kontrak sama dengan
1041
+ * `OpenCodeServerManager`, tapi tanpa proses/port: hanya klien in-process
1042
+ * yang mengelola proses `claude` per Session.
1043
+ */
1044
+ export function createClaudeCodeServerManager(
1045
+ opts: ClaudeCodeServerManagerOptions = {},
1046
+ ): OpenCodeServerManager {
1047
+ const factory = opts.clientFactory ?? ((cwd: string) => createClaudeCodeClient(cwd));
1048
+ const handles = new Map<
1049
+ string,
1050
+ { handle: OpenCodeServerHandle; client: ClaudeCodeClient; cwd: string }
1051
+ >();
1052
+
1053
+ async function ensureServer(projectId: string, projectPath: string) {
1054
+ const cur = handles.get(projectId);
1055
+ if (cur && cur.cwd === projectPath) return { ok: true as const, data: cur.handle };
1056
+ let isDir = false;
1057
+ try {
1058
+ isDir = existsSync(projectPath) && statSync(projectPath).isDirectory();
1059
+ } catch {
1060
+ isDir = false;
1061
+ }
1062
+ if (!isDir) return { ok: false as const, error: "PROJECT_DIR_NOT_FOUND" };
1063
+ if (cur) await cur.client.close();
1064
+ const client = factory(path.resolve(projectPath));
1065
+ const handle: OpenCodeServerHandle = {
1066
+ projectId,
1067
+ baseUrl: `claude-code://${projectPath}`,
1068
+ client,
1069
+ };
1070
+ handles.set(projectId, { handle, client, cwd: projectPath });
1071
+ return { ok: true as const, data: handle };
1072
+ }
1073
+
1074
+ async function stopServer(projectId: string): Promise<void> {
1075
+ const cur = handles.get(projectId);
1076
+ if (!cur) return;
1077
+ handles.delete(projectId);
1078
+ await cur.client.close();
1079
+ }
1080
+
1081
+ return {
1082
+ ensureServer,
1083
+ ensureFreshServer: ensureServer,
1084
+ getServer: (projectId) => handles.get(projectId)?.handle,
1085
+ stopServer,
1086
+ async stopAll() {
1087
+ await Promise.all([...handles.keys()].map((id) => stopServer(id)));
1088
+ },
1089
+ // Tidak ada proses server yang bisa keluar; crash proses `claude` per
1090
+ // Session dilaporkan sebagai error turn (lihat `pump`).
1091
+ onServerExit() {},
1092
+ };
1093
+ }