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,319 @@
1
+ /**
2
+ * OpenCode_Server — lifecycle proses `opencode serve` per Project.
3
+ *
4
+ * Satu instance `opencode serve` hanya dapat membuat Session di direktori
5
+ * kerja (cwd) tempat ia dijalankan (terverifikasi: `POST /session` tidak
6
+ * menerima field `directory`). Karena Project KCG Code bisa berbeda-beda
7
+ * direktori, setiap Project mendapat satu server sendiri yang di-spawn di
8
+ * `project.path` — menggantikan model "satu PTY per Session".
9
+ *
10
+ * - `--port 0` meminta port acak; port ditemukan dari baris log
11
+ * "opencode server listening on http://127.0.0.1:<port>" di stderr.
12
+ * - stdout/stderr terus di-drain (buffer log bergulir) agar pipe tidak penuh
13
+ * dan memblokir proses server yang berumur panjang.
14
+ * - `ensureServer` idempoten dan aman terhadap konkurensi (in-flight promise).
15
+ * - Exit callback hanya dipanggil bila instance yang keluar masih instance
16
+ * aktif — instance pengganti tidak terpengaruh.
17
+ */
18
+
19
+ import { existsSync, statSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+ import type { Result } from "../result";
23
+ import type { OpenCodeClient, OpenCodeEvent } from "./opencode-client";
24
+ import { createOpenCodeClient } from "./opencode-client";
25
+
26
+ export interface OpenCodeServerHandle {
27
+ projectId: string;
28
+ baseUrl: string;
29
+ client: OpenCodeClient;
30
+ }
31
+
32
+ /** Kontrak proses minimal (di-mock pada test). */
33
+ export interface ServerProcessLike {
34
+ kill(signal: "SIGTERM" | "SIGKILL"): void;
35
+ exited: Promise<number | null>;
36
+ /** Stream stderr (untuk menemukan port). */
37
+ stderr: ReadableStream<Uint8Array>;
38
+ /** Stream stdout (di-drain agar tidak penuh). */
39
+ stdout?: ReadableStream<Uint8Array>;
40
+ /** PID proses (untuk SIGKILL fallback). */
41
+ pid?: number;
42
+ }
43
+
44
+ export interface OpenCodeServerManagerOptions {
45
+ /** Spawn `opencode serve` (injectable untuk test). */
46
+ spawn?: (cwd: string) => ServerProcessLike;
47
+ /** Factory client (injectable untuk test). */
48
+ clientFactory?: (baseUrl: string) => OpenCodeClient;
49
+ /** Batas waktu menunggu port tercetak di stderr (ms). */
50
+ portTimeoutMs?: number;
51
+ /** Batas waktu menunggu health (ms). */
52
+ readyTimeoutMs?: number;
53
+ /** Hook event SSE dari tiap server (diteruskan ke Session_Manager). */
54
+ onEvent?: (projectId: string, ev: OpenCodeEvent) => void;
55
+ }
56
+
57
+ export interface OpenCodeServerManager {
58
+ ensureServer(projectId: string, projectPath: string): Promise<Result<OpenCodeServerHandle>>;
59
+ getServer(projectId: string): OpenCodeServerHandle | undefined;
60
+ /**
61
+ * Pastikan server hidup; bila file config opencode berubah sejak spawn
62
+ * terakhir, server di-restart agar `/config/providers` (daftar model)
63
+ * dan `/agent` ikut terbaca ulang — `opencode serve` tidak hot-reload.
64
+ */
65
+ ensureFreshServer(projectId: string, projectPath: string): Promise<Result<OpenCodeServerHandle>>;
66
+ stopServer(projectId: string): Promise<void>;
67
+ stopAll(): Promise<void>;
68
+ /** Daftarkan hook saat server keluar tak terduga. */
69
+ onServerExit(cb: (projectId: string) => void): void;
70
+ }
71
+
72
+ interface ServerInstance {
73
+ handle: OpenCodeServerHandle;
74
+ proc: ServerProcessLike;
75
+ exiting: boolean;
76
+ /** Fingerprint config opencode saat server di-spawn (deteksi perubahan). */
77
+ configFingerprint: string;
78
+ }
79
+
80
+ /**
81
+ * Path file config opencode yang memengaruhi provider/model/agent:
82
+ * project (`opencode.json`/`opencode.jsonc`) + global user config.
83
+ */
84
+ export function opencodeConfigPaths(projectPath: string): string[] {
85
+ const home = homedir();
86
+ return [
87
+ join(projectPath, "opencode.json"),
88
+ join(projectPath, "opencode.jsonc"),
89
+ join(home, ".config", "opencode", "opencode.json"),
90
+ join(home, ".config", "opencode", "opencode.jsonc"),
91
+ join(home, ".opencode", "opencode.json"),
92
+ join(home, ".opencode", "opencode.jsonc"),
93
+ ];
94
+ }
95
+
96
+ /**
97
+ * Fingerprint config: path:mtimeMs untuk tiap file yang ada. Bila nilainya
98
+ * berbeda dari saat spawn, config dianggap berubah dan server perlu restart.
99
+ */
100
+ export function configFingerprint(projectPath: string): string {
101
+ const parts: string[] = [];
102
+ for (const p of opencodeConfigPaths(projectPath)) {
103
+ try {
104
+ if (existsSync(p)) parts.push(`${p}:${statSync(p).mtimeMs}`);
105
+ } catch {
106
+ /* file hilang / tidak readable — lewati */
107
+ }
108
+ }
109
+ return parts.join("|");
110
+ }
111
+
112
+ const DEFAULT_SPAWN: (cwd: string) => ServerProcessLike = (cwd) => {
113
+ const proc = Bun.spawn(["opencode", "serve", "--port", "0", "--hostname", "127.0.0.1"], {
114
+ cwd,
115
+ stdout: "pipe",
116
+ stderr: "pipe",
117
+ });
118
+ return {
119
+ kill: (signal) => proc.kill(signal as NodeJS.Signals),
120
+ exited: proc.exited.then((code) => code),
121
+ stderr: proc.stderr,
122
+ stdout: proc.stdout,
123
+ pid: proc.pid,
124
+ };
125
+ };
126
+
127
+ /** Ekstrak port dari baris log stderr "listening on http://host:port". */
128
+ export function parseListeningPort(line: string): number | null {
129
+ const m = line.match(/listening on http:\/\/[^:]+:(\d+)/);
130
+ return m ? Number(m[1]) : null;
131
+ }
132
+
133
+ /** Poll `GET /global/health` hingga siap atau timeout. */
134
+ export async function waitHealth(client: OpenCodeClient, timeoutMs: number): Promise<boolean> {
135
+ const deadline = Date.now() + timeoutMs;
136
+ while (Date.now() < deadline) {
137
+ if (await client.health()) return true;
138
+ await Bun.sleep(300);
139
+ }
140
+ return false;
141
+ }
142
+
143
+ export function createOpenCodeServerManager(
144
+ opts: OpenCodeServerManagerOptions = {},
145
+ ): OpenCodeServerManager {
146
+ const spawn = opts.spawn ?? DEFAULT_SPAWN;
147
+ const clientFactory = opts.clientFactory ?? createOpenCodeClient;
148
+ const portTimeoutMs = opts.portTimeoutMs ?? 30000;
149
+ const readyTimeoutMs = opts.readyTimeoutMs ?? 60000;
150
+ const onEvent = opts.onEvent;
151
+ const instances = new Map<string, ServerInstance>();
152
+ /** In-flight ensure agar dua panggilan paralel tidak men-spawn dua server. */
153
+ const pending = new Map<string, Promise<Result<OpenCodeServerHandle>>>();
154
+ const exitCbs = new Set<(projectId: string) => void>();
155
+
156
+ function ensureServer(
157
+ projectId: string,
158
+ projectPath: string,
159
+ ): Promise<Result<OpenCodeServerHandle>> {
160
+ const existing = instances.get(projectId);
161
+ if (existing && !existing.exiting) {
162
+ return Promise.resolve({ ok: true, data: existing.handle });
163
+ }
164
+ const inflight = pending.get(projectId);
165
+ if (inflight) return inflight;
166
+ const p = startServer(projectId, projectPath).finally(() => pending.delete(projectId));
167
+ pending.set(projectId, p);
168
+ return p;
169
+ }
170
+
171
+ /**
172
+ * Seperti `ensureServer`, tapi restart dulu bila fingerprint config
173
+ * berubah. Restart adalah stop + ensure (in-flight aman terhadap
174
+ * dua panggilan paralel lewat map `pending`).
175
+ */
176
+ async function ensureFreshServer(
177
+ projectId: string,
178
+ projectPath: string,
179
+ ): Promise<Result<OpenCodeServerHandle>> {
180
+ const existing = instances.get(projectId);
181
+ if (existing && !existing.exiting) {
182
+ if (existing.configFingerprint === configFingerprint(projectPath)) {
183
+ return { ok: true, data: existing.handle };
184
+ }
185
+ await stopServer(projectId);
186
+ }
187
+ return ensureServer(projectId, projectPath);
188
+ }
189
+
190
+ async function startServer(
191
+ projectId: string,
192
+ projectPath: string,
193
+ ): Promise<Result<OpenCodeServerHandle>> {
194
+ let proc: ServerProcessLike;
195
+ try {
196
+ proc = spawn(projectPath);
197
+ } catch (e) {
198
+ return { ok: false, error: `SERVER_START_FAILED: ${(e as Error).message}` };
199
+ }
200
+
201
+ // ---- Drain stdout/stderr berkelanjutan + deteksi port dari stderr ----
202
+ let serverLog = "";
203
+ let port: number | null = null;
204
+ let portResolved = false;
205
+ let resolvePort: (p: number | null) => void = () => {};
206
+ const portPromise = new Promise<number | null>((res) => {
207
+ resolvePort = res;
208
+ });
209
+ const resolveOnce = (p: number | null): void => {
210
+ if (!portResolved) {
211
+ portResolved = true;
212
+ resolvePort(p);
213
+ }
214
+ };
215
+
216
+ const drain = (stream: ReadableStream<Uint8Array>): void => {
217
+ const decoder = new TextDecoder();
218
+ void (async () => {
219
+ const reader = stream.getReader();
220
+ for (;;) {
221
+ const { done, value } = await reader.read();
222
+ if (done) {
223
+ resolveOnce(port);
224
+ return;
225
+ }
226
+ const text = decoder.decode(value, { stream: true });
227
+ serverLog = (serverLog + text).slice(-16000);
228
+ if (port === null) {
229
+ const found = parseListeningPort(text);
230
+ if (found !== null) {
231
+ port = found;
232
+ resolveOnce(found);
233
+ }
234
+ }
235
+ }
236
+ })();
237
+ };
238
+ drain(proc.stderr);
239
+ if (proc.stdout) drain(proc.stdout);
240
+
241
+ const found = await Promise.race([
242
+ portPromise,
243
+ Bun.sleep(portTimeoutMs).then(() => {
244
+ resolveOnce(port);
245
+ return port;
246
+ }),
247
+ ]);
248
+ if (found === null) {
249
+ proc.kill("SIGKILL");
250
+ return {
251
+ ok: false,
252
+ error: `SERVER_START_FAILED: port tidak ditemukan. Log:\n${serverLog.slice(-800)}`,
253
+ };
254
+ }
255
+
256
+ const baseUrl = `http://127.0.0.1:${found}`;
257
+ const client = clientFactory(baseUrl);
258
+ if (!(await waitHealth(client, readyTimeoutMs))) {
259
+ proc.kill("SIGKILL");
260
+ return { ok: false, error: "SERVER_START_FAILED: health check gagal" };
261
+ }
262
+
263
+ const handle: OpenCodeServerHandle = { projectId, baseUrl, client };
264
+ const instance: ServerInstance = {
265
+ handle,
266
+ proc,
267
+ exiting: false,
268
+ configFingerprint: configFingerprint(projectPath),
269
+ };
270
+ instances.set(projectId, instance);
271
+
272
+ if (onEvent) {
273
+ client.subscribeEvents((ev) => onEvent(projectId, ev));
274
+ }
275
+
276
+ // Server mati tak terduga: hanya bila instance ini masih yang aktif.
277
+ void proc.exited.then(() => {
278
+ if (instance.exiting) return;
279
+ if (instances.get(projectId) !== instance) return; // sudah diganti — abaikan
280
+ instances.delete(projectId);
281
+ for (const cb of exitCbs) cb(projectId);
282
+ });
283
+
284
+ return { ok: true, data: handle };
285
+ }
286
+
287
+ async function stopServer(projectId: string): Promise<void> {
288
+ const instance = instances.get(projectId);
289
+ if (!instance) return;
290
+ instance.exiting = true;
291
+ instances.delete(projectId);
292
+ instance.proc.kill("SIGTERM");
293
+ await Promise.race([instance.proc.exited, Bun.sleep(3000)]);
294
+ if (instance.proc.pid !== undefined) {
295
+ try {
296
+ Bun.spawnSync(["kill", "-9", String(instance.proc.pid)]);
297
+ } catch {
298
+ /* best effort */
299
+ }
300
+ } else {
301
+ instance.proc.kill("SIGKILL");
302
+ }
303
+ }
304
+
305
+ async function stopAll(): Promise<void> {
306
+ await Promise.all([...instances.keys()].map((id) => stopServer(id)));
307
+ }
308
+
309
+ return {
310
+ ensureServer,
311
+ ensureFreshServer,
312
+ getServer: (projectId) => instances.get(projectId)?.handle,
313
+ stopServer,
314
+ stopAll,
315
+ onServerExit: (cb) => {
316
+ exitCbs.add(cb);
317
+ },
318
+ };
319
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Event SSE server headless opencode — parsing & langganan.
3
+ *
4
+ * Dipisah dari `opencode-client.ts`:
5
+ * - `parseSseFrame` mengambil baris `data:` dari satu frame SSE.
6
+ * - `normalizeEvent` meratakan payload `{ id, type, properties }` menjadi
7
+ * `{ type, id, ...properties }` agar konsumen mudah membaca `requestID`,
8
+ * `sessionID`, `permission`, `questions`, dst.
9
+ * - `subscribeOpenCodeEvents` menjalankan loop baca `GET /event` dengan
10
+ * reconnect otomatis; mengembalikan fungsi untuk berhenti.
11
+ */
12
+ import type { OpenCodeEvent } from "./opencode-client";
13
+
14
+ /** Parsing satu frame SSE: baris `data:` (dan `event:` bila ada). */
15
+ export function parseSseFrame(frame: string): string | null {
16
+ const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
17
+ if (!dataLine) return null;
18
+ return dataLine.slice(5).trim();
19
+ }
20
+
21
+ /** Normalisasi payload event SSE `{ id, type, properties }` -> event datar. */
22
+ export function normalizeEvent(payload: unknown): OpenCodeEvent | null {
23
+ if (typeof payload !== "object" || payload === null) return null;
24
+ const raw = payload as Record<string, unknown>;
25
+ if (typeof raw.type !== "string") return null;
26
+ const props =
27
+ typeof raw.properties === "object" && raw.properties !== null
28
+ ? (raw.properties as Record<string, unknown>)
29
+ : {};
30
+ return { type: raw.type, ...(raw.id !== undefined ? { id: String(raw.id) } : {}), ...props };
31
+ }
32
+
33
+ /**
34
+ * Buka langganan event SSE (`GET /event`) dan panggil `cb` untuk setiap
35
+ * event. Bila koneksi putus/error, coba lagi setelah jeda 1 detik. Fungsi
36
+ * yang dikembalikan menghentikan langganan (membatalkan reader aktif).
37
+ */
38
+ export function subscribeOpenCodeEvents(
39
+ baseUrl: string,
40
+ cb: (ev: OpenCodeEvent) => void,
41
+ ): () => void {
42
+ let cancelled = false;
43
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
44
+ void (async () => {
45
+ while (!cancelled) {
46
+ try {
47
+ const res = await fetch(`${baseUrl}/event`);
48
+ if (!res.ok || !res.body) {
49
+ await Bun.sleep(1000);
50
+ continue;
51
+ }
52
+ reader = res.body.getReader();
53
+ const decoder = new TextDecoder();
54
+ let buf = "";
55
+ for (;;) {
56
+ const { done, value } = await reader.read();
57
+ if (done || cancelled) break;
58
+ buf += decoder.decode(value, { stream: true });
59
+ const frames = buf.split("\n\n");
60
+ buf = frames.pop() ?? "";
61
+ for (const frame of frames) {
62
+ const data = parseSseFrame(frame);
63
+ if (data === null) continue;
64
+ try {
65
+ const ev = normalizeEvent(JSON.parse(data));
66
+ if (ev) cb(ev);
67
+ } catch {
68
+ /* frame tidak valid — abaikan */
69
+ }
70
+ }
71
+ }
72
+ } catch {
73
+ /* koneksi SSE terputus — coba lagi setelah jeda */
74
+ }
75
+ if (!cancelled) await Bun.sleep(1000);
76
+ }
77
+ })();
78
+ return () => {
79
+ cancelled = true;
80
+ reader?.cancel().catch(() => {});
81
+ };
82
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Project_Manager & Folder_Browser.
3
+ * Sesuai `design.md` — `project-manager.ts`.
4
+ *
5
+ * `listDirectory` dan `createProject` selalu memvalidasi path lewat
6
+ * `sandbox.ts` sebelum operasi filesystem apa pun, dan memakai `db.ts`
7
+ * untuk persistensi.
8
+ */
9
+ import { randomUUID } from "node:crypto";
10
+ import { mkdirSync, readdirSync } from "node:fs";
11
+ import type { SessionStore } from "../../db";
12
+ import type { Project } from "../../types";
13
+ import type { Result, SimpleResult } from "../result";
14
+ import { resolveWithinSandbox } from "./sandbox";
15
+
16
+ export interface ProjectManager {
17
+ listDirectory(relativePath: string): Result<{ entries: string[] }>;
18
+ createProject(name: string, relativePath: string): Result<Project>;
19
+ listProjects(): Project[];
20
+ getProject(projectId: string): Result<Project>;
21
+ /**
22
+ * Simpan custom instruction Project. Teks di-trim; kosong -> `null`
23
+ * (hapus). Menolak teks di atas `MAX_PROJECT_INSTRUCTIONS_LENGTH`.
24
+ */
25
+ updateInstructions(projectId: string, instructions: string): Result<Project>;
26
+ /**
27
+ * Hapus registrasi Project dari Session_Store. Direktori kerja di
28
+ * filesystem TIDAK disentuh — Project hanyalah pendaftaran sebuah folder,
29
+ * jadi menghapusnya tidak boleh menghapus kode/pekerjaan pengguna.
30
+ * Menolak bila masih ada Session miliknya (`PROJECT_HAS_SESSIONS`).
31
+ */
32
+ deleteProject(projectId: string): SimpleResult;
33
+ /** Sandbox_Root aktif (path absolut). */
34
+ getSandboxRoot(): string;
35
+ /**
36
+ * Ganti Sandbox_Root saat berjalan (Settings -> Workspace). Pemanggil
37
+ * WAJIB sudah memvalidasi root lewat `workspace.ts`; dari titik ini semua
38
+ * operasi (listing, create) memakai root baru.
39
+ */
40
+ setSandboxRoot(root: string): void;
41
+ }
42
+
43
+ /** Batas panjang custom instruction Project (karakter). */
44
+ export const MAX_PROJECT_INSTRUCTIONS_LENGTH = 20_000;
45
+
46
+ /** Peta hasil validasi sandbox -> pesan error publik. */
47
+ function sandboxError(reason: string): string {
48
+ return reason === "outside_sandbox" ? "PATH_OUTSIDE_SANDBOX" : "INVALID_PATH_CHARS";
49
+ }
50
+
51
+ /**
52
+ * Membuat instance Project_Manager terikat pada `store`. Sandbox_Root
53
+ * awal dari config; bisa diganti lewat `setSandboxRoot` tanpa restart.
54
+ */
55
+ export function createProjectManager(initialRoot: string, store: SessionStore): ProjectManager {
56
+ let sandboxRoot = initialRoot;
57
+ function listDirectory(relativePath: string): Result<{ entries: string[] }> {
58
+ const res = resolveWithinSandbox(sandboxRoot, relativePath);
59
+ if (!res.ok) return { ok: false, error: sandboxError(res.reason) };
60
+
61
+ const realPath = res.realPath;
62
+ let dirents: import("node:fs").Dirent[];
63
+ try {
64
+ dirents = readdirSync(realPath, { withFileTypes: true });
65
+ } catch {
66
+ return { ok: false, error: "PATH_NOT_FOUND" };
67
+ }
68
+
69
+ // Hanya sub-direktori (Requirement 10.2). Symlink tidak diikuti sehingga
70
+ // entri dari luar sandbox tidak pernah bocor ke hasil listing.
71
+ const entries = dirents
72
+ .filter((d) => d.isDirectory())
73
+ .map((d) => d.name)
74
+ .sort();
75
+ return { ok: true, data: { entries } };
76
+ }
77
+
78
+ function createProject(name: string, relativePath: string): Result<Project> {
79
+ const trimmed = name.trim();
80
+ if (!trimmed) return { ok: false, error: "NAME_REQUIRED" };
81
+
82
+ const byName = store.getProjectByName(trimmed);
83
+ if (byName.ok) return { ok: false, error: "NAME_TAKEN" };
84
+
85
+ const res = resolveWithinSandbox(sandboxRoot, relativePath);
86
+ if (!res.ok) return { ok: false, error: sandboxError(res.reason) };
87
+ const realPath = res.realPath;
88
+
89
+ const byPath = store.getProjectByPath(realPath);
90
+ if (byPath.ok) return { ok: false, error: "PATH_TAKEN" };
91
+
92
+ try {
93
+ mkdirSync(realPath, { recursive: true });
94
+ } catch {
95
+ return { ok: false, error: "MKDIR_FAILED" };
96
+ }
97
+
98
+ const project: Project = {
99
+ id: randomUUID(),
100
+ name: trimmed,
101
+ path: realPath,
102
+ createdAt: Date.now(),
103
+ instructions: null,
104
+ };
105
+ const inserted = store.insertProject(project);
106
+ if (!inserted.ok) return inserted;
107
+ return { ok: true, data: project };
108
+ }
109
+
110
+ function listProjects(): Project[] {
111
+ return store.listProjects();
112
+ }
113
+
114
+ function getProject(projectId: string): Result<Project> {
115
+ return store.getProjectById(projectId);
116
+ }
117
+
118
+ function updateInstructions(projectId: string, instructions: string): Result<Project> {
119
+ const trimmed = instructions.trim();
120
+ if (trimmed.length > MAX_PROJECT_INSTRUCTIONS_LENGTH) {
121
+ return { ok: false, error: "INSTRUCTIONS_TOO_LONG" };
122
+ }
123
+ return store.updateProjectInstructions(projectId, trimmed === "" ? null : trimmed);
124
+ }
125
+
126
+ /**
127
+ * Hapus pendaftaran Project. Direktori kerjanya dibiarkan utuh: Project
128
+ * adalah referensi ke sebuah folder Sandbox, bukan pemiliknya — menghapus
129
+ * folder berisi kode pengguna jauh melampaui maksud aksi ini.
130
+ */
131
+ function deleteProject(projectId: string): SimpleResult {
132
+ return store.deleteProject(projectId);
133
+ }
134
+
135
+ return {
136
+ listDirectory,
137
+ createProject,
138
+ listProjects,
139
+ getProject,
140
+ updateInstructions,
141
+ deleteProject,
142
+ getSandboxRoot: () => sandboxRoot,
143
+ setSandboxRoot: (root: string) => {
144
+ sandboxRoot = root;
145
+ },
146
+ };
147
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Validator Sandbox_Root — `resolveWithinSandbox`.
3
+ * Sesuai `design.md` — `sandbox.ts`.
4
+ *
5
+ * Langkah validasi:
6
+ * 1. Tolak segmen path yang berisi `..` sebelum resolusi apa pun.
7
+ * 2. Validasi karakter terlarang OS pada tiap segmen nama direktori.
8
+ * 3. Resolve path (mengikuti symlink) lalu pastikan hasil berada di dalam
9
+ * `sandboxRoot` (startsWith `rootReal + sep` atau sama persis).
10
+ *
11
+ * Path yang belum ada di filesystem (mis. saat `createProject` akan mkdir)
12
+ * tetap diperbolehkan selama seluruh segmen valid dan ancestor terdalam yang
13
+ * sudah ada berada di dalam sandbox.
14
+ */
15
+ import { existsSync, realpathSync } from "node:fs";
16
+ import path from "node:path";
17
+
18
+ export type SandboxReason = "outside_sandbox" | "invalid_chars";
19
+
20
+ export type SandboxResult = { ok: true; realPath: string } | { ok: false; reason: SandboxReason };
21
+
22
+ /** Karakter terlarang untuk nama direktori sesuai OS server. */
23
+ const FORBIDDEN_CHARS = process.platform === "win32" ? /[<>:"|?*\0/\\]/ : /[\0]/;
24
+
25
+ function isWithin(rootReal: string, p: string): boolean {
26
+ return p === rootReal || p.startsWith(rootReal + path.sep);
27
+ }
28
+
29
+ function splitSegments(userPath: string): string[] {
30
+ return userPath.split(/[\\/]/).filter((s) => s.length > 0 && s !== ".");
31
+ }
32
+
33
+ /**
34
+ * Memvalidasi dan me-resolve `userPath` terhadap `sandboxRoot`.
35
+ * Mengembalikan path absolut hasil resolusi bila valid.
36
+ */
37
+ export function resolveWithinSandbox(sandboxRoot: string, userPath: string): SandboxResult {
38
+ let rootReal: string;
39
+ try {
40
+ rootReal = realpathSync(sandboxRoot);
41
+ } catch {
42
+ return { ok: false, reason: "outside_sandbox" };
43
+ }
44
+
45
+ const segments = splitSegments(userPath);
46
+
47
+ // 1. Tolak ".." sebelum resolusi apa pun
48
+ // 2. Validasi karakter terlarang OS
49
+ for (const seg of segments) {
50
+ if (seg === "..") return { ok: false, reason: "outside_sandbox" };
51
+ if (FORBIDDEN_CHARS.test(seg)) return { ok: false, reason: "invalid_chars" };
52
+ }
53
+
54
+ // 3. Gabungkan + resolve, mengikuti symlink.
55
+ // Path absolut dipakai langsung (bukan digabung) sehingga path absolut di
56
+ // luar sandbox pasti tertolak oleh pemeriksaan kontainmen.
57
+ const candidate = path.isAbsolute(userPath)
58
+ ? path.normalize(userPath)
59
+ : path.join(rootReal, ...segments);
60
+
61
+ // Cari ancestor terdalam yang sudah ada di filesystem agar `realpath`
62
+ // (yang mengikuti symlink) dapat dijalankan meski path tujuan belum dibuat.
63
+ let existing = candidate;
64
+ const tail: string[] = [];
65
+ while (!existsSync(existing)) {
66
+ const parent = path.dirname(existing);
67
+ if (parent === existing) return { ok: false, reason: "outside_sandbox" };
68
+ tail.unshift(path.basename(existing));
69
+ existing = parent;
70
+ }
71
+
72
+ let realExisting: string;
73
+ try {
74
+ realExisting = realpathSync(existing);
75
+ } catch {
76
+ return { ok: false, reason: "outside_sandbox" };
77
+ }
78
+
79
+ if (!isWithin(rootReal, realExisting)) return { ok: false, reason: "outside_sandbox" };
80
+
81
+ const realPath = tail.length === 0 ? realExisting : path.join(realExisting, ...tail);
82
+ return { ok: true, realPath };
83
+ }