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,374 @@
1
+ /**
2
+ * WebSocket_Gateway — registrasi koneksi, reattach, broadcast (versi headless).
3
+ *
4
+ * Perubahan dari versi PTY/TUI:
5
+ * - Cursor `seq` per-koneksi dihapus (tidak ada lagi chunk Output_Stream);
6
+ * reattach mengirim `history` berisi `messages` + `prompts` pending.
7
+ * - `input` / `prompt_response` kini asinkron (HTTP ke server headless);
8
+ * error asinkron dilaporkan via pesan `error` ke Client pengirim.
9
+ * - Pesan `stop` meneruskan ke `stopSession`; pesan `interrupt` meneruskan
10
+ * ke `interruptSession` (hentikan balasan saja, Session tetap berjalan).
11
+ *
12
+ * Prinsip yang dipertahankan: kegagalan `send` ke satu Client ditangkap
13
+ * per-Client (hapus subscriber) tanpa menghentikan broadcast ke Client lain
14
+ * (Requirement 5.3); attach ke Session tidak ditemukan -> `error` + `close`
15
+ * (Requirement 4.3).
16
+ */
17
+ import type { SessionStore } from "../../db";
18
+ import type {
19
+ InteractivePrompt,
20
+ MessagePart,
21
+ PromptResponse,
22
+ SessionMessage,
23
+ SessionStatus,
24
+ } from "../../types";
25
+ import { ErrorCodes, type ServerMessage } from "../../ws-protocol";
26
+ import type { SessionManager } from "./session-manager";
27
+
28
+ /** Abstraksi koneksi — diimplementasikan oleh `bunWsSubscriber` atau mock test. */
29
+ export interface Subscriber {
30
+ send(msg: ServerMessage): void;
31
+ close(): void;
32
+ }
33
+
34
+ /**
35
+ * Koneksi yang ter-attach ke sebuah Session.
36
+ * `sessionId: null` = mode daftar (tanpa Session) — hanya menerima broadcast
37
+ * global seperti `session_title` (daftar Session di halaman Project).
38
+ */
39
+ export interface AttachedInfo {
40
+ sessionId: string | null;
41
+ }
42
+
43
+ export interface WebSocketGatewayOptions {
44
+ store: SessionStore;
45
+ sessionManager: SessionManager;
46
+ }
47
+
48
+ export interface WebSocketGateway {
49
+ attach(sub: Subscriber, sessionId: string): void;
50
+ input(
51
+ sub: Subscriber,
52
+ sessionId: string,
53
+ text: string,
54
+ files?: string[],
55
+ images?: string[],
56
+ ): void;
57
+ promptResponse(
58
+ sub: Subscriber,
59
+ sessionId: string,
60
+ promptId: string,
61
+ response: PromptResponse,
62
+ ): void;
63
+ stop(sub: Subscriber, sessionId: string): void;
64
+ /** Hentikan balasan model saja (interrupt) — Session tetap running. */
65
+ interrupt(sub: Subscriber, sessionId: string): void;
66
+ notifyMessage(sessionId: string, message: SessionMessage): void;
67
+ notifyMessagePart(sessionId: string, messageId: string, part: MessagePart): void;
68
+ notifyMessagePartDelta(
69
+ sessionId: string,
70
+ messageId: string,
71
+ partId: string,
72
+ field: string,
73
+ delta: string,
74
+ ): void;
75
+ notifyPrompt(sessionId: string, prompt: InteractivePrompt): void;
76
+ notifySessionStatus(sessionId: string, status: SessionStatus): void;
77
+ /** Broadcast judul Session baru hasil generate opencode. */
78
+ notifySessionTitle(sessionId: string, title: string): void;
79
+ /** Beri tahu subscriber apakah model sedang merespon (turn aktif). */
80
+ notifyTurnActive(sessionId: string, active: boolean): void;
81
+ notifySessionDeleted(sessionId: string): void;
82
+ /** Daftar Project/Session berubah — broadcast ke semua koneksi. */
83
+ notifyDataChanged(): void;
84
+ notifyPromptResolved(sessionId: string, promptId: string): void;
85
+ notifyError(sessionId: string, code: string, message: string): void;
86
+ detach(sub: Subscriber): void;
87
+ subscriberCount(sessionId: string): number;
88
+ }
89
+
90
+ /** Pemetaan error domain Session_Manager ke kode protokol `error`. */
91
+ function toErrorCode(error: string): string {
92
+ if (error === ErrorCodes.SESSION_NOT_FOUND) return ErrorCodes.SESSION_NOT_FOUND;
93
+ if (error === "SESSION_NOT_RUNNING" || error === "SESSION_NOT_ACTIVE") {
94
+ return ErrorCodes.SESSION_NOT_RUNNING;
95
+ }
96
+ if (error === "TEXT_EMPTY" || error === "TEXT_TOO_LONG") return ErrorCodes.INVALID_TEXT;
97
+ if (error === "ATTACHMENT_NOT_FOUND") return ErrorCodes.ATTACHMENT_NOT_FOUND;
98
+ if (error === ErrorCodes.PROMPT_NOT_FOUND) return ErrorCodes.PROMPT_NOT_FOUND;
99
+ if (error === ErrorCodes.PROMPT_ALREADY_RESOLVED) return ErrorCodes.PROMPT_ALREADY_RESOLVED;
100
+ if (error === "INVALID_PROMPT_OPTION" || error === "INVALID_PROMPT_RESPONSE") {
101
+ return ErrorCodes.INVALID_RESPONSE;
102
+ }
103
+ return "ERROR";
104
+ }
105
+
106
+ /**
107
+ * Pesan ramah untuk kegagalan reply prompt (kartu tetap bisa dicoba lagi):
108
+ * error mentah dari opencode-client berupa kode seperti
109
+ * `OC_QUESTION_REPLY_FAILED(400)` — kurang berguna bagi user.
110
+ */
111
+ function friendlyPromptError(raw: string): string {
112
+ if (raw === "PROMPT_EXPIRED") {
113
+ return "The agent stopped waiting for that answer, so it was dismissed. Send a new message to continue.";
114
+ }
115
+ if (
116
+ raw === ErrorCodes.PROMPT_NOT_FOUND ||
117
+ raw.startsWith("OC_QUESTION") ||
118
+ raw.startsWith("OC_PERMISSION")
119
+ ) {
120
+ return "The agent is no longer waiting for this answer (it may have moved on). Try again or send a new message.";
121
+ }
122
+ if (raw === "SESSION_NOT_ACTIVE" || raw === "SESSION_NOT_RUNNING") {
123
+ return "The session is not running. Start it again to answer.";
124
+ }
125
+ if (raw === ErrorCodes.PROMPT_ALREADY_RESOLVED) {
126
+ return "This prompt was already answered.";
127
+ }
128
+ return `Failed to send the answer (${raw.split("(")[0] ?? raw}). Try again.`;
129
+ }
130
+
131
+ export function createWebSocketGateway(opts: WebSocketGatewayOptions): WebSocketGateway {
132
+ const { store, sessionManager } = opts;
133
+ const subs = new Map<Subscriber, AttachedInfo>();
134
+
135
+ /** Kirim pesan; bila `send` melempar, hapus subscriber (Req 5.3). */
136
+ function sendSafe(sub: Subscriber, msg: ServerMessage): boolean {
137
+ try {
138
+ sub.send(msg);
139
+ return true;
140
+ } catch {
141
+ subs.delete(sub);
142
+ return false;
143
+ }
144
+ }
145
+
146
+ function attach(sub: Subscriber, sessionId: string): void {
147
+ // Mode daftar (sessionId kosong): tanpa `history`, cukup daftar sebagai
148
+ // penerima broadcast global (`session_title`, `session_status`,
149
+ // `session_deleted`).
150
+ if (sessionId === "") {
151
+ subs.set(sub, { sessionId: null });
152
+ return;
153
+ }
154
+ // (1) validasi Session ada di store (Req 4.3)
155
+ const sess = store.getSession(sessionId);
156
+ if (!sess.ok) {
157
+ sendSafe(sub, {
158
+ type: "error",
159
+ code: ErrorCodes.SESSION_NOT_FOUND,
160
+ message: "Session not found.",
161
+ });
162
+ sub.close();
163
+ return;
164
+ }
165
+
166
+ // (2) riwayat pesan terstruktur + prompt pending (Req 4.1, 4.4)
167
+ const messages = store.getMessages(sessionId);
168
+ if (!messages.ok) {
169
+ sendSafe(sub, { type: "error", code: "STORE_ERROR", message: messages.error });
170
+ sub.close();
171
+ return;
172
+ }
173
+ const prompts = store.listPendingPrompts(sessionId);
174
+ if (!sendSafe(sub, { type: "history", sessionId, messages: messages.data, prompts })) return;
175
+
176
+ // (3) daftarkan sebagai subscriber live
177
+ subs.set(sub, { sessionId });
178
+ }
179
+
180
+ function input(
181
+ sub: Subscriber,
182
+ sessionId: string,
183
+ text: string,
184
+ files?: string[],
185
+ images?: string[],
186
+ ): void {
187
+ void sessionManager
188
+ .sendFreeTextInput(sessionId, text, files ?? [], images ?? [])
189
+ .then((res) => {
190
+ if (!res.ok) {
191
+ const code = toErrorCode(res.error ?? "");
192
+ sendSafe(sub, { type: "error", code, message: res.error ?? "ERROR" });
193
+ }
194
+ })
195
+ .catch(() => {
196
+ sendSafe(sub, { type: "error", code: "ERROR", message: "Failed to send the message." });
197
+ });
198
+ }
199
+
200
+ function promptResponse(
201
+ sub: Subscriber,
202
+ sessionId: string,
203
+ promptId: string,
204
+ response: PromptResponse,
205
+ ): void {
206
+ void sessionManager
207
+ .resolvePrompt(sessionId, promptId, response)
208
+ .then((res) => {
209
+ if (!res.ok && res.error === "PROMPT_EXPIRED") {
210
+ // Prompt sudah ditutup secara lokal (agent tidak lagi menunggu):
211
+ // hapus kartu di semua klien lalu beri tahu pengirim kenapa —
212
+ // session kembali bisa dipakai (composer muncul lagi).
213
+ notifyPromptResolved(sessionId, promptId);
214
+ sendSafe(sub, {
215
+ type: "error",
216
+ code: ErrorCodes.PROMPT_EXPIRED,
217
+ message: friendlyPromptError("PROMPT_EXPIRED"),
218
+ });
219
+ return;
220
+ }
221
+ if (!res.ok) {
222
+ // Kode PROMPT_FAILED: client menampilkan error ini DI kartu prompt
223
+ // (bukan banner global) agar klik yang gagal tidak terasa mati.
224
+ sendSafe(sub, {
225
+ type: "error",
226
+ code: ErrorCodes.PROMPT_FAILED,
227
+ message: friendlyPromptError(res.error ?? "ERROR"),
228
+ });
229
+ return;
230
+ }
231
+ notifyPromptResolved(sessionId, promptId);
232
+ })
233
+ .catch(() => {
234
+ sendSafe(sub, {
235
+ type: "error",
236
+ code: ErrorCodes.PROMPT_FAILED,
237
+ message: "Failed to send the answer to the agent. Try again.",
238
+ });
239
+ });
240
+ }
241
+
242
+ function stop(sub: Subscriber, sessionId: string): void {
243
+ const res = sessionManager.stopSession(sessionId);
244
+ if (!res.ok) {
245
+ const code = toErrorCode(res.error ?? "");
246
+ sendSafe(sub, { type: "error", code, message: res.error ?? "ERROR" });
247
+ }
248
+ }
249
+
250
+ function interrupt(sub: Subscriber, sessionId: string): void {
251
+ const res = sessionManager.interruptSession(sessionId);
252
+ if (!res.ok) {
253
+ const code = toErrorCode(res.error ?? "");
254
+ sendSafe(sub, { type: "error", code, message: res.error ?? "ERROR" });
255
+ }
256
+ }
257
+
258
+ function broadcast(sessionId: string, build: () => ServerMessage): void {
259
+ for (const [sub, info] of subs) {
260
+ if (info.sessionId !== sessionId) continue;
261
+ sendSafe(sub, build());
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Broadcast ke seluruh koneksi — dipakai pesan yang relevan lintas Session
267
+ * (mis. `session_title`: pemilik koneksi mungkin sedang membuka daftar
268
+ * Session, bukan me-attach satu Session).
269
+ */
270
+ function broadcastAll(build: () => ServerMessage): void {
271
+ for (const sub of subs.keys()) {
272
+ sendSafe(sub, build());
273
+ }
274
+ }
275
+
276
+ function notifyMessage(sessionId: string, message: SessionMessage): void {
277
+ broadcast(sessionId, () => ({ type: "message", sessionId, message }));
278
+ }
279
+
280
+ function notifyMessagePart(sessionId: string, messageId: string, part: MessagePart): void {
281
+ broadcast(sessionId, () => ({ type: "message_part", sessionId, messageId, part }));
282
+ }
283
+
284
+ function notifyMessagePartDelta(
285
+ sessionId: string,
286
+ messageId: string,
287
+ partId: string,
288
+ field: string,
289
+ delta: string,
290
+ ): void {
291
+ broadcast(sessionId, () => ({
292
+ type: "message_part_delta",
293
+ sessionId,
294
+ messageId,
295
+ partId,
296
+ field,
297
+ delta,
298
+ }));
299
+ }
300
+
301
+ function notifyPrompt(sessionId: string, prompt: InteractivePrompt): void {
302
+ broadcast(sessionId, () => ({ type: "prompt", sessionId, prompt }));
303
+ }
304
+
305
+ function notifySessionStatus(sessionId: string, status: SessionStatus): void {
306
+ // Relevan lintas halaman: titik status di sidebar / daftar Session harus
307
+ // ikut berubah walau koneksi itu tidak sedang attach Session ini.
308
+ broadcastAll(() => ({ type: "session_status", sessionId, status }));
309
+ }
310
+
311
+ function notifySessionTitle(sessionId: string, title: string): void {
312
+ // `session_title` relevan bagi koneksi mana pun (daftar Session di
313
+ // halaman Project), jadi dikirim ke semua koneksi — termasuk yang tidak
314
+ // sedang attach satu Session.
315
+ broadcastAll(() => ({ type: "session_title", sessionId, title }));
316
+ }
317
+
318
+ function notifyTurnActive(sessionId: string, active: boolean): void {
319
+ broadcast(sessionId, () => ({ type: "turn_active", sessionId, active }));
320
+ }
321
+
322
+ /**
323
+ * Session dihapus permanen — dikirim ke SEMUA koneksi: yang sedang membuka
324
+ * Session ini meninggalkan halamannya, sidebar/daftar menghapus barisnya.
325
+ */
326
+ function notifySessionDeleted(sessionId: string): void {
327
+ broadcastAll(() => ({ type: "session_deleted", sessionId }));
328
+ }
329
+
330
+ function notifyDataChanged(): void {
331
+ broadcastAll(() => ({ type: "data_changed" }));
332
+ }
333
+
334
+ function notifyPromptResolved(sessionId: string, promptId: string): void {
335
+ broadcast(sessionId, () => ({ type: "prompt_resolved", sessionId, promptId }));
336
+ }
337
+
338
+ function notifyError(sessionId: string, code: string, message: string): void {
339
+ broadcast(sessionId, () => ({ type: "error", code, message }));
340
+ }
341
+
342
+ function detach(sub: Subscriber): void {
343
+ subs.delete(sub);
344
+ }
345
+
346
+ function subscriberCount(sessionId: string): number {
347
+ let n = 0;
348
+ for (const info of subs.values()) {
349
+ if (info.sessionId === sessionId) n += 1;
350
+ }
351
+ return n;
352
+ }
353
+
354
+ return {
355
+ attach,
356
+ input,
357
+ promptResponse,
358
+ stop,
359
+ interrupt,
360
+ notifyMessage,
361
+ notifyMessagePart,
362
+ notifyMessagePartDelta,
363
+ notifyPrompt,
364
+ notifySessionStatus,
365
+ notifySessionTitle,
366
+ notifyTurnActive,
367
+ notifySessionDeleted,
368
+ notifyDataChanged,
369
+ notifyPromptResolved,
370
+ notifyError,
371
+ detach,
372
+ subscriberCount,
373
+ };
374
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Workspace (Sandbox_Root) yang bisa diubah dari Settings tanpa restart.
3
+ *
4
+ * Sandbox_Root adalah BATAS KEAMANAN: semua Project, Folder_Browser, dan
5
+ * agent hanya boleh bekerja di dalamnya (termasuk saat diakses lewat
6
+ * tunnel). Karena itu mengganti root:
7
+ * - wajib konfirmasi PIN/password bila app dikunci (`AuthService`),
8
+ * - menolak folder yang terlalu luas (root filesystem, home, direktori
9
+ * sistem) dan folder milik KCG Code sendiri (data / config),
10
+ * - menolak bila masih ada Project terdaftar di luar root baru — Project
11
+ * tidak boleh diam-diam menjadi "di luar sandbox",
12
+ * - ditulis ke `config.json` (sumber kebenaran yang sama dengan CLI
13
+ * `kcgcode init`), lalu berlaku seketika di proses yang berjalan.
14
+ */
15
+ import {
16
+ accessSync,
17
+ constants,
18
+ existsSync,
19
+ mkdirSync,
20
+ readFileSync,
21
+ realpathSync,
22
+ renameSync,
23
+ statSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import path from "node:path";
28
+ import type { Project } from "../../types";
29
+ import type { Result } from "../result";
30
+
31
+ export const WORKSPACE_PATH_MAX = 1024;
32
+
33
+ export interface WorkspaceInfo {
34
+ /** Root aktif (path absolut hasil realpath). */
35
+ root: string;
36
+ /** Root ditampilkan dengan `~` untuk home (lebih mudah dibaca). */
37
+ display: string;
38
+ /** Jumlah Project terdaftar di dalam root. */
39
+ projectCount: number;
40
+ configPath: string;
41
+ }
42
+
43
+ export interface WorkspaceCandidate {
44
+ /** Path absolut hasil realpath (folder dibuat bila `create`). */
45
+ root: string;
46
+ /** Folder belum ada & akan dibuat. */
47
+ willCreate: boolean;
48
+ }
49
+
50
+ /** Tampilkan path dengan `~` bila berada di home pengguna. */
51
+ export function displayPath(p: string, home: string = homedir()): string {
52
+ // `undefined` eksplisit dari pemanggil -> tetap pakai home asli.
53
+ home ??= homedir();
54
+ if (p === home) return "~";
55
+ if (p.startsWith(home + path.sep)) return `~${p.slice(home.length)}`;
56
+ return p;
57
+ }
58
+
59
+ /** Perluas `~` / `~/x` ke home. Path relatif ditolak (ambigu: relatif ke cwd server). */
60
+ export function expandUserPath(input: string, home: string = homedir()): string | null {
61
+ const raw = input.trim();
62
+ if (raw === "~") return home;
63
+ if (raw.startsWith("~/") || raw.startsWith("~\\")) return path.join(home, raw.slice(2));
64
+ if (!path.isAbsolute(raw)) return null;
65
+ return path.normalize(raw);
66
+ }
67
+
68
+ /**
69
+ * Direktori yang terlalu luas / sensitif untuk dijadikan workspace: memberi
70
+ * agent akses ke seluruh sistem atau seluruh home pengguna.
71
+ */
72
+ export function isTooBroad(realRoot: string, home: string = homedir()): boolean {
73
+ const p = path.resolve(realRoot);
74
+ // Root filesystem (`/`, `C:\`).
75
+ if (path.dirname(p) === p) return true;
76
+ const homeReal = safeRealpath(home) ?? home;
77
+ if (p === homeReal || p === home) return true;
78
+ // Leluhur home (mis. `/home`, `/Users`) juga mencakup home pengguna lain.
79
+ if (homeReal.startsWith(p + path.sep)) return true;
80
+ if (process.platform !== "win32") {
81
+ const system = [
82
+ "/bin",
83
+ "/boot",
84
+ "/dev",
85
+ "/etc",
86
+ "/lib",
87
+ "/lib64",
88
+ "/opt",
89
+ "/proc",
90
+ "/root",
91
+ "/run",
92
+ "/sbin",
93
+ "/srv",
94
+ "/sys",
95
+ "/tmp",
96
+ "/usr",
97
+ "/var",
98
+ "/System",
99
+ "/Library",
100
+ "/Applications",
101
+ "/private",
102
+ "/Volumes",
103
+ "/mnt",
104
+ "/media",
105
+ ];
106
+ // Persis direktori sistem (sub-folder di /mnt/data atau /opt/work boleh).
107
+ if (system.includes(p)) return true;
108
+ }
109
+ return false;
110
+ }
111
+
112
+ function safeRealpath(p: string): string | null {
113
+ try {
114
+ return realpathSync(p);
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ function isWithin(root: string, p: string): boolean {
121
+ return p === root || p.startsWith(root + path.sep);
122
+ }
123
+
124
+ /**
125
+ * Validasi kandidat root baru. `protectedDirs` = folder milik KCG Code
126
+ * (data, uploads, config) yang tidak boleh berada di dalam workspace —
127
+ * agent bisa membaca DB (hash sandi, token tunnel) bila itu terjadi.
128
+ */
129
+ export function validateWorkspaceRoot(
130
+ input: unknown,
131
+ opts: { create: boolean; protectedDirs: string[]; home?: string },
132
+ ): Result<WorkspaceCandidate> {
133
+ if (typeof input !== "string" || input.trim() === "") {
134
+ return { ok: false, error: "WORKSPACE_PATH_REQUIRED" };
135
+ }
136
+ if (input.length > WORKSPACE_PATH_MAX || input.includes("\0")) {
137
+ return { ok: false, error: "WORKSPACE_PATH_INVALID" };
138
+ }
139
+ const home = opts.home ?? homedir();
140
+ const expanded = expandUserPath(input, home);
141
+ if (expanded === null) return { ok: false, error: "WORKSPACE_PATH_NOT_ABSOLUTE" };
142
+
143
+ let willCreate = false;
144
+ if (!existsSync(expanded)) {
145
+ if (!opts.create) return { ok: false, error: "WORKSPACE_NOT_FOUND" };
146
+ // Hanya satu tingkat terakhir yang boleh dibuat: induknya harus ada
147
+ // (menghindari salah ketik membuat pohon folder baru di tempat acak).
148
+ const parent = path.dirname(expanded);
149
+ if (!existsSync(parent)) return { ok: false, error: "WORKSPACE_PARENT_NOT_FOUND" };
150
+ willCreate = true;
151
+ }
152
+
153
+ // Resolusi symlink: keputusan keamanan berdasarkan lokasi NYATA.
154
+ const real = willCreate
155
+ ? path.join(
156
+ safeRealpath(path.dirname(expanded)) ?? path.dirname(expanded),
157
+ path.basename(expanded),
158
+ )
159
+ : safeRealpath(expanded);
160
+ if (real === null) return { ok: false, error: "WORKSPACE_NOT_FOUND" };
161
+
162
+ if (!willCreate) {
163
+ try {
164
+ if (!statSync(real).isDirectory()) return { ok: false, error: "WORKSPACE_NOT_DIRECTORY" };
165
+ } catch {
166
+ return { ok: false, error: "WORKSPACE_NOT_FOUND" };
167
+ }
168
+ }
169
+
170
+ if (isTooBroad(real, home)) return { ok: false, error: "WORKSPACE_TOO_BROAD" };
171
+
172
+ for (const dir of opts.protectedDirs) {
173
+ const d = safeRealpath(dir) ?? path.resolve(dir);
174
+ // Workspace tidak boleh MEMUAT folder data KCG Code, dan sebaliknya
175
+ // tidak boleh berada di dalamnya.
176
+ if (isWithin(real, d) || isWithin(d, real)) {
177
+ return { ok: false, error: "WORKSPACE_CONTAINS_APP_DATA" };
178
+ }
179
+ }
180
+
181
+ const writableTarget = willCreate ? path.dirname(real) : real;
182
+ try {
183
+ accessSync(writableTarget, constants.R_OK | constants.W_OK);
184
+ } catch {
185
+ return { ok: false, error: "WORKSPACE_NOT_WRITABLE" };
186
+ }
187
+
188
+ return { ok: true, data: { root: real, willCreate } };
189
+ }
190
+
191
+ /** Project yang akan berada DI LUAR root baru (menghalangi perubahan). */
192
+ export function projectsOutside(root: string, projects: Project[]): Project[] {
193
+ return projects.filter((p) => !isWithin(root, safeRealpath(p.path) ?? path.resolve(p.path)));
194
+ }
195
+
196
+ /**
197
+ * Tulis `sandboxRoot` ke config.json. Kunci lain di file dipertahankan;
198
+ * penulisan atomik (file sementara + rename) agar config tidak pernah
199
+ * setengah tertulis bila proses mati di tengah jalan.
200
+ */
201
+ export function writeSandboxRoot(configPath: string, root: string): Result<null> {
202
+ let obj: Record<string, unknown> = {};
203
+ try {
204
+ if (existsSync(configPath)) {
205
+ const parsed = JSON.parse(readFileSync(configPath, "utf8")) as unknown;
206
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
207
+ obj = parsed as Record<string, unknown>;
208
+ }
209
+ }
210
+ } catch {
211
+ return { ok: false, error: "WORKSPACE_CONFIG_UNREADABLE" };
212
+ }
213
+ obj.sandboxRoot = root;
214
+ try {
215
+ mkdirSync(path.dirname(configPath), { recursive: true });
216
+ const tmp = `${configPath}.${process.pid}.tmp`;
217
+ writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`, "utf8");
218
+ renameSync(tmp, configPath);
219
+ } catch {
220
+ return { ok: false, error: "WORKSPACE_CONFIG_WRITE_FAILED" };
221
+ }
222
+ return { ok: true, data: null };
223
+ }