kcg-code 0.0.0-stage → 0.1.0

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