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,1439 @@
1
+ /**
2
+ * Session_Manager — lifecycle Session & orkestrasi headless opencode.
3
+ *
4
+ * Menggantikan versi PTY/TUI:
5
+ * - `createSession` memastikan server `opencode serve` untuk Project lalu
6
+ * membuat Session di server headless (`POST /session`) — bukan spawn PTY.
7
+ * - Output bukan chunk terminal, melainkan `SessionMessage` terstruktur yang
8
+ * disimpan di `messages` dan diteruskan via hook `onMessage`.
9
+ * - Prompt dikirim lewat `prompt_async` (204) dan turn ditutup oleh event
10
+ * `session.idle` Session akar; pesan assistant dirakit dari parts SSE.
11
+ * Sebelumnya balasan `POST /message` yang menggantung dipakai, sehingga turn
12
+ * panjang (sub-agent) gagal tersimpan saat koneksi HTTP-nya putus.
13
+ * - Interactive_Prompt berasal dari event SSE terstruktur:
14
+ * `permission.asked` (-> confirmation) dan `question.asked` (-> menu) —
15
+ * `prompt-detector.ts` (regex atas teks TUI) dihapus. Varian `*.v2.asked`
16
+ * ikut ditangani (skema opencode mengekspos keduanya) dengan penamaan field
17
+ * berbeda: v2 memakai `action`/`resources`, v1 `permission`/`patterns`.
18
+ * - Sub-agent (`task`) dijalankan opencode sebagai Session terpisah dengan
19
+ * `parentID`; `session.created` memetakan child tersebut ke Session lokal
20
+ * induk agar prompt izin & streaming-nya tidak terbuang.
21
+ * - `resolvePrompt` menjawab lewat `POST /permission/{id}/reply` /
22
+ * `POST /question/{id}/reply|reject` — bukan menulis `y\n` ke PTY.
23
+ *
24
+ * Prinsip error tetap sama: fungsi publik mengembalikan
25
+ * `{ ok: true, data } | { ok: false, error }`, dan Session berstatus
26
+ * `running` hanya setelah berhasil dibuat di server headless.
27
+ */
28
+ import { randomUUID } from "node:crypto";
29
+ import { existsSync, statSync } from "node:fs";
30
+ import path from "node:path";
31
+ import type { SessionStore } from "../../db";
32
+ import { modelFacingMime } from "../../lib/attachments";
33
+ import type {
34
+ AgentType,
35
+ InteractivePrompt,
36
+ MessagePart,
37
+ PromptResponse,
38
+ Session,
39
+ SessionContext,
40
+ SessionMessage,
41
+ SessionModel,
42
+ SessionStatus,
43
+ } from "../../types";
44
+ import type { Result, SimpleResult } from "../result";
45
+ import type { AttachmentManager } from "./attachments";
46
+ import { EMPTY_GIT_INFO, readGitInfo } from "./git-info";
47
+ import type {
48
+ AgentOption,
49
+ McpServerInfo,
50
+ ModelOption,
51
+ OpenCodeClient,
52
+ OpenCodeEvent,
53
+ OpenCodeFileRef,
54
+ SkillInfo,
55
+ } from "./opencode-client";
56
+ import { OC_REQUEST_GONE } from "./opencode-client";
57
+ import type { OpenCodeServerManager } from "./opencode-server";
58
+ import {
59
+ deriveSessionTitle,
60
+ describeSessionError,
61
+ eventField as field,
62
+ friendlySendError,
63
+ isPlaceholderTitle,
64
+ permissionGroupKey,
65
+ promptFromPermission,
66
+ promptFromQuestion,
67
+ retryStatusMessage,
68
+ } from "./session-events";
69
+ import { TurnStream } from "./turn-stream";
70
+
71
+ export const MAX_FREE_TEXT_LENGTH = 10000;
72
+ /**
73
+ * Batas IDLE turn (jaring pengaman bila `session.idle` tak kunjung datang).
74
+ * Bukan batas total: timer di-reset tiap ada tanda hidup opencode (part
75
+ * streaming, pesan assistant, status busy/retry) — turn panjang yang sehat
76
+ * (reasoning panjang, tool lambat, sub-agent, retry provider) tidak dipotong;
77
+ * hanya keheningan TOTAL selama durasi ini yang memicu fail.
78
+ */
79
+ export const TURN_IDLE_TIMEOUT_MS = 300_000;
80
+ export const SHUTDOWN_BUDGET_MS = 5000;
81
+
82
+ /**
83
+ * Tipe CLI_Agent yang dikenal. `claude-code` hanya aktif bila
84
+ * `claudeServers` diinjeksi (lihat `SessionManagerOptions`).
85
+ */
86
+ export const SUPPORTED_AGENT_TYPES: readonly AgentType[] = ["opencode", "claude-code"];
87
+
88
+ export interface CreateSessionRequest {
89
+ agentType: AgentType;
90
+ projectId: string;
91
+ /** Model pilihan user; null/undefined = model default opencode. */
92
+ model?: SessionModel | null;
93
+ /** Agent (mode) pilihan user; null/undefined = default opencode. */
94
+ agent?: string | null;
95
+ }
96
+
97
+ export type CreateSessionResult = { ok: true; session: Session } | { ok: false; error: string };
98
+
99
+ export type { SimpleResult };
100
+
101
+ /** Default jeda refresh skill tertunda setelah turn terakhir selesai. */
102
+ const SKILL_REFRESH_DELAY_MS = 1500;
103
+
104
+ export interface SessionManagerOptions {
105
+ store: SessionStore;
106
+ servers: OpenCodeServerManager;
107
+ /**
108
+ * Backend Claude Code (Agent SDK) dengan kontrak yang sama dengan
109
+ * `servers`. Tidak diisi -> Session `claude-code` ditolak.
110
+ */
111
+ claudeServers?: OpenCodeServerManager;
112
+ /**
113
+ * Penyimpanan lampiran gambar (upload dari perangkat). Bila tidak diisi,
114
+ * fitur gambar dinonaktifkan — pesan dengan `images` ditolak.
115
+ */
116
+ attachments?: AttachmentManager;
117
+ now?: () => number;
118
+ shutdownBudgetMs?: number;
119
+ /**
120
+ * Batas idle turn (ms) — jaring pengaman bila `session.idle` tak kunjung
121
+ * datang. Di-reset tiap ada tanda hidup; bukan batas total turn.
122
+ */
123
+ turnIdleTimeoutMs?: number;
124
+ setTimeoutFn?: (cb: () => void, ms: number) => unknown;
125
+ clearTimeoutFn?: (handle: unknown) => void;
126
+ /** Hook pesan baru — disambungkan ke WebSocket_Gateway. */
127
+ onMessage?: (message: SessionMessage) => void;
128
+ /**
129
+ * Hook part pesan yang sedang di-stream (SSE `message.part.updated`).
130
+ * `messageId` = id pesan assistant di opencode yang sedang dibangun.
131
+ */
132
+ onMessagePart?: (sessionId: string, messageId: string, part: MessagePart) => void;
133
+ /**
134
+ * Hook potongan teks streaming (SSE `message.part.delta`) — token model
135
+ * tiba lewat event ini; lihat `TurnStream.acceptDelta`.
136
+ */
137
+ onMessagePartDelta?: (
138
+ sessionId: string,
139
+ messageId: string,
140
+ partId: string,
141
+ field: string,
142
+ delta: string,
143
+ ) => void;
144
+ /** Hook Interactive_Prompt baru — disambungkan ke WebSocket_Gateway. */
145
+ onPrompt?: (prompt: InteractivePrompt) => void;
146
+ /**
147
+ * Hook prompt kembar yang ikut ter-resolve lewat fan-out jawaban kartu
148
+ * grup (lihat `permissionGroupKey`) — agar kartu kembar hilang dari UI
149
+ * client lain tanpa menunggu reattach.
150
+ */
151
+ onPromptResolved?: (sessionId: string, promptId: string) => void;
152
+ /** Hook perubahan status Session — disambungkan ke WebSocket_Gateway. */
153
+ onStatusChange?: (sessionId: string, status: SessionStatus) => void;
154
+ /**
155
+ * Hook judul Session baru hasil generate opencode (SSE `session.updated`
156
+ * setelah prompt pertama) — disambungkan ke WebSocket_Gateway agar daftar
157
+ * Session di Client ikut terbarui tanpa reload.
158
+ */
159
+ onTitleChange?: (sessionId: string, title: string) => void;
160
+ /** Hook Session dihapus permanen — disambungkan ke WebSocket_Gateway. */
161
+ onDeleted?: (sessionId: string) => void;
162
+ /** Hook error asinkron (mis. balasan model gagal) — disambungkan ke gateway. */
163
+ onError?: (sessionId: string, message: string) => void;
164
+ /**
165
+ * Hook perubahan status turn — `true` saat model mulai merespon (turn
166
+ * streaming aktif), `false` saat turn selesai/dibatalkan. Disambungkan ke
167
+ * gateway agar Client tahu kapan tombol stop (interrupt) perlu tampil.
168
+ */
169
+ onTurnChange?: (sessionId: string, active: boolean) => void;
170
+ /**
171
+ * Refresh skill yang sempat tertunda (turn aktif saat skill dipasang) baru
172
+ * saja dijalankan otomatis untuk Project ini.
173
+ */
174
+ onSkillsRefreshed?: (projectId: string) => void;
175
+ /** Jeda refresh skill tertunda setelah turn terakhir selesai (ms). */
176
+ skillRefreshDelayMs?: number;
177
+ }
178
+
179
+ export interface SessionManager {
180
+ createSession(req: CreateSessionRequest): Promise<CreateSessionResult>;
181
+ listSessions(): Session[];
182
+ getSession(sessionId: string): Result<Session>;
183
+ stopSession(sessionId: string): SimpleResult;
184
+ /**
185
+ * Hentikan balasan model yang sedang berlangsung (interrupt ala opencode):
186
+ * turn remote di-abort, parts yang sudah ter-stream disimpan, tapi Session
187
+ * TETAP `running` — user bisa langsung kirim pesan baru tanpa Start ulang.
188
+ */
189
+ interruptSession(sessionId: string): SimpleResult;
190
+ /**
191
+ * Hapus Session permanen: pastikan server headless opencode hidup (spawn
192
+ * ulang bila perlu) lalu hapus session remote di sana (beserta riwayat
193
+ * pesannya), kemudian baris Session di Session_Store.
194
+ */
195
+ deleteSession(sessionId: string): Promise<SimpleResult>;
196
+ /**
197
+ * Menghidupkan kembali Session yang `stopped`/`crashed` — memakai ocSessionId
198
+ * lama bila masih dikenal server headless, memakai sesi remote baru bila
199
+ * tidak, lalu status kembali `running`.
200
+ */
201
+ resumeSession(sessionId: string): Promise<SimpleResult>;
202
+ /**
203
+ * Lepaskan seluruh sumber daya milik satu Project sebelum Project dihapus:
204
+ * hapus tiap Session-nya (termasuk sesi remote opencode & lampiran) lalu
205
+ * hentikan server headless Project. Gagal pada salah satu Session ->
206
+ * error diteruskan tanpa melanjutkan, agar tidak ada sesi yatim.
207
+ */
208
+ releaseProject(projectId: string): Promise<SimpleResult>;
209
+ /** Daftar model yang tersedia pada server headless milik Project. */
210
+ listModels(projectId: string, agentType?: AgentType): Promise<Result<ModelOption[]>>;
211
+ /**
212
+ * Daftar agent (mode) opencode milik Project — primary/all saja, siap
213
+ * ditampilkan di pemilih mode composer.
214
+ */
215
+ listAgents(projectId: string, agentType?: AgentType): Promise<Result<AgentOption[]>>;
216
+ /** Status MCP server yang terkonfigurasi untuk Project. */
217
+ listMcp(projectId: string, agentType?: AgentType): Promise<Result<McpServerInfo[]>>;
218
+ /** Skill yang terdaftar untuk Project (global, folder Project, bawaan). */
219
+ listSkills(projectId: string, agentType?: AgentType): Promise<Result<SkillInfo[]>>;
220
+ /**
221
+ * Muat ulang daftar skill server headless Project setelah skill dipasang.
222
+ * Bila ada turn aktif di Project, refresh ditunda (`refreshed: false`) agar
223
+ * chat yang berjalan tidak terganggu — lalu dijalankan OTOMATIS begitu
224
+ * seluruh turn di Project selesai (`onSkillsRefreshed`). Server yang belum
225
+ * hidup tidak perlu di-refresh. Claude Code selalu langsung `refreshed`.
226
+ */
227
+ refreshSkills(projectId: string, agentType?: AgentType): Promise<Result<{ refreshed: boolean }>>;
228
+ /** Project dengan refresh skill tertunda (menunggu turn selesai). */
229
+ hasPendingSkillRefresh(projectId: string): boolean;
230
+ /** Cari file project untuk autocomplete `@file` di composer. */
231
+ findFiles(projectId: string, query: string, agentType?: AgentType): Promise<Result<string[]>>;
232
+ /**
233
+ * Konteks kerja Session untuk panel kanan: perubahan file di worktree,
234
+ * cabang git, dan task list (`todowrite`) agent.
235
+ */
236
+ sessionContext(sessionId: string): Promise<Result<SessionContext>>;
237
+ /** Ganti model pilihan Session (`null` = kembali ke default opencode). */
238
+ setSessionModel(sessionId: string, model: SessionModel | null): SimpleResult;
239
+ /**
240
+ * Ganti agent (mode) pilihan Session — `build`, `plan`, atau agent kustom
241
+ * (`null` = default opencode). Berlaku pada prompt berikutnya.
242
+ */
243
+ setSessionAgent(sessionId: string, agent: string | null): SimpleResult;
244
+ /**
245
+ * Kirim input bebas. `files` = path relatif project (`@file`); `images` =
246
+ * id lampiran di Attachment_Store (gambar upload dari perangkat).
247
+ */
248
+ sendFreeTextInput(
249
+ sessionId: string,
250
+ text: string,
251
+ files?: string[],
252
+ images?: string[],
253
+ ): Promise<SimpleResult>;
254
+ resolvePrompt(
255
+ sessionId: string,
256
+ promptId: string,
257
+ response: PromptResponse,
258
+ ): Promise<SimpleResult>;
259
+ reconcileOnStartup(): void;
260
+ shutdown(): Promise<void>;
261
+ }
262
+
263
+ /**
264
+ * Membuat instance Session_Manager terikat pada `store` dan `servers`.
265
+ * `servers` (OpenCode_Server), `now`, dan timer dapat diinjeksi untuk test.
266
+ */
267
+ export function createSessionManager(opts: SessionManagerOptions): SessionManager {
268
+ const store = opts.store;
269
+ const servers = opts.servers;
270
+ const claudeServers = opts.claudeServers;
271
+
272
+ /** Server manager backend milik tipe CLI_Agent. */
273
+ function serversFor(agentType: AgentType): OpenCodeServerManager {
274
+ return agentType === "claude-code" && claudeServers ? claudeServers : servers;
275
+ }
276
+
277
+ /** Kunci subscription/exit per backend + Project (keduanya bisa hidup). */
278
+ function backendKey(agentType: AgentType, projectId: string): string {
279
+ return agentType === "claude-code" ? `claude-code:${projectId}` : projectId;
280
+ }
281
+ const attachments = opts.attachments;
282
+ const now = opts.now ?? Date.now;
283
+ const shutdownBudgetMs = opts.shutdownBudgetMs ?? SHUTDOWN_BUDGET_MS;
284
+ const turnIdleTimeoutMs = opts.turnIdleTimeoutMs ?? TURN_IDLE_TIMEOUT_MS;
285
+ const setTimeoutFn = opts.setTimeoutFn ?? ((cb: () => void, ms: number) => setTimeout(cb, ms));
286
+ const clearTimeoutFn =
287
+ opts.clearTimeoutFn ??
288
+ ((handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>));
289
+ const onMessage = opts.onMessage;
290
+ const onPrompt = opts.onPrompt;
291
+ const onPromptResolved = opts.onPromptResolved;
292
+ const onStatusChange = opts.onStatusChange;
293
+ const onTitleChange = opts.onTitleChange;
294
+ const onDeleted = opts.onDeleted;
295
+ const onError = opts.onError;
296
+
297
+ /**
298
+ * Project yang refresh skill-nya tertunda karena ada turn aktif saat skill
299
+ * dipasang. Dijalankan otomatis begitu seluruh turn di Project selesai.
300
+ */
301
+ const pendingSkillRefresh = new Set<string>();
302
+ /** Timer refresh tertunda per Project (dibatalkan bila turn baru dimulai). */
303
+ const skillRefreshTimers = new Map<string, unknown>();
304
+ /** Jeda setelah turn terakhir selesai sebelum dispose (biarkan event penutup tuntas). */
305
+ const skillRefreshDelayMs = opts.skillRefreshDelayMs ?? SKILL_REFRESH_DELAY_MS;
306
+ const onSkillsRefreshed = opts.onSkillsRefreshed;
307
+
308
+ const onTurnChange = (sessionId: string, active: boolean): void => {
309
+ opts.onTurnChange?.(sessionId, active);
310
+ const projectId = store.getSession(sessionId);
311
+ if (!projectId.ok || !pendingSkillRefresh.has(projectId.data.projectId)) return;
312
+ if (active) {
313
+ // Turn baru dimulai sebelum timer jalan -> tunda lagi.
314
+ cancelScheduledSkillRefresh(projectId.data.projectId);
315
+ } else {
316
+ scheduleSkillRefresh(projectId.data.projectId);
317
+ }
318
+ };
319
+
320
+ /**
321
+ * Pemetaan ocSessionId (opencode) -> id Session lokal.
322
+ *
323
+ * Selain Session yang dibuat lewat `POST /session`, map ini juga memuat
324
+ * child session yang dibuat opencode sendiri saat model memanggil `task`
325
+ * (sub-agent). Child session punya `sessionID` sendiri di SSE, sehingga tanpa
326
+ * pemetaan ini event-nya (termasuk `permission.asked`) akan terbuang.
327
+ */
328
+ const ocToSession = new Map<string, string>();
329
+ /** Kebalikan `ocToSession`: id Session lokal -> seluruh ocSessionId miliknya. */
330
+ const sessionOcIds = new Map<string, Set<string>>();
331
+ /** Backend+Project yang event-nya sudah disubscribe (lihat `backendKey`). */
332
+ const activeSubscriptions = new Map<string, { active: boolean; unsubscribe: () => void }>();
333
+ /** Serialisasi kirim pesan per Session agar turn tidak tumpang tindih. */
334
+ const inflight = new Map<string, Promise<void>>();
335
+ /** Backend+Project yang sudah diproses saat server-nya keluar (hindari duplikasi). */
336
+ const exitNotified = new Set<string>();
337
+ const onMessagePart = opts.onMessagePart;
338
+ const onMessagePartDelta = opts.onMessagePartDelta;
339
+
340
+ /** State turn streaming per Session (finish/discard/fail + timer idle). */
341
+ const turns = new TurnStream({
342
+ store,
343
+ now,
344
+ onMessage,
345
+ onMessagePart,
346
+ onMessagePartDelta,
347
+ onError,
348
+ onTurnChange,
349
+ // Turn menunggu keputusan user (kartu permission/question pending) bukan
350
+ // keheningan — timer idle ditunda sampai kartu dijawab atau turn ditutup.
351
+ shouldFireIdle: (sessionId) => store.listPendingPrompts(sessionId).length === 0,
352
+ setTimeoutFn,
353
+ clearTimeoutFn,
354
+ });
355
+
356
+ function updateStatus(sessionId: string, status: SessionStatus, changedAt: number): void {
357
+ const res = store.updateSessionStatus(sessionId, status, changedAt);
358
+ if (res.ok) onStatusChange?.(sessionId, status);
359
+ }
360
+
361
+ function updatePromptResolved(promptId: string): void {
362
+ store.updatePromptStatus(promptId, "resolved", now());
363
+ }
364
+
365
+ /** Catat pemetaan ocSessionId -> Session lokal (juga untuk cleanup). */
366
+ function mapOcSession(ocSessionId: string, sessionId: string): void {
367
+ ocToSession.set(ocSessionId, sessionId);
368
+ let owned = sessionOcIds.get(sessionId);
369
+ if (!owned) {
370
+ owned = new Set<string>();
371
+ sessionOcIds.set(sessionId, owned);
372
+ }
373
+ owned.add(ocSessionId);
374
+ }
375
+
376
+ /** Lepas seluruh ocSessionId (induk + child) milik satu Session lokal. */
377
+ function unmapOcSessions(sessionId: string): void {
378
+ const owned = sessionOcIds.get(sessionId);
379
+ if (!owned) return;
380
+ for (const ocId of owned) ocToSession.delete(ocId);
381
+ sessionOcIds.delete(sessionId);
382
+ }
383
+
384
+ /**
385
+ * Daftarkan child session (sub-agent `task`) ke Session lokal induknya.
386
+ *
387
+ * opencode membuat session baru untuk setiap sub-agent dengan `parentID`
388
+ * menunjuk session pemanggil. Event-nya (`permission.asked`, `message.*`)
389
+ * memakai `sessionID` child, jadi tanpa pemetaan ini prompt izin sub-agent
390
+ * tidak akan pernah sampai ke Client dan turn menggantung sampai timeout.
391
+ * Rantai bersarang ikut tertangani karena induk sudah lebih dulu terpetakan.
392
+ */
393
+ function registerChildSession(ev: OpenCodeEvent): void {
394
+ const info = field(ev, "info");
395
+ if (typeof info !== "object" || info === null) return;
396
+ const { id, parentID } = info as { id?: unknown; parentID?: unknown };
397
+ if (typeof id !== "string" || typeof parentID !== "string") return;
398
+ if (ocToSession.has(id)) return; // sudah terpetakan (mis. session induk)
399
+ const sessionId = ocToSession.get(parentID);
400
+ if (!sessionId) return; // induk bukan Session milik KCG Code — abaikan
401
+ mapOcSession(id, sessionId);
402
+ }
403
+
404
+ /**
405
+ * Handler event SSE dari satu server (per Project).
406
+ * Selain permission/question, kini memproses streaming:
407
+ * - `session.created` dengan `parentID` -> petakan child sub-agent ke induk.
408
+ * - `message.updated` role=assistant -> catat id pesan assistant (turn).
409
+ * - `message.part.updated` -> forward part ke Client (via onMessagePart).
410
+ * - `message.part.delta` -> potongan teks streaming (via onMessagePartDelta).
411
+ */
412
+ function handleEvent(_projectId: string, ev: OpenCodeEvent): void {
413
+ if (ev.type === "session.created") {
414
+ registerChildSession(ev);
415
+ return;
416
+ }
417
+ if (ev.type === "session.updated") {
418
+ // Judul Session dibuat otomatis opencode setelah prompt pertama
419
+ // (summarization). Hanya Session AKAR yang dipetakan yang diproses —
420
+ // child sub-agent tidak mewakili percakapan user.
421
+ const info = field(ev, "info");
422
+ if (typeof info !== "object" || info === null) return;
423
+ const { id, title } = info as { id?: unknown; title?: unknown };
424
+ if (typeof id !== "string" || typeof title !== "string") return;
425
+ if (title.trim() === "") return;
426
+ // Placeholder (`New session - <ISO>`, judul lama "KCG Code Session")
427
+ // bukan judul percakapan — abaikan agar tidak menimpa judul sementara.
428
+ // Judul hasil opencode selalu menggantikan judul sementara lokal.
429
+ if (isPlaceholderTitle(title)) return;
430
+ const sessionId = ocToSession.get(id);
431
+ if (sessionId === undefined) return;
432
+ // Hanya Session akar: judul child sub-agent bukan judul percakapan
433
+ // user (guard yang sama dengan `session.idle` di bawah).
434
+ const cur = store.getSession(sessionId);
435
+ if (!cur.ok || cur.data.ocSessionId !== id) return;
436
+ if (cur.data.title === title) return;
437
+ if (!store.updateSessionTitle(sessionId, title).ok) return;
438
+ onTitleChange?.(sessionId, title);
439
+ return;
440
+ }
441
+ if (ev.type === "session.idle") {
442
+ const ocId = field(ev, "sessionID", "sessionId");
443
+ if (typeof ocId !== "string") return;
444
+ const sessionId = ocToSession.get(ocId);
445
+ if (!sessionId) return;
446
+ // Sub-agent idle lebih dulu dari induknya; hanya idle Session akar yang
447
+ // menandai turn selesai, jika tidak turn terpotong di tengah.
448
+ const cur = store.getSession(sessionId);
449
+ if (!cur.ok || cur.data.ocSessionId !== ocId) return;
450
+ turns.finish(sessionId);
451
+ return;
452
+ }
453
+ if (ev.type === "session.error") {
454
+ const ocId = field(ev, "sessionID", "sessionId");
455
+ if (typeof ocId !== "string") return;
456
+ const sessionId = ocToSession.get(ocId);
457
+ if (!sessionId) return;
458
+ const err = field(ev, "error");
459
+ const errName =
460
+ typeof err === "object" && err !== null ? (err as { name?: unknown }).name : undefined;
461
+ // MessageAbortedError = turn dibatalkan (interrupt user / reject prompt).
462
+ // Bukan kegagalan: parts yang sudah ter-stream disimpan tanpa banner error.
463
+ const aborted = errName === "MessageAbortedError";
464
+ const message = describeSessionError(ev);
465
+ if (turns.has(sessionId)) {
466
+ if (aborted) turns.finish(sessionId);
467
+ else turns.noteError(sessionId, message);
468
+ } else if (!aborted) {
469
+ // Tanpa turn aktif, abort berarti turn sudah ditutup interrupt/stop -
470
+ // jangan tampilkan banner "Pemrosesan prompt dibatalkan" ke Client.
471
+ onError?.(sessionId, message);
472
+ }
473
+ return;
474
+ }
475
+ if (ev.type === "session.status") {
476
+ // Saat provider model error yang bisa di-retry (rate limit, kredit habis,
477
+ // cooldown, 5xx), opencode memancarkan `status: { type: "retry", ... }`
478
+ // alih-alih langsung gagal — turn TETAP berjalan selama backoff retry.
479
+ // Tanpa banner ini Client menunggu diam-diam sampai retry habis atau
480
+ // batas idle turn. Status apa pun (busy/retry) adalah tanda hidup:
481
+ // reset timer idle agar turn panjang tidak dipotong.
482
+ const ocId = field(ev, "sessionID", "sessionId");
483
+ if (typeof ocId !== "string") return;
484
+ const sessionId = ocToSession.get(ocId);
485
+ if (!sessionId) return;
486
+ turns.keepAlive(sessionId);
487
+ const status = field(ev, "status");
488
+ const message = retryStatusMessage(status);
489
+ if (message) onError?.(sessionId, message);
490
+ return;
491
+ }
492
+ if (ev.type === "message.updated") {
493
+ const ocId = field(ev, "sessionID", "sessionId");
494
+ if (typeof ocId !== "string") return;
495
+ const sessionId = ocToSession.get(ocId);
496
+ if (!sessionId) return;
497
+ const info = field(ev, "info");
498
+ if (info && typeof info === "object") {
499
+ const role = (info as { role?: unknown }).role;
500
+ const id = (info as { id?: unknown }).id;
501
+ if (role === "assistant" && typeof id === "string") {
502
+ turns.noteAssistantMessage(sessionId, id);
503
+ }
504
+ }
505
+ return;
506
+ }
507
+ if (ev.type === "message.part.updated") {
508
+ const ocId = field(ev, "sessionID", "sessionId");
509
+ if (typeof ocId !== "string") return;
510
+ const sessionId = ocToSession.get(ocId);
511
+ if (!sessionId) return;
512
+ const part = field(ev, "part");
513
+ if (typeof part !== "object" || part === null) return;
514
+ const p = part as MessagePart & { messageID?: unknown };
515
+ const messageId = p.messageID;
516
+ if (typeof messageId !== "string") return;
517
+ turns.acceptPart(sessionId, messageId, p);
518
+ return;
519
+ }
520
+ if (ev.type === "message.part.delta") {
521
+ const ocId = field(ev, "sessionID", "sessionId");
522
+ if (typeof ocId !== "string") return;
523
+ const sessionId = ocToSession.get(ocId);
524
+ if (!sessionId) return;
525
+ const messageId = field(ev, "messageID", "messageId");
526
+ const partId = field(ev, "partID", "partId");
527
+ const name = field(ev, "field");
528
+ const delta = field(ev, "delta");
529
+ if (
530
+ typeof messageId !== "string" ||
531
+ typeof partId !== "string" ||
532
+ typeof name !== "string" ||
533
+ typeof delta !== "string"
534
+ ) {
535
+ return;
536
+ }
537
+ turns.acceptDelta(sessionId, messageId, partId, name, delta);
538
+ return;
539
+ }
540
+ // v1 & v2 memakai id request yang sama (`per_...`), dan `prompts.id` adalah
541
+ // PRIMARY KEY — jadi bila server memancarkan keduanya, insert kedua gagal
542
+ // dan kartu tidak terduplikasi.
543
+ if (ev.type === "permission.asked" || ev.type === "permission.v2.asked") {
544
+ const ocId = field(ev, "sessionID", "sessionId");
545
+ if (typeof ocId !== "string") return;
546
+ const sessionId = ocToSession.get(ocId);
547
+ if (!sessionId) return;
548
+ // Turn sedang MENUNGGU keputusan user — bukan sunyi: reset timer idle
549
+ // agar turn tidak dipotong saat user lambat menjawab kartu izin.
550
+ turns.keepAlive(sessionId);
551
+ const prompt = promptFromPermission(ev, sessionId, now());
552
+ if (!store.insertPrompt(prompt).ok) return; // duplikat -> abaikan
553
+ onPrompt?.(prompt);
554
+ return;
555
+ }
556
+ if (ev.type === "question.asked" || ev.type === "question.v2.asked") {
557
+ const ocId = field(ev, "sessionID", "sessionId");
558
+ if (typeof ocId !== "string") return;
559
+ const sessionId = ocToSession.get(ocId);
560
+ if (!sessionId) return;
561
+ // Sama seperti permission: menunggu jawaban user = tanda hidup.
562
+ turns.keepAlive(sessionId);
563
+ const prompt = promptFromQuestion(ev, sessionId, now());
564
+ if (!store.insertPrompt(prompt).ok) return;
565
+ onPrompt?.(prompt);
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Pastikan server Project hidup; subscribe event SSE bila belum aktif.
571
+ * `refreshConfig` = true: bila file config opencode berubah, server
572
+ * di-restart dulu (dipakai listModels/listAgents agar daftar ikut
573
+ * terbaca ulang — `opencode serve` tidak hot-reload config). Setelah
574
+ * restart, subscription SSE lama dibuang dan diganti milik server baru.
575
+ */
576
+ async function ensureServerFor(
577
+ projectId: string,
578
+ projectPath: string,
579
+ opts: { refreshConfig?: boolean; agentType?: AgentType } = {},
580
+ ): Promise<Result<{ client: OpenCodeClient }>> {
581
+ const agentType = opts.agentType ?? "opencode";
582
+ const backend = serversFor(agentType);
583
+ const key = backendKey(agentType, projectId);
584
+ const prevClient = backend.getServer(projectId)?.client;
585
+ const ensure = opts.refreshConfig
586
+ ? backend.ensureFreshServer(projectId, projectPath)
587
+ : backend.ensureServer(projectId, projectPath);
588
+ const res = await ensure;
589
+ if (!res.ok) return { ok: false, error: res.error };
590
+ const handle = res.data;
591
+ // Server headless dapat diganti (crash lalu ensure ulang, resume, atau
592
+ // restart config). Bila client berbeda dari sebelumnya, subscription
593
+ // lama tidak lagi menempel — buang dan subscribe ulang ke instance baru.
594
+ const sub = activeSubscriptions.get(key);
595
+ if (prevClient && handle.client !== prevClient) {
596
+ sub?.unsubscribe();
597
+ activeSubscriptions.delete(key);
598
+ }
599
+ if (!activeSubscriptions.get(key)?.active) {
600
+ const prev = activeSubscriptions.get(key);
601
+ prev?.unsubscribe();
602
+ const unsubscribe = handle.client.subscribeEvents((ev) => handleEvent(projectId, ev));
603
+ activeSubscriptions.set(key, { active: true, unsubscribe });
604
+ }
605
+ // Server baru (atau hasil restart) — izinkan event exit berikutnya diproses.
606
+ exitNotified.delete(key);
607
+ return { ok: true, data: { client: handle.client } };
608
+ }
609
+
610
+ function enqueue(sessionId: string, task: () => Promise<void>): void {
611
+ const prev = inflight.get(sessionId) ?? Promise.resolve();
612
+ const next = prev.then(task).finally(() => {
613
+ if (inflight.get(sessionId) === next) inflight.delete(sessionId);
614
+ });
615
+ inflight.set(sessionId, next);
616
+ }
617
+
618
+ async function createSession(req: CreateSessionRequest): Promise<CreateSessionResult> {
619
+ // (1) tipe CLI_Agent didukung (Requirement 1.3)
620
+ if (
621
+ !SUPPORTED_AGENT_TYPES.includes(req.agentType) ||
622
+ (req.agentType === "claude-code" && !claudeServers)
623
+ ) {
624
+ return { ok: false, error: "UNSUPPORTED_AGENT_TYPE" };
625
+ }
626
+ // (2) Project ada di Session_Store (Requirement 10.10)
627
+ const projectRes = store.getProjectById(req.projectId);
628
+ if (!projectRes.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
629
+ const project = projectRes.data;
630
+ // (3) direktori kerja Project masih ada di filesystem (Requirement 10.11)
631
+ let dirExists = false;
632
+ try {
633
+ dirExists = existsSync(project.path) && statSync(project.path).isDirectory();
634
+ } catch {
635
+ dirExists = false;
636
+ }
637
+ if (!dirExists) return { ok: false, error: "PROJECT_DIR_NOT_FOUND" };
638
+
639
+ // (4) pastikan server headless untuk Project (spawn bila perlu)
640
+ const serverRes = await ensureServerFor(project.id, project.path, {
641
+ agentType: req.agentType,
642
+ });
643
+ if (!serverRes.ok) return { ok: false, error: serverRes.error };
644
+ const client = serverRes.data.client;
645
+
646
+ // (5) validasi model pilihan terhadap daftar provider server (Req: pilih
647
+ // model). `null` = pakai default opencode. Daftar provider gagal
648
+ // diambil -> ditolak agar Session tidak lahir dengan model mati.
649
+ const model: SessionModel | null = req.model ?? null;
650
+ if (model) {
651
+ const models = await client.listModels();
652
+ if (!models.ok) return { ok: false, error: models.error };
653
+ const known = models.data.some(
654
+ (m) => m.providerID === model.providerID && m.modelID === model.modelID,
655
+ );
656
+ if (!known) return { ok: false, error: "MODEL_NOT_FOUND" };
657
+ }
658
+
659
+ // (6) buat Session di server headless — TANPA judul, agar opencode
660
+ // meng-generate judul dari prompt pertama (lihat lib/session-title.ts).
661
+ const created = await client.createSession();
662
+ if (!created.ok) return { ok: false, error: created.error };
663
+
664
+ const sessionId = randomUUID();
665
+ const createdAt = now();
666
+ // Judul awal opencode berupa placeholder (`New session - <ISO>`) — tidak
667
+ // disimpan. Judul datang dari SSE `session.updated`; sementara itu
668
+ // `sendFreeTextInput` memberi judul turunan prompt pertama.
669
+ const remoteTitle =
670
+ typeof created.data.title === "string" && !isPlaceholderTitle(created.data.title)
671
+ ? created.data.title
672
+ : null;
673
+ const session: Session = {
674
+ id: sessionId,
675
+ projectId: project.id,
676
+ agentType: req.agentType,
677
+ cwd: project.path,
678
+ status: "running",
679
+ ocSessionId: created.data.id,
680
+ title: remoteTitle,
681
+ model,
682
+ agent: req.agent ?? null,
683
+ createdAt,
684
+ updatedAt: createdAt,
685
+ };
686
+ const ins = store.insertSession(session);
687
+ if (!ins.ok) {
688
+ // Abort session remote agar tidak jadi yatim bila persistensi gagal.
689
+ void client.abortSession(created.data.id);
690
+ return ins;
691
+ }
692
+ mapOcSession(created.data.id, sessionId);
693
+ return { ok: true, session };
694
+ }
695
+
696
+ function listSessions(): Session[] {
697
+ return store.listSessions();
698
+ }
699
+
700
+ function getSession(sessionId: string): Result<Session> {
701
+ return store.getSession(sessionId);
702
+ }
703
+
704
+ function stopSession(sessionId: string): SimpleResult {
705
+ const cur = store.getSession(sessionId);
706
+ if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
707
+ if (cur.data.status !== "running") return { ok: false, error: "SESSION_NOT_RUNNING" };
708
+ // Abort turn berjalan (best-effort, tidak menunggu).
709
+ if (cur.data.ocSessionId) {
710
+ const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
711
+ void handle?.client.abortSession(cur.data.ocSessionId);
712
+ }
713
+ // Simpan parts yang sudah terkumpul sebelum Session ditutup.
714
+ turns.finish(sessionId);
715
+ // Lepas induk + seluruh child sub-agent milik Session ini.
716
+ unmapOcSessions(sessionId);
717
+ updateStatus(sessionId, "stopped", now());
718
+ return { ok: true };
719
+ }
720
+
721
+ /**
722
+ * Hentikan balasan model yang sedang berlangsung (interrupt ala opencode):
723
+ * - Turn remote di-abort (best-effort, tidak menunggu).
724
+ * - Parts yang sudah ter-stream DIBUANG (tidak disimpan) — user membatalkan
725
+ * balasan, sehingga respon parsial tidak boleh muncul di riwayat/UI.
726
+ * - Session TETAP `running` dan pemetaan SSE dipertahankan — user bisa
727
+ * langsung mengirim pesan baru tanpa Start ulang.
728
+ * Idempoten: tanpa turn aktif tidak ada yang berubah (tetap `ok`).
729
+ */
730
+ function interruptSession(sessionId: string): SimpleResult {
731
+ const cur = store.getSession(sessionId);
732
+ if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
733
+ // Abort turn berjalan (best-effort, tidak menunggu).
734
+ if (cur.data.status === "running" && cur.data.ocSessionId) {
735
+ const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
736
+ void handle?.client.abortSession(cur.data.ocSessionId);
737
+ }
738
+ // Buang turn parsial (tidak disimpan) + broadcast turn tidak aktif.
739
+ turns.discard(sessionId);
740
+ return { ok: true };
741
+ }
742
+
743
+ /**
744
+ * Menghidupkan kembali Session yang `stopped`/`crashed` dengan riwayat
745
+ * percakapan utuh:
746
+ *
747
+ * 1. Server headless Project di-ensure (spawn ulang bila sudah mati) —
748
+ * storage session opencode bertahan di disk project, sehingga ocSessionId
749
+ * lama biasanya masih dikenal server baru.
750
+ * 2. ocSessionId diverifikasi via `GET /session/{id}`. Bila masih ada,
751
+ * dipakai lagi (riwayat server opencode tetap nyambung). Bila tidak,
752
+ * dibuat sesi remote baru dan `oc_session_id` di DB diperbarui — pesan
753
+ * lama di Session_Store tidak tersentuh.
754
+ * 3. Pemetaan SSE diaktifkan kembali lalu status -> `running`.
755
+ */
756
+ async function resumeSession(sessionId: string): Promise<SimpleResult> {
757
+ const cur = store.getSession(sessionId);
758
+ if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
759
+ if (cur.data.status === "running") return { ok: false, error: "SESSION_ALREADY_RUNNING" };
760
+ const project = store.getProjectById(cur.data.projectId);
761
+ if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
762
+
763
+ // (1) Pastikan server headless Project hidup (spawn ulang bila perlu).
764
+ const serverRes = await ensureServerFor(project.data.id, project.data.path, {
765
+ agentType: cur.data.agentType,
766
+ });
767
+ if (!serverRes.ok) return { ok: false, error: serverRes.error };
768
+
769
+ // (2) Verifikasi ocSessionId lama; bila tidak dikenal -> sesi remote baru.
770
+ let ocSessionId = cur.data.ocSessionId;
771
+ if (ocSessionId) {
772
+ const remote = await serverRes.data.client.getSession(ocSessionId);
773
+ if (remote.ok) {
774
+ mapOcSession(ocSessionId, sessionId);
775
+ } else {
776
+ ocSessionId = null;
777
+ }
778
+ }
779
+ if (!ocSessionId) {
780
+ // Sesi remote baru (yang lama hilang). Tanpa judul: bila Session belum
781
+ // punya judul, opencode akan meng-generate-nya dari prompt berikutnya.
782
+ const created = await serverRes.data.client.createSession();
783
+ if (!created.ok) return { ok: false, error: created.error };
784
+ ocSessionId = created.data.id;
785
+ mapOcSession(ocSessionId, sessionId);
786
+ const upd = store.updateSessionOcId(sessionId, ocSessionId);
787
+ if (!upd.ok) return { ok: false, error: upd.error };
788
+ }
789
+
790
+ // (3) Status kembali running.
791
+ updateStatus(sessionId, "running", now());
792
+ return { ok: true };
793
+ }
794
+
795
+ /**
796
+ * Lepaskan seluruh sumber daya milik satu Project — dipakai rute
797
+ * `DELETE /api/projects/:id` sebelum baris Project dihapus:
798
+ *
799
+ * 1. Setiap Session milik Project dihapus lewat `deleteSession` sehingga
800
+ * sesi remote opencode, lampiran gambar, dan baris anaknya ikut bersih.
801
+ * Kegagalan menghentikan proses (error diteruskan) supaya tidak ada
802
+ * session opencode yatim — user bisa mencoba lagi.
803
+ * 2. Server headless Project dihentikan; tanpa Session ia tidak lagi
804
+ * dipakai dan tidak perlu terus memakan memori.
805
+ */
806
+ async function releaseProject(projectId: string): Promise<SimpleResult> {
807
+ if (!store.getProjectById(projectId).ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
808
+ for (const session of store.listProjectSessions(projectId)) {
809
+ const res = await deleteSession(session.id);
810
+ if (!res.ok) return res;
811
+ }
812
+ await servers.stopServer(projectId);
813
+ await claudeServers?.stopServer(projectId);
814
+ // Subscription SSE ikut dilepas — server-nya sudah tidak ada.
815
+ for (const agentType of SUPPORTED_AGENT_TYPES) {
816
+ const key = backendKey(agentType, projectId);
817
+ const sub = activeSubscriptions.get(key);
818
+ if (sub) {
819
+ sub.unsubscribe();
820
+ activeSubscriptions.delete(key);
821
+ }
822
+ exitNotified.delete(key);
823
+ }
824
+ return { ok: true };
825
+ }
826
+
827
+ /**
828
+ * Daftar model yang tersedia untuk Project — server headless Project
829
+ * di-ensure lebih dulu (spawn bila perlu) karena provider/model dibaca dari
830
+ * konfigurasi opencode pada direktori Project. Bila file config opencode
831
+ * berubah sejak server hidup, proses di-restart agar daftar ikut terbaru
832
+ * (opencode serve tidak hot-reload config). Restart ditahan bila ada
833
+ * Session `running` agar turn yang sedang berjalan tidak terputus.
834
+ */
835
+ async function listModels(
836
+ projectId: string,
837
+ agentType: AgentType = "opencode",
838
+ ): Promise<Result<ModelOption[]>> {
839
+ const project = store.getProjectById(projectId);
840
+ if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
841
+ const hasRunning = store.listProjectSessions(projectId).some((s) => s.status === "running");
842
+ const serverRes = await ensureServerFor(project.data.id, project.data.path, {
843
+ refreshConfig: !hasRunning,
844
+ agentType,
845
+ });
846
+ if (!serverRes.ok) return { ok: false, error: serverRes.error };
847
+ return serverRes.data.client.listModels();
848
+ }
849
+
850
+ /**
851
+ * Daftar agent (mode) untuk Project — server headless Project di-ensure
852
+ * lebih dulu karena daftar agent dibaca dari konfigurasi opencode di
853
+ * direktori Project (agent kustom user ikut masuk). Config yang berubah
854
+ * memicu restart server agar daftar ikut terbaru (dengan guard yang sama
855
+ * seperti `listModels`).
856
+ */
857
+ async function listAgents(
858
+ projectId: string,
859
+ agentType: AgentType = "opencode",
860
+ ): Promise<Result<AgentOption[]>> {
861
+ const project = store.getProjectById(projectId);
862
+ if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
863
+ const hasRunning = store.listProjectSessions(projectId).some((s) => s.status === "running");
864
+ const serverRes = await ensureServerFor(project.data.id, project.data.path, {
865
+ refreshConfig: !hasRunning,
866
+ agentType,
867
+ });
868
+ if (!serverRes.ok) return { ok: false, error: serverRes.error };
869
+ return serverRes.data.client.listAgents();
870
+ }
871
+
872
+ /**
873
+ * Status MCP server Project — server headless di-ensure lebih dulu karena
874
+ * daftar MCP dibaca dari config opencode di direktori Project. Config yang
875
+ * berubah memicu restart (guard sama dengan `listModels`).
876
+ */
877
+ async function listMcp(
878
+ projectId: string,
879
+ agentType: AgentType = "opencode",
880
+ ): Promise<Result<McpServerInfo[]>> {
881
+ const project = store.getProjectById(projectId);
882
+ if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
883
+ const hasRunning = store.listProjectSessions(projectId).some((s) => s.status === "running");
884
+ const serverRes = await ensureServerFor(project.data.id, project.data.path, {
885
+ refreshConfig: !hasRunning,
886
+ agentType,
887
+ });
888
+ if (!serverRes.ok) return { ok: false, error: serverRes.error };
889
+ return serverRes.data.client.listMcp();
890
+ }
891
+
892
+ /**
893
+ * Skill yang terdaftar untuk Project. Skill di dalam folder Project
894
+ * ditandai `project` sehingga UI dapat membedakannya dari skill global.
895
+ */
896
+ async function listSkills(
897
+ projectId: string,
898
+ agentType: AgentType = "opencode",
899
+ ): Promise<Result<SkillInfo[]>> {
900
+ const project = store.getProjectById(projectId);
901
+ if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
902
+ const serverRes = await ensureServerFor(project.data.id, project.data.path, { agentType });
903
+ if (!serverRes.ok) return { ok: false, error: serverRes.error };
904
+ return serverRes.data.client.listSkills(project.data.path);
905
+ }
906
+
907
+ function projectBusy(projectId: string): boolean {
908
+ return store.listSessions().some((s) => s.projectId === projectId && turns.has(s.id));
909
+ }
910
+
911
+ function cancelScheduledSkillRefresh(projectId: string): void {
912
+ const t = skillRefreshTimers.get(projectId);
913
+ if (t !== undefined) {
914
+ clearTimeoutFn(t);
915
+ skillRefreshTimers.delete(projectId);
916
+ }
917
+ }
918
+
919
+ /** Jadwalkan refresh tertunda bila Project sudah tidak punya turn aktif. */
920
+ function scheduleSkillRefresh(projectId: string): void {
921
+ if (projectBusy(projectId)) return;
922
+ cancelScheduledSkillRefresh(projectId);
923
+ const handle = setTimeoutFn(() => {
924
+ skillRefreshTimers.delete(projectId);
925
+ void runPendingSkillRefresh(projectId);
926
+ }, skillRefreshDelayMs);
927
+ skillRefreshTimers.set(projectId, handle);
928
+ }
929
+
930
+ async function runPendingSkillRefresh(projectId: string): Promise<void> {
931
+ if (!pendingSkillRefresh.has(projectId)) return;
932
+ // Turn baru sempat dimulai -> tunggu turn berikutnya selesai.
933
+ if (projectBusy(projectId)) return;
934
+ const handle = servers.getServer(projectId);
935
+ if (!handle) {
936
+ // Server sudah berhenti -> skill terbaca saat server di-spawn lagi.
937
+ pendingSkillRefresh.delete(projectId);
938
+ onSkillsRefreshed?.(projectId);
939
+ return;
940
+ }
941
+ const res = await handle.client.disposeInstance();
942
+ if (!res.ok) {
943
+ // Tetap tertunda; dicoba lagi setelah turn berikutnya selesai.
944
+ console.warn(`[kcg-code] refresh skill tertunda Project ${projectId} gagal:`, res.error);
945
+ return;
946
+ }
947
+ pendingSkillRefresh.delete(projectId);
948
+ onSkillsRefreshed?.(projectId);
949
+ }
950
+
951
+ async function refreshSkills(
952
+ projectId: string,
953
+ agentType: AgentType = "opencode",
954
+ ): Promise<Result<{ refreshed: boolean }>> {
955
+ const project = store.getProjectById(projectId);
956
+ if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
957
+ // Claude Code: daftar skill dibaca langsung dari disk dan proses `claude`
958
+ // memuat skill sendiri — tidak ada cache server yang perlu dibuang.
959
+ if (agentType === "claude-code") return { ok: true, data: { refreshed: true } };
960
+ const handle = servers.getServer(projectId);
961
+ // Server belum hidup -> skill terbaca saat server di-spawn nanti.
962
+ if (!handle) {
963
+ pendingSkillRefresh.delete(projectId);
964
+ return { ok: true, data: { refreshed: true } };
965
+ }
966
+ if (projectBusy(projectId)) {
967
+ pendingSkillRefresh.add(projectId);
968
+ return { ok: true, data: { refreshed: false } };
969
+ }
970
+ cancelScheduledSkillRefresh(projectId);
971
+ const res = await handle.client.disposeInstance();
972
+ if (!res.ok) {
973
+ console.warn(`[kcg-code] refresh skill Project ${projectId} gagal:`, res.error);
974
+ // Coba lagi otomatis setelah turn berikutnya selesai.
975
+ pendingSkillRefresh.add(projectId);
976
+ return { ok: true, data: { refreshed: false } };
977
+ }
978
+ pendingSkillRefresh.delete(projectId);
979
+ return { ok: true, data: { refreshed: true } };
980
+ }
981
+
982
+ function hasPendingSkillRefresh(projectId: string): boolean {
983
+ return pendingSkillRefresh.has(projectId);
984
+ }
985
+
986
+ /**
987
+ * Cari file di Project untuk autocomplete `@file` — index pencarian
988
+ * milik server headless opencode (sesuai perilaku `@` di opencode TUI).
989
+ * `query` kosong juga valid (mengembalikan daftar awal).
990
+ */
991
+ /**
992
+ * Konteks kerja Session (panel kanan). Ketiga sumbernya independen dan
993
+ * SELALU dibaca bersamaan; kegagalan salah satu tidak menggagalkan yang
994
+ * lain (mis. repo bukan git -> `git` kosong, task list tetap tampil).
995
+ * Session yang tidak berjalan -> hanya info git (server opencode tidak
996
+ * dinyalakan hanya untuk mengisi panel).
997
+ */
998
+ async function sessionContext(sessionId: string): Promise<Result<SessionContext>> {
999
+ const cur = store.getSession(sessionId);
1000
+ if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
1001
+ const cwd = cur.data.cwd;
1002
+ const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
1003
+ // Task list disimpan per session REMOTE (opencode `ses_...` / UUID Claude).
1004
+ const remoteId = cur.data.ocSessionId ?? sessionId;
1005
+
1006
+ const [git, todos, changes, mcp] = await Promise.all([
1007
+ readGitInfo(cwd).catch(() => EMPTY_GIT_INFO),
1008
+ handle ? handle.client.listTodos(remoteId) : Promise.resolve(null),
1009
+ handle ? handle.client.fileStatus() : Promise.resolve(null),
1010
+ handle ? handle.client.listMcp() : Promise.resolve(null),
1011
+ ]);
1012
+
1013
+ return {
1014
+ ok: true,
1015
+ data: {
1016
+ git,
1017
+ todos: todos?.ok ? todos.data : [],
1018
+ changes: changes?.ok ? changes.data : [],
1019
+ mcp: mcp?.ok ? mcp.data : [],
1020
+ /** Panel perlu tahu data tool belum tersedia vs memang kosong. */
1021
+ live: handle !== undefined && handle !== null,
1022
+ },
1023
+ };
1024
+ }
1025
+
1026
+ async function findFiles(
1027
+ projectId: string,
1028
+ query: string,
1029
+ agentType: AgentType = "opencode",
1030
+ ): Promise<Result<string[]>> {
1031
+ const project = store.getProjectById(projectId);
1032
+ if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
1033
+ const serverRes = await ensureServerFor(project.data.id, project.data.path, { agentType });
1034
+ if (!serverRes.ok) return { ok: false, error: serverRes.error };
1035
+ return serverRes.data.client.findFiles(query);
1036
+ }
1037
+
1038
+ /**
1039
+ * Ganti model pilihan Session. Berlaku untuk prompt berikutnya (prompt_async
1040
+ * selalu mengirim model tersimpan di Session), jadi tidak perlu restart.
1041
+ */
1042
+ function setSessionModel(sessionId: string, model: SessionModel | null): SimpleResult {
1043
+ const cur = store.getSession(sessionId);
1044
+ if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
1045
+ const upd = store.updateSessionModel(sessionId, model);
1046
+ return upd.ok ? { ok: true } : { ok: false, error: upd.error };
1047
+ }
1048
+
1049
+ /**
1050
+ * Ganti agent (mode) pilihan Session. Berlaku untuk prompt berikutnya
1051
+ * (prompt_async selalu mengirim agent tersimpan di Session).
1052
+ */
1053
+ function setSessionAgent(sessionId: string, agent: string | null): SimpleResult {
1054
+ const cur = store.getSession(sessionId);
1055
+ if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
1056
+ if (agent !== null && agent.trim() === "") return { ok: false, error: "INVALID_AGENT" };
1057
+ const upd = store.updateSessionAgent(sessionId, agent);
1058
+ return upd.ok ? { ok: true } : { ok: false, error: upd.error };
1059
+ }
1060
+
1061
+ /**
1062
+ * Hapus Session permanen:
1063
+ *
1064
+ * 1. Turn yang sedang stream difinalisasi & pemetaan SSE dilepas.
1065
+ * 2. Pastikan server headless Project hidup — spawn ulang bila mati — lalu
1066
+ * `DELETE /session/{id}`: data session (termasuk seluruh pesan) ikut
1067
+ * dihapus di sisi opencode. Penghapusan remote bersifat wajib: bila
1068
+ * server tidak bisa di-spawn ulang atau menolak hapus, error diteruskan
1069
+ * dan data lokal TIDAK dihapus — supaya tidak ada session opencode yang
1070
+ * jadi yatim tanpa sepengetahuan user (bisa dicoba lagi kemudian).
1071
+ * 3. Baris Session + seluruh baris anaknya dihapus dari Session_Store.
1072
+ */
1073
+ async function deleteSession(sessionId: string): Promise<SimpleResult> {
1074
+ const cur = store.getSession(sessionId);
1075
+ if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
1076
+
1077
+ turns.finish(sessionId);
1078
+ // Bila masih running, abort turn remote agar model berhenti dieksekusi.
1079
+ if (cur.data.status === "running" && cur.data.ocSessionId) {
1080
+ const runningHandle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
1081
+ void runningHandle?.client.abortSession(cur.data.ocSessionId);
1082
+ }
1083
+ unmapOcSessions(sessionId);
1084
+
1085
+ // (2) Hapus di server headless — wajib: server di-spawn ulang bila mati
1086
+ // agar data remote tidak tertinggal. Server hasil spawn dibiarkan
1087
+ // hidup (bukan di-stop) karena mungkin sedang dipakai operasi lain
1088
+ // dan hanya akan dihentikan saat shutdown.
1089
+ const ocSessionId = cur.data.ocSessionId;
1090
+ if (ocSessionId) {
1091
+ let client = serversFor(cur.data.agentType).getServer(cur.data.projectId)?.client;
1092
+ if (!client) {
1093
+ const project = store.getProjectById(cur.data.projectId);
1094
+ if (!project.ok) return { ok: false, error: project.error };
1095
+ const serverRes = await ensureServerFor(project.data.id, project.data.path, {
1096
+ agentType: cur.data.agentType,
1097
+ });
1098
+ if (!serverRes.ok) return { ok: false, error: serverRes.error };
1099
+ client = serverRes.data.client;
1100
+ }
1101
+ const remote = await client.deleteSession(ocSessionId);
1102
+ if (!remote.ok) return { ok: false, error: remote.error };
1103
+ }
1104
+
1105
+ // (3) Hapus lokal (transaksional: messages, prompts, history, session).
1106
+ const res = store.deleteSession(sessionId);
1107
+ if (res.ok) {
1108
+ // Lampiran gambar milik Session ikut dibersihkan (best effort).
1109
+ attachments?.removeSession(sessionId);
1110
+ onDeleted?.(sessionId);
1111
+ }
1112
+ return res;
1113
+ }
1114
+
1115
+ async function sendFreeTextInput(
1116
+ sessionId: string,
1117
+ text: string,
1118
+ files: string[] = [],
1119
+ images: string[] = [],
1120
+ ): Promise<SimpleResult> {
1121
+ // Pesan berupa teks bebas dan/atau gambar: teks kosong diizinkan bila ada
1122
+ // gambar terlampir (mis. kirim gambar saja), selainnya wajib non-kosong.
1123
+ const hasText = text.trim().length > 0;
1124
+ const hasImages = images.length > 0;
1125
+ if (!hasText && !hasImages) {
1126
+ return { ok: false, error: "TEXT_EMPTY" };
1127
+ }
1128
+ if (hasText && text.length > MAX_FREE_TEXT_LENGTH) {
1129
+ return { ok: false, error: "TEXT_TOO_LONG" };
1130
+ }
1131
+ const cur = store.getSession(sessionId);
1132
+ if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
1133
+ if (cur.data.status !== "running") return { ok: false, error: "SESSION_NOT_ACTIVE" };
1134
+ const ocSessionId = cur.data.ocSessionId;
1135
+ if (!ocSessionId) return { ok: false, error: "SESSION_NOT_ACTIVE" };
1136
+
1137
+ // Pastikan server masih hidup SEBELUM menyimpan echo user.
1138
+ const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
1139
+ if (!handle) return { ok: false, error: "SESSION_NOT_ACTIVE" };
1140
+
1141
+ // Gambar upload dipetakan ke info + path absolut. Kegagalan resolve
1142
+ // (id tidak dikenal / sudah dihapus) menolak pengiriman tanpa echo.
1143
+ const imageInfos: { id: string; filename: string; mime: string; absPath: string }[] = [];
1144
+ for (const id of images) {
1145
+ const info = attachments?.info(sessionId, id);
1146
+ if (!info?.ok) return { ok: false, error: "ATTACHMENT_NOT_FOUND" };
1147
+ imageInfos.push(info.data);
1148
+ }
1149
+
1150
+ // Echo pesan user (disimpan + dikirim ke Client) sebelum menunggu balasan.
1151
+ // Referensi @file ikut di-echo sebagai part `file` mime text/plain;
1152
+ // gambar upload sebagai part `file` mime image/* dengan `attachmentId`
1153
+ // agar renderer dapat memuat bytes lewat HTTP. Path relatif project
1154
+ // diubah ke file URL absolut — opencode mem-parse `url` dengan URL()
1155
+ // sehingga `file://rel/path` (host=rel) ditolak di Linux; host kosong
1156
+ // (`file:///abs/path`) valid.
1157
+ const promptFiles: OpenCodeFileRef[] = [];
1158
+ const userParts: MessagePart[] = [{ type: "text", text }];
1159
+ for (const filename of files) {
1160
+ const url = `file://${path.resolve(cur.data.cwd, filename)}`;
1161
+ userParts.push({ type: "file", mime: "text/plain", filename, url });
1162
+ promptFiles.push({ filename, mime: "text/plain", url });
1163
+ }
1164
+ /** Lampiran biner (zip, audio, …): tidak dikirim sebagai part. */
1165
+ const pathOnly: { filename: string; absPath: string }[] = [];
1166
+ for (const info of imageInfos) {
1167
+ // Jaring pengaman: `file://` harus menunjuk path absolut (host kosong).
1168
+ // `file://rel/path` membuat host="rel" yang ditolak opencode.
1169
+ const url = `file://${path.resolve(info.absPath)}`;
1170
+ // Echo selalu memakai mime asli (renderer memilih thumbnail / kartu).
1171
+ userParts.push({
1172
+ type: "file",
1173
+ mime: info.mime,
1174
+ filename: info.filename,
1175
+ url,
1176
+ attachmentId: info.id,
1177
+ });
1178
+ // Ke model: hanya mime yang diterima provider (gambar/PDF/teks). Mime
1179
+ // lain merusak turn (dan riwayat) dengan "functionality not supported".
1180
+ const modelMime = modelFacingMime(info.mime, info.filename);
1181
+ if (modelMime) promptFiles.push({ filename: info.filename, mime: modelMime, url });
1182
+ else pathOnly.push({ filename: info.filename, absPath: path.resolve(info.absPath) });
1183
+ }
1184
+ const userMessage: SessionMessage = {
1185
+ id: `usr_${randomUUID()}`,
1186
+ sessionId,
1187
+ role: "user",
1188
+ parts: userParts,
1189
+ createdAt: now(),
1190
+ };
1191
+ if (!store.insertMessage(userMessage).ok) return { ok: false, error: "MESSAGE_WRITE_FAILED" };
1192
+ onMessage?.(userMessage);
1193
+
1194
+ // Judul SEMENTARA sampai opencode mengirim judul hasil generate
1195
+ // (`session.updated`, biasanya beberapa detik). Bila masih placeholder /
1196
+ // null, turunkan dari teks prompt pertama. Gambar saja -> "Image".
1197
+ if (isPlaceholderTitle(cur.data.title)) {
1198
+ const candidate = deriveSessionTitle(text) ?? (hasImages ? "Image" : null);
1199
+ if (candidate && store.updateSessionTitle(sessionId, candidate).ok) {
1200
+ onTitleChange?.(sessionId, candidate);
1201
+ }
1202
+ }
1203
+
1204
+ // Turn sebelumnya yang belum idle ditutup dulu agar parts-nya tidak
1205
+ // tercampur ke turn baru dan tidak hilang.
1206
+ turns.finish(sessionId);
1207
+
1208
+ // Mulai turn streaming: parts dari SSE `message.part.updated` di-forward.
1209
+ // Jaring pengaman bila `session.idle` tidak pernah datang (mis. server
1210
+ // mati di tengah turn): batas IDLE, di-reset tiap tanda hidup — parts
1211
+ // yang sudah terkumpul tetap disimpan saat dipicu.
1212
+ turns.begin(sessionId, turnIdleTimeoutMs, friendlySendError("TURN_TIMEOUT"));
1213
+
1214
+ // Custom instruction Project dibaca saat kirim (bukan saat Session dibuat)
1215
+ // agar perubahan instruksi langsung berlaku di prompt berikutnya.
1216
+ const projectRes = store.getProjectById(cur.data.projectId);
1217
+ const system = projectRes.ok ? (projectRes.data.instructions ?? null) : null;
1218
+
1219
+ // File biner dirujuk lewat path agar model bisa memakai tool (bash/read)
1220
+ // bila perlu; teks yang tampil di bubble tetap teks asli pengguna.
1221
+ const promptText =
1222
+ pathOnly.length === 0
1223
+ ? text
1224
+ : [
1225
+ text,
1226
+ "Attached files (saved on disk, not shown inline):",
1227
+ ...pathOnly.map((f) => `- ${f.filename}: ${f.absPath}`),
1228
+ ]
1229
+ .filter((line) => line !== "")
1230
+ .join("\n");
1231
+
1232
+ enqueue(sessionId, async () => {
1233
+ try {
1234
+ // `prompt_async` balas 204 begitu prompt diterima; hasil turn tiba
1235
+ // lewat SSE dan ditutup oleh `session.idle`.
1236
+ const res = await handle.client.promptAsync(
1237
+ ocSessionId,
1238
+ promptText,
1239
+ cur.data.model,
1240
+ promptFiles,
1241
+ cur.data.agent,
1242
+ system,
1243
+ );
1244
+ if (!res.ok) {
1245
+ turns.fail(sessionId, friendlySendError(res.error ?? "OC_PROMPT_ASYNC_FAILED"));
1246
+ }
1247
+ } catch (e) {
1248
+ turns.fail(sessionId, friendlySendError(`SEND_FAILED: ${(e as Error).message}`));
1249
+ }
1250
+ });
1251
+
1252
+ return { ok: true };
1253
+ }
1254
+
1255
+ async function resolvePrompt(
1256
+ sessionId: string,
1257
+ promptId: string,
1258
+ response: PromptResponse,
1259
+ ): Promise<SimpleResult> {
1260
+ const p = store.getPrompt(promptId);
1261
+ if (!p.ok) return { ok: false, error: "PROMPT_NOT_FOUND" };
1262
+ if (p.data.sessionId !== sessionId) return { ok: false, error: "PROMPT_NOT_FOUND" };
1263
+ if (p.data.status !== "pending") return { ok: false, error: "PROMPT_ALREADY_RESOLVED" };
1264
+
1265
+ const cur = store.getSession(sessionId);
1266
+ if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
1267
+ const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
1268
+ if (!handle) return { ok: false, error: "SESSION_NOT_ACTIVE" };
1269
+
1270
+ // Kumpulkan request pending yang identik (grup kartu yang sama di UI):
1271
+ // jawaban satu kartu diteruskan ke seluruh anggota grup. Tanpa ini,
1272
+ // kartu kembar yang tertinggal di server membuat turn berikutnya
1273
+ // menggantung menunggu keputusan yang sudah diambil user.
1274
+ const targets = store
1275
+ .listPendingPrompts(sessionId)
1276
+ .filter((q) => q.id !== promptId && permissionGroupKey(q) === permissionGroupKey(p.data));
1277
+
1278
+ /**
1279
+ * Reply satu request permission. `always` = "Always allow" — opencode
1280
+ * mengingat pola yang disetujui sehingga request identik berikutnya tidak
1281
+ * lagi menampilkan kartu.
1282
+ */
1283
+ const replyPermission = (id: string, r: PromptResponse): Promise<SimpleResult> => {
1284
+ if (r === "approve") return handle.client.replyPermission(id, "once");
1285
+ if (r === "always") return handle.client.replyPermission(id, "always");
1286
+ if (r === "deny" || r === "cancel") {
1287
+ // Cancel == tolak (tanpa meneruskan input), setara deny di mode TUI.
1288
+ return handle.client.replyPermission(id, "reject");
1289
+ }
1290
+ return Promise.resolve({ ok: false, error: "INVALID_PROMPT_RESPONSE" });
1291
+ };
1292
+
1293
+ let res: SimpleResult;
1294
+ if (p.data.kind === "permission") {
1295
+ res = await replyPermission(promptId, response);
1296
+ } else {
1297
+ // kind === "question" — opsi harus bagian dari daftar (Requirement 6.6).
1298
+ // Opsi tidak dikenal TIDAK ditolak keras bila daftar tersimpan tidak
1299
+ // cocok dengan opsi live (baris DB lama / skema server berubah):
1300
+ // diteruskan apa adanya ke opencode yang tahu daftar sebenarnya —
1301
+ // menolak di sini membuat tombol opsi terasa mati tanpa feedback.
1302
+ if (typeof response === "object" && response !== null) {
1303
+ if ("answers" in response) {
1304
+ // Multi-question: SATU array label per pertanyaan — diteruskan
1305
+ // apa adanya ke reply API (skema Question.Reply).
1306
+ res = await handle.client.replyQuestion(promptId, response.answers);
1307
+ } else if ("options" in response) {
1308
+ // Multi-select: kirim seluruh label terpilih (urut tampilan).
1309
+ res = await handle.client.replyQuestion(promptId, [response.options]);
1310
+ } else {
1311
+ res = await handle.client.replyQuestion(promptId, [[response.option]]);
1312
+ }
1313
+ } else if (response === "cancel") {
1314
+ res = await handle.client.rejectQuestion(promptId);
1315
+ } else {
1316
+ return { ok: false, error: "INVALID_PROMPT_RESPONSE" };
1317
+ }
1318
+ }
1319
+ if (!res.ok) {
1320
+ if (res.error === OC_REQUEST_GONE) {
1321
+ // opencode tidak lagi menunggu request ini (404): sudah dijawab dari
1322
+ // klien lain, dibatalkan karena turn di-abort, atau server opencode
1323
+ // di-restart. Tanpa penutupan di sini, baris DB tetap `pending`:
1324
+ // kartu tidak bisa dijawab MAUPUN di-Skip (keduanya kena 404) dan
1325
+ // composer tetap tersembunyi — session terkunci. Tutup prompt (dan
1326
+ // kembarannya) secara lokal supaya UI pulih.
1327
+ updatePromptResolved(promptId);
1328
+ for (const twin of targets) {
1329
+ updatePromptResolved(twin.id);
1330
+ onPromptResolved?.(sessionId, twin.id);
1331
+ }
1332
+ return { ok: false, error: "PROMPT_EXPIRED" };
1333
+ }
1334
+ return { ok: false, error: res.error };
1335
+ }
1336
+ updatePromptResolved(promptId);
1337
+ // Jawaban question cukup lewat reply API — tidak di-echo ke riwayat chat
1338
+ // agar transcript bersih (agent tetap menerima jawabannya).
1339
+ // Fan-out best-effort ke kartu kembar; kegagalan satu request tidak
1340
+ // menggagalkan jawaban utama yang sudah diterima server.
1341
+ for (const twin of targets) {
1342
+ const twinRes =
1343
+ p.data.kind === "permission"
1344
+ ? await replyPermission(twin.id, response)
1345
+ : typeof response === "object" && response !== null
1346
+ ? "answers" in response
1347
+ ? await handle.client.replyQuestion(twin.id, response.answers)
1348
+ : "options" in response
1349
+ ? await handle.client.replyQuestion(twin.id, [response.options])
1350
+ : await handle.client.replyQuestion(twin.id, [[response.option]])
1351
+ : await handle.client.rejectQuestion(twin.id);
1352
+ if (twinRes.ok) {
1353
+ updatePromptResolved(twin.id);
1354
+ onPromptResolved?.(sessionId, twin.id);
1355
+ }
1356
+ }
1357
+ return { ok: true };
1358
+ }
1359
+
1360
+ /** Requirement 2.4: seluruh Session `running` tanpa server -> `crashed`. */
1361
+ function reconcileOnStartup(): void {
1362
+ for (const s of store.listSessions()) {
1363
+ if (s.status === "running") {
1364
+ updateStatus(s.id, "crashed", now());
1365
+ }
1366
+ }
1367
+ }
1368
+
1369
+ /**
1370
+ * Requirement 2.3: simpan status terakhir Session `running` dalam budget
1371
+ * waktu, lalu hentikan seluruh server headless.
1372
+ */
1373
+ async function shutdown(): Promise<void> {
1374
+ // Refresh skill tertunda tidak relevan lagi: server dihentikan, skill
1375
+ // terbaca saat server di-spawn berikutnya.
1376
+ for (const projectId of [...skillRefreshTimers.keys()]) cancelScheduledSkillRefresh(projectId);
1377
+ pendingSkillRefresh.clear();
1378
+ const running = store.listSessions().filter((s) => s.status === "running");
1379
+ const results = running.map((s) => store.insertStatusHistory(s.id, "running", now()));
1380
+ await raceWithTimeout(Promise.resolve(results), shutdownBudgetMs);
1381
+ await Promise.all([servers.stopAll(), claudeServers?.stopAll()]);
1382
+ }
1383
+
1384
+ /** Balapan dengan timer budget; timer selalu dibersihkan setelah selesai. */
1385
+ async function raceWithTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
1386
+ let timer: unknown;
1387
+ const timeout: Promise<T> = new Promise((_resolve, reject) => {
1388
+ timer = setTimeoutFn(() => reject(new Error("shutdown budget exceeded")), ms);
1389
+ });
1390
+ try {
1391
+ return await Promise.race([p, timeout]);
1392
+ } finally {
1393
+ if (timer !== undefined) clearTimeoutFn(timer);
1394
+ }
1395
+ }
1396
+
1397
+ // Saat server keluar tak terduga, tandai Session project tsb `crashed`.
1398
+ const onBackendExit = (agentType: AgentType) => (projectId: string) => {
1399
+ const key = backendKey(agentType, projectId);
1400
+ if (exitNotified.has(key)) return;
1401
+ exitNotified.add(key);
1402
+ const crashedAt = now();
1403
+ for (const s of store.listSessions()) {
1404
+ if (s.projectId === projectId && s.agentType === agentType && s.status === "running") {
1405
+ // Server mati di tengah turn: selamatkan parts yang sudah ter-stream.
1406
+ turns.finish(s.id);
1407
+ updateStatus(s.id, "crashed", crashedAt);
1408
+ unmapOcSessions(s.id);
1409
+ }
1410
+ }
1411
+ };
1412
+ servers.onServerExit(onBackendExit("opencode"));
1413
+ claudeServers?.onServerExit(onBackendExit("claude-code"));
1414
+
1415
+ return {
1416
+ createSession,
1417
+ listSessions,
1418
+ getSession,
1419
+ stopSession,
1420
+ interruptSession,
1421
+ resumeSession,
1422
+ deleteSession,
1423
+ releaseProject,
1424
+ listModels,
1425
+ listAgents,
1426
+ listMcp,
1427
+ listSkills,
1428
+ refreshSkills,
1429
+ hasPendingSkillRefresh,
1430
+ setSessionModel,
1431
+ setSessionAgent,
1432
+ findFiles,
1433
+ sessionContext,
1434
+ sendFreeTextInput,
1435
+ resolvePrompt,
1436
+ reconcileOnStartup,
1437
+ shutdown,
1438
+ };
1439
+ }