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,339 @@
1
+ /**
2
+ * TurnStream — state turn balasan model yang sedang di-stream (per Session).
3
+ *
4
+ * Dipisah dari `session-manager.ts`:
5
+ * - Satu turn = pesan assistant yang dirakit dari parts SSE
6
+ * (`message.part.updated`) yang diterima setelah `message.updated`
7
+ * role=assistant, disimpan per messageId sesuai urutan kemunculan.
8
+ * - `finish` (sukses `session.idle` / stop / server keluar) menyimpan parts;
9
+ * `discard` (interrupt user) membuang parts; `fail` menyimpan parts lalu
10
+ * menulis pesan error role=assistant + `onError`.
11
+ * - `noteError` mencatat `session.error` non-abort TANPA menutup turn —
12
+ * error sering tidak terminal (mis. referensi `@file` gagal dibaca) dan
13
+ * jawaban tetap di-stream setelahnya; pesan error hanya ditulis ke history
14
+ * bila turn ditutup tanpa konten apa pun.
15
+ * - Timer turn adalah batas KEDIAMAN (inactivity), bukan batas total: setiap
16
+ * tanda hidup (`keepAlive`, part baru, pesan assistant) me-reset-nya,
17
+ * sehingga turn panjang yang sehat (reasoning/tool/sub-agent/retry) tidak
18
+ * dipotong di tengah jalan.
19
+ */
20
+ import { randomUUID } from "node:crypto";
21
+ import type { SessionStore } from "../../db";
22
+ import type { MessagePart, SessionMessage } from "../../types";
23
+
24
+ /** Satu turn balasan yang sedang di-stream (SSE `message.part.updated`). */
25
+ interface StreamingTurn {
26
+ /**
27
+ * Id pesan assistant di opencode (`msg_...`) yang sedang dibangun.
28
+ * Satu turn bisa memuat beberapa pesan assistant (mis. sub-agent/tool
29
+ * yang memancarkan `msg_...` sendiri), jadi disimpan sebagai Set.
30
+ */
31
+ assistantMsgIds: Set<string>;
32
+ /** Parts terakumulasi per pesan (messageId -> partId -> part). */
33
+ parts: Map<string, Map<string, MessagePart>>;
34
+ /** Urutan kemunculan messageId agar penyimpanan mengikuti alur turn. */
35
+ order: string[];
36
+ /** Sudah difinalisasi (persist + onMessage) — cegah pemrosesan ganda. */
37
+ finalized: boolean;
38
+ /**
39
+ * Error turn yang DITUNDA (`noteError`). opencode memancarkan
40
+ * `session.error` yang tidak terminal lalu turn BERLANJUT — menutup turn
41
+ * di sini membuang jawaban yang menyusul. Banner tetap tampil seketika;
42
+ * pesan error baru ditulis ke history saat turn ditutup BILA tidak ada
43
+ * konten baru setelah error (error memang yang terakhir terjadi).
44
+ */
45
+ pendingError: { message: string; partsAtError: number } | null;
46
+ /** Timer batas idle turn; di-reset (`keepAlive`) tiap ada tanda hidup. */
47
+ timeout?: unknown;
48
+ /** Konfigurasi timer idle: durasi sunyi maksimum & pesan saat dipicu. */
49
+ idleTimeoutMs: number;
50
+ timeoutMessage: string;
51
+ }
52
+
53
+ export interface TurnStreamOptions {
54
+ store: SessionStore;
55
+ now: () => number;
56
+ /** Pesan assistant final/sementara — diteruskan ke Client. */
57
+ onMessage?: (message: SessionMessage) => void;
58
+ /** Part yang sedang di-stream — diteruskan ke Client. */
59
+ onMessagePart?: (sessionId: string, messageId: string, part: MessagePart) => void;
60
+ /**
61
+ * Potongan teks baru untuk satu part (`message.part.delta`) — diteruskan ke
62
+ * Client sebagai delta (bukan teks penuh) agar ringan tiap token.
63
+ */
64
+ onMessagePartDelta?: (
65
+ sessionId: string,
66
+ messageId: string,
67
+ partId: string,
68
+ field: string,
69
+ delta: string,
70
+ ) => void;
71
+ /** Error asinkron turn (mis. timeout) — banner di Client. */
72
+ onError?: (sessionId: string, message: string) => void;
73
+ /** Perubahan status turn — Client tahu kapan tombol stop aktif. */
74
+ onTurnChange?: (sessionId: string, active: boolean) => void;
75
+ /**
76
+ * Dipanggil saat timer idle terpicu: `false` = tunda fire dan pasang ulang
77
+ * timer — turn sedang menunggu sesuatu yang bukan sunyi (mis. keputusan
78
+ * user pada kartu permission/question). Tanpa ini, user yang lama memutus
79
+ * kartu kehilangan jawaban yang sebenarnya masih hidup.
80
+ */
81
+ shouldFireIdle?: (sessionId: string) => boolean;
82
+ setTimeoutFn: (cb: () => void, ms: number) => unknown;
83
+ clearTimeoutFn: (handle: unknown) => void;
84
+ }
85
+
86
+ function emptyTurn(): StreamingTurn {
87
+ return {
88
+ assistantMsgIds: new Set(),
89
+ parts: new Map(),
90
+ order: [],
91
+ finalized: false,
92
+ pendingError: null,
93
+ idleTimeoutMs: 0,
94
+ timeoutMessage: "",
95
+ };
96
+ }
97
+
98
+ export class TurnStream {
99
+ private readonly turns = new Map<string, StreamingTurn>();
100
+
101
+ constructor(private readonly opts: TurnStreamOptions) {}
102
+
103
+ /** Apakah ada turn streaming aktif untuk Session ini. */
104
+ has(sessionId: string): boolean {
105
+ return this.turns.has(sessionId);
106
+ }
107
+
108
+ /**
109
+ * Mulai turn streaming: buat entry + pasang timer idle (jaring pengaman).
110
+ * Timer adalah batas KEDIAMAN, bukan batas total: setiap tanda hidup (part
111
+ * baru, pesan assistant, status opencode) me-reset-nya lewat `keepAlive`,
112
+ * sehingga turn panjang yang sehat tidak dipotong. Bila sunyi terus sampai
113
+ * batas (mis. server mati di tengah turn), timer memanggil `fail` dengan
114
+ * `timeoutMessage` — parts yang sudah terkumpul tetap disimpan.
115
+ */
116
+ begin(sessionId: string, timeoutMs: number, timeoutMessage: string): void {
117
+ const turn = emptyTurn();
118
+ turn.idleTimeoutMs = timeoutMs;
119
+ turn.timeoutMessage = timeoutMessage;
120
+ this.turns.set(sessionId, turn);
121
+ this.armTimer(sessionId, turn);
122
+ // Beri tahu Client bahwa model mulai merespon (tombol stop/interrupt aktif).
123
+ this.opts.onTurnChange?.(sessionId, true);
124
+ }
125
+
126
+ /** Pasang ulang timer idle turn (timer lama dibersihkan lebih dulu). */
127
+ private armTimer(sessionId: string, turn: StreamingTurn): void {
128
+ if (turn.timeout !== undefined) this.opts.clearTimeoutFn(turn.timeout);
129
+ // Callback memeriksa handle MASIH yang terpasang: bila timer sudah
130
+ // di-reset (`keepAlive`) tapi timer lama sempat terpicu (race), jangan
131
+ // gagalkan turn yang justru sedang hidup.
132
+ let handle: unknown;
133
+ handle = this.opts.setTimeoutFn(() => {
134
+ if (this.turns.get(sessionId) !== turn || turn.timeout !== handle) return;
135
+ // Turn menunggu input user (kartu permission pending, dsb.) -> bukan
136
+ // sunyi: tunda fire dan pasang ulang timer.
137
+ if (this.opts.shouldFireIdle?.(sessionId) === false) {
138
+ this.armTimer(sessionId, turn);
139
+ return;
140
+ }
141
+ this.fail(sessionId, turn.timeoutMessage);
142
+ }, turn.idleTimeoutMs);
143
+ turn.timeout = handle;
144
+ }
145
+
146
+ /**
147
+ * Tanda hidup opencode di tengah turn (part baru, status busy/retry):
148
+ * reset timer idle agar turn panjang yang sehat tidak dipicu timeout.
149
+ */
150
+ keepAlive(sessionId: string): void {
151
+ const turn = this.turns.get(sessionId);
152
+ if (!turn || turn.finalized) return;
153
+ this.armTimer(sessionId, turn);
154
+ }
155
+
156
+ /**
157
+ * Catat error `session.error` non-abort pada turn yang masih berjalan:
158
+ * banner diteruskan ke Client SEKETIKA, tapi turn TIDAK ditutup — error
159
+ * semacam ini sering tidak terminal (mis. `@file` gagal dibaca; turn
160
+ * lanjut dan jawaban tetap di-stream). Saat turn ditutup, pesan error
161
+ * hanya ditulis ke history bila TIDAK ada konten baru setelah error
162
+ * (lihat `finish`) — kalau jawaban menyusul, transcript tetap bersih.
163
+ */
164
+ noteError(sessionId: string, message: string): void {
165
+ const turn = this.turns.get(sessionId);
166
+ if (!turn || turn.finalized) return;
167
+ turn.pendingError = { message, partsAtError: this.countParts(turn) };
168
+ this.opts.onError?.(sessionId, message);
169
+ this.armTimer(sessionId, turn);
170
+ }
171
+
172
+ /** Catat id pesan assistant (`message.updated` role=assistant) pada turn aktif. */
173
+ noteAssistantMessage(sessionId: string, messageId: string): void {
174
+ const turn = this.turns.get(sessionId);
175
+ if (!turn) return;
176
+ turn.assistantMsgIds.add(messageId);
177
+ // Pesan assistant baru = tanda hidup: reset timer idle turn.
178
+ this.armTimer(sessionId, turn);
179
+ }
180
+
181
+ /**
182
+ * Terima satu part streaming dari pesan assistant yang dikenal: simpan ke
183
+ * akumulasi turn (per messageId) lalu forward ke Client. Part yang datang
184
+ * sebelum assistant dikenal / untuk messageId lain diabaikan.
185
+ */
186
+ acceptPart(sessionId: string, messageId: string, part: MessagePart): void {
187
+ const turn = this.turns.get(sessionId);
188
+ if (!turn || turn.assistantMsgIds.size === 0) return;
189
+ if (!turn.assistantMsgIds.has(messageId)) return;
190
+ if (typeof part.type !== "string" || typeof part.id !== "string") return;
191
+ let byPart = turn.parts.get(messageId);
192
+ if (!byPart) {
193
+ byPart = new Map<string, MessagePart>();
194
+ turn.parts.set(messageId, byPart);
195
+ turn.order.push(messageId);
196
+ }
197
+ byPart.set(part.id, part);
198
+ // Part baru = tanda hidup: reset timer idle turn.
199
+ this.armTimer(sessionId, turn);
200
+ this.opts.onMessagePart?.(sessionId, messageId, part);
201
+ }
202
+
203
+ /**
204
+ * Terima potongan teks streaming (`message.part.delta`, opencode >= 1.x):
205
+ * token model tiba lewat event ini, sementara `message.part.updated` hanya
206
+ * dikirim di awal (teks kosong) dan di akhir (teks lengkap). Delta
207
+ * ditambahkan ke part yang terakumulasi (agar yang tersimpan tetap utuh
208
+ * walau `updated` final tidak datang) lalu diteruskan ke Client.
209
+ * Delta untuk part/pesan yang belum dikenal diabaikan — `updated` final
210
+ * tetap membawa teks lengkapnya.
211
+ */
212
+ acceptDelta(
213
+ sessionId: string,
214
+ messageId: string,
215
+ partId: string,
216
+ field: string,
217
+ delta: string,
218
+ ): void {
219
+ const turn = this.turns.get(sessionId);
220
+ if (!turn || turn.finalized || !turn.assistantMsgIds.has(messageId)) return;
221
+ const part = turn.parts.get(messageId)?.get(partId);
222
+ if (!part) return;
223
+ const cur = (part as Record<string, unknown>)[field];
224
+ if (cur !== undefined && typeof cur !== "string") return;
225
+ // Salinan baru: part yang sudah dikirim ke Client tidak dimutasi.
226
+ turn.parts.get(messageId)?.set(partId, { ...part, [field]: (cur ?? "") + delta });
227
+ this.armTimer(sessionId, turn);
228
+ this.opts.onMessagePartDelta?.(sessionId, messageId, partId, field, delta);
229
+ }
230
+
231
+ /**
232
+ * Simpan seluruh pesan assistant yang sudah terakumulasi dari parts SSE
233
+ * (satu pesan per messageId, sesuai urutan kemunculan). Dipakai baik oleh
234
+ * finalisasi sukses (`session.idle`) maupun gagal — parts yang sudah
235
+ * ter-stream tidak boleh hilang walau turn berakhir dengan error.
236
+ */
237
+ private persistParts(sessionId: string, turn: StreamingTurn): void {
238
+ const createdAt = this.opts.now();
239
+ for (const messageId of turn.order) {
240
+ const byPart = turn.parts.get(messageId);
241
+ if (!byPart || byPart.size === 0) continue;
242
+ const message: SessionMessage = {
243
+ id: messageId,
244
+ sessionId,
245
+ role: "assistant",
246
+ parts: [...byPart.values()],
247
+ createdAt,
248
+ };
249
+ // Turn ulang atas messageId yang sama (UNIQUE) gagal disimpan — pesan
250
+ // tetap diteruskan agar Client tidak kehilangan balasan.
251
+ this.opts.store.insertMessage(message);
252
+ this.opts.onMessage?.(message);
253
+ }
254
+ }
255
+
256
+ /** Tandai turn tertutup: bersihkan timer + hapus entry + broadcast tidak aktif. */
257
+ private close(sessionId: string, turn: StreamingTurn): void {
258
+ turn.finalized = true;
259
+ if (turn.timeout !== undefined) this.opts.clearTimeoutFn(turn.timeout);
260
+ this.turns.delete(sessionId);
261
+ this.opts.onTurnChange?.(sessionId, false);
262
+ }
263
+
264
+ /**
265
+ * Tutup turn sukses: rakit pesan assistant dari parts hasil SSE, simpan,
266
+ * kirim ke Client. Dipanggil saat `session.idle` Session akar (atau
267
+ * stop/exit).
268
+ *
269
+ * Sumber kebenaran adalah parts SSE, bukan balasan `POST /message` — pada
270
+ * turn panjang (mis. sub-agent) koneksi POST bisa putus sebelum balasan
271
+ * datang, sehingga pesan final tidak akan pernah tersimpan.
272
+ */
273
+ finish(sessionId: string): void {
274
+ const turn = this.turns.get(sessionId);
275
+ if (!turn || turn.finalized) return;
276
+ this.close(sessionId, turn);
277
+ this.persistParts(sessionId, turn);
278
+ // Error yang ditunda hanya menjadi pesan bila TIDAK ada konten baru
279
+ // setelah error (error memang yang terakhir terjadi — gagal terminal).
280
+ // Bila jawaban menyusul setelah error (kasus `@file` gagal dibaca),
281
+ // menulis error di atas jawaban asli hanya membuat error palsu.
282
+ // Banner sudah dikirim saat `noteError` — di sini cukup tulis history.
283
+ const pending = turn.pendingError;
284
+ if (pending && this.countParts(turn) === pending.partsAtError) {
285
+ this.writeErrorMessage(sessionId, pending.message, false);
286
+ }
287
+ }
288
+
289
+ /** Total jumlah part yang sudah terakumulasi pada turn. */
290
+ private countParts(turn: StreamingTurn): number {
291
+ let total = 0;
292
+ for (const byPart of turn.parts.values()) total += byPart.size;
293
+ return total;
294
+ }
295
+
296
+ /**
297
+ * Buang turn yang sedang di-stream TANPA menyimpan parts-nya. Dipakai saat
298
+ * user meng-interrupt: balasan parsial yang belum selesai dibuang agar
299
+ * tidak tersimpan & tidak muncul kembali di riwayat/Client. Broadcast turn
300
+ * tidak aktif tetap dikirim agar tombol stop di UI mati.
301
+ */
302
+ discard(sessionId: string): void {
303
+ const turn = this.turns.get(sessionId);
304
+ if (!turn || turn.finalized) return;
305
+ this.close(sessionId, turn);
306
+ }
307
+
308
+ /**
309
+ * Tutup turn dalam kondisi gagal (prompt ditolak, `session.error`, atau
310
+ * timeout): parts yang sudah ter-stream tetap disimpan, lalu pesan error
311
+ * ber-role assistant (part `type: "error"`) ditulis ke history agar
312
+ * kegagalan terlihat dan bertahan setelah reattach. `onError` tetap
313
+ * dipanggil untuk banner instan di Client.
314
+ */
315
+ fail(sessionId: string, message: string): void {
316
+ const turn = this.turns.get(sessionId);
317
+ if (!turn || turn.finalized) return;
318
+ this.close(sessionId, turn);
319
+ this.persistParts(sessionId, turn);
320
+ this.writeErrorMessage(sessionId, message);
321
+ }
322
+
323
+ /**
324
+ * Tulis pesan error role=assistant (part `type: "error"`) ke history agar
325
+ * kegagalan terlihat dan bertahan setelah reattach, plus banner instan ke
326
+ * Client — kecuali `banner=false` (banner sudah dikirim lebih dulu).
327
+ */
328
+ private writeErrorMessage(sessionId: string, message: string, banner = true): void {
329
+ const errMsg: SessionMessage = {
330
+ id: `err_${randomUUID()}`,
331
+ sessionId,
332
+ role: "assistant",
333
+ parts: [{ type: "error", text: message }],
334
+ createdAt: this.opts.now(),
335
+ };
336
+ if (this.opts.store.insertMessage(errMsg).ok) this.opts.onMessage?.(errMsg);
337
+ if (banner) this.opts.onError?.(sessionId, message);
338
+ }
339
+ }