kcg-code 0.1.0 → 0.2.1

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 (83) hide show
  1. package/README.md +2 -0
  2. package/package.json +2 -1
  3. package/src/cli/__tests__/daemon.test.ts +84 -0
  4. package/src/cli/__tests__/dashboard.test.ts +170 -8
  5. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  6. package/src/cli/__tests__/port.test.ts +86 -0
  7. package/src/cli/args.ts +69 -6
  8. package/src/cli/attach.ts +154 -0
  9. package/src/cli/commands.ts +174 -21
  10. package/src/cli/daemon.ts +275 -0
  11. package/src/cli/dashboard.tsx +880 -159
  12. package/src/cli/index.ts +10 -0
  13. package/src/cli/ink-react.ts +26 -0
  14. package/src/cli/keep-awake.ts +148 -0
  15. package/src/cli/port.ts +92 -0
  16. package/src/cli/theme.ts +6 -6
  17. package/src/components/layout/AppShell.tsx +8 -3
  18. package/src/components/projects/HomeChat.tsx +85 -48
  19. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  20. package/src/components/projects/ProjectInstructions.tsx +27 -38
  21. package/src/components/projects/ProjectSidePanel.tsx +382 -316
  22. package/src/components/sessions/AgentLogo.tsx +42 -0
  23. package/src/components/sessions/AgentPicker.tsx +13 -4
  24. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
  25. package/src/components/sessions/ContextPanel.tsx +159 -59
  26. package/src/components/sessions/ModelPicker.tsx +65 -27
  27. package/src/components/sessions/ModelSearchList.tsx +137 -57
  28. package/src/components/sessions/NewSessionDialog.tsx +100 -117
  29. package/src/components/sessions/SessionComposer.tsx +2 -1
  30. package/src/components/sessions/SessionHeader.tsx +1 -0
  31. package/src/components/sessions/SessionList.tsx +173 -66
  32. package/src/components/sessions/SessionRow.tsx +176 -76
  33. package/src/components/sessions/SessionView.tsx +17 -13
  34. package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
  35. package/src/components/settings/LhrRemoteAccess.tsx +20 -25
  36. package/src/components/settings/RemoteAccessGroup.tsx +242 -267
  37. package/src/components/settings/prefs.tsx +69 -1
  38. package/src/components/skills/InstallNotifications.tsx +4 -3
  39. package/src/components/skills/InstallSkillDialog.tsx +256 -99
  40. package/src/components/skills/SkillCards.tsx +52 -8
  41. package/src/components/ui/adw-dialog.tsx +114 -0
  42. package/src/components/ui/adw-toggle-group.tsx +75 -0
  43. package/src/components/ui/search-entry.tsx +70 -0
  44. package/src/config.ts +32 -3
  45. package/src/hooks/useAgentPicker.ts +6 -2
  46. package/src/hooks/useSessionList.ts +95 -2
  47. package/src/lib/__tests__/project-extensions.test.ts +32 -1
  48. package/src/lib/__tests__/project-overview.test.ts +15 -0
  49. package/src/lib/__tests__/routes.test.ts +2 -2
  50. package/src/lib/__tests__/session-selection.test.ts +41 -0
  51. package/src/lib/__tests__/skill-installs.test.ts +1 -0
  52. package/src/lib/__tests__/skills-catalog.test.ts +36 -8
  53. package/src/lib/agent-types.ts +49 -0
  54. package/src/lib/project-extensions.ts +66 -4
  55. package/src/lib/project-overview.ts +14 -8
  56. package/src/lib/routes.ts +13 -4
  57. package/src/lib/session-selection.ts +40 -0
  58. package/src/lib/skill-installs.ts +3 -1
  59. package/src/lib/skills-catalog.ts +56 -17
  60. package/src/pages/projects/ProjectDetailPage.tsx +13 -12
  61. package/src/pages/settings/SettingsPage.tsx +212 -145
  62. package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
  63. package/src/pages/skills/SkillsPage.tsx +89 -45
  64. package/src/server/__tests__/app.test.ts +15 -2
  65. package/src/server/app.ts +13 -1
  66. package/src/server/routes/projects.routes.ts +14 -4
  67. package/src/server/routes/sessions.routes.ts +1 -1
  68. package/src/server/routes/skills.routes.ts +24 -5
  69. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  70. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  71. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  72. package/src/server/services/__tests__/session-manager.test.ts +40 -0
  73. package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
  74. package/src/server/services/__tests__/skills-registry.test.ts +6 -0
  75. package/src/server/services/claude-code-client.ts +1093 -0
  76. package/src/server/services/claude-code-translate.ts +370 -0
  77. package/src/server/services/claude-mcp.ts +122 -0
  78. package/src/server/services/claude-skills.ts +102 -0
  79. package/src/server/services/session-manager.ts +117 -52
  80. package/src/server/services/skill-install-jobs.ts +21 -8
  81. package/src/server/services/skills-registry.ts +18 -6
  82. package/src/types/message.ts +1 -1
  83. package/src/components/projects/PanelSection.tsx +0 -102
@@ -79,8 +79,11 @@ export const MAX_FREE_TEXT_LENGTH = 10000;
79
79
  export const TURN_IDLE_TIMEOUT_MS = 300_000;
80
80
  export const SHUTDOWN_BUDGET_MS = 5000;
81
81
 
82
- /** v1 headless: hanya opencode (claude-code punya mekanisme headless sendiri). */
83
- export const SUPPORTED_AGENT_TYPES: readonly AgentType[] = ["opencode"];
82
+ /**
83
+ * Tipe CLI_Agent yang dikenal. `claude-code` hanya aktif bila
84
+ * `claudeServers` diinjeksi (lihat `SessionManagerOptions`).
85
+ */
86
+ export const SUPPORTED_AGENT_TYPES: readonly AgentType[] = ["opencode", "claude-code"];
84
87
 
85
88
  export interface CreateSessionRequest {
86
89
  agentType: AgentType;
@@ -101,6 +104,11 @@ const SKILL_REFRESH_DELAY_MS = 1500;
101
104
  export interface SessionManagerOptions {
102
105
  store: SessionStore;
103
106
  servers: OpenCodeServerManager;
107
+ /**
108
+ * Backend Claude Code (Agent SDK) dengan kontrak yang sama dengan
109
+ * `servers`. Tidak diisi -> Session `claude-code` ditolak.
110
+ */
111
+ claudeServers?: OpenCodeServerManager;
104
112
  /**
105
113
  * Penyimpanan lampiran gambar (upload dari perangkat). Bila tidak diisi,
106
114
  * fitur gambar dinonaktifkan — pesan dengan `images` ditolak.
@@ -199,28 +207,28 @@ export interface SessionManager {
199
207
  */
200
208
  releaseProject(projectId: string): Promise<SimpleResult>;
201
209
  /** Daftar model yang tersedia pada server headless milik Project. */
202
- listModels(projectId: string): Promise<Result<ModelOption[]>>;
210
+ listModels(projectId: string, agentType?: AgentType): Promise<Result<ModelOption[]>>;
203
211
  /**
204
212
  * Daftar agent (mode) opencode milik Project — primary/all saja, siap
205
213
  * ditampilkan di pemilih mode composer.
206
214
  */
207
- listAgents(projectId: string): Promise<Result<AgentOption[]>>;
215
+ listAgents(projectId: string, agentType?: AgentType): Promise<Result<AgentOption[]>>;
208
216
  /** Status MCP server yang terkonfigurasi untuk Project. */
209
- listMcp(projectId: string): Promise<Result<McpServerInfo[]>>;
217
+ listMcp(projectId: string, agentType?: AgentType): Promise<Result<McpServerInfo[]>>;
210
218
  /** Skill yang terdaftar untuk Project (global, folder Project, bawaan). */
211
- listSkills(projectId: string): Promise<Result<SkillInfo[]>>;
219
+ listSkills(projectId: string, agentType?: AgentType): Promise<Result<SkillInfo[]>>;
212
220
  /**
213
221
  * Muat ulang daftar skill server headless Project setelah skill dipasang.
214
222
  * Bila ada turn aktif di Project, refresh ditunda (`refreshed: false`) agar
215
223
  * chat yang berjalan tidak terganggu — lalu dijalankan OTOMATIS begitu
216
224
  * seluruh turn di Project selesai (`onSkillsRefreshed`). Server yang belum
217
- * hidup tidak perlu di-refresh.
225
+ * hidup tidak perlu di-refresh. Claude Code selalu langsung `refreshed`.
218
226
  */
219
- refreshSkills(projectId: string): Promise<Result<{ refreshed: boolean }>>;
227
+ refreshSkills(projectId: string, agentType?: AgentType): Promise<Result<{ refreshed: boolean }>>;
220
228
  /** Project dengan refresh skill tertunda (menunggu turn selesai). */
221
229
  hasPendingSkillRefresh(projectId: string): boolean;
222
230
  /** Cari file project untuk autocomplete `@file` di composer. */
223
- findFiles(projectId: string, query: string): Promise<Result<string[]>>;
231
+ findFiles(projectId: string, query: string, agentType?: AgentType): Promise<Result<string[]>>;
224
232
  /**
225
233
  * Konteks kerja Session untuk panel kanan: perubahan file di worktree,
226
234
  * cabang git, dan task list (`todowrite`) agent.
@@ -259,6 +267,17 @@ export interface SessionManager {
259
267
  export function createSessionManager(opts: SessionManagerOptions): SessionManager {
260
268
  const store = opts.store;
261
269
  const servers = opts.servers;
270
+ const claudeServers = opts.claudeServers;
271
+
272
+ /** Server manager backend milik tipe CLI_Agent. */
273
+ function serversFor(agentType: AgentType): OpenCodeServerManager {
274
+ return agentType === "claude-code" && claudeServers ? claudeServers : servers;
275
+ }
276
+
277
+ /** Kunci subscription/exit per backend + Project (keduanya bisa hidup). */
278
+ function backendKey(agentType: AgentType, projectId: string): string {
279
+ return agentType === "claude-code" ? `claude-code:${projectId}` : projectId;
280
+ }
262
281
  const attachments = opts.attachments;
263
282
  const now = opts.now ?? Date.now;
264
283
  const shutdownBudgetMs = opts.shutdownBudgetMs ?? SHUTDOWN_BUDGET_MS;
@@ -309,11 +328,11 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
309
328
  const ocToSession = new Map<string, string>();
310
329
  /** Kebalikan `ocToSession`: id Session lokal -> seluruh ocSessionId miliknya. */
311
330
  const sessionOcIds = new Map<string, Set<string>>();
312
- /** Project yang event SSE-nya sudah disubscribe (subscription aktif). */
331
+ /** Backend+Project yang event-nya sudah disubscribe (lihat `backendKey`). */
313
332
  const activeSubscriptions = new Map<string, { active: boolean; unsubscribe: () => void }>();
314
333
  /** Serialisasi kirim pesan per Session agar turn tidak tumpang tindih. */
315
334
  const inflight = new Map<string, Promise<void>>();
316
- /** Project yang sudah diproses saat server-nya keluar (hindari duplikasi). */
335
+ /** Backend+Project yang sudah diproses saat server-nya keluar (hindari duplikasi). */
317
336
  const exitNotified = new Set<string>();
318
337
  const onMessagePart = opts.onMessagePart;
319
338
  const onMessagePartDelta = opts.onMessagePartDelta;
@@ -557,31 +576,34 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
557
576
  async function ensureServerFor(
558
577
  projectId: string,
559
578
  projectPath: string,
560
- opts: { refreshConfig?: boolean } = {},
579
+ opts: { refreshConfig?: boolean; agentType?: AgentType } = {},
561
580
  ): Promise<Result<{ client: OpenCodeClient }>> {
562
- const prevClient = servers.getServer(projectId)?.client;
581
+ const agentType = opts.agentType ?? "opencode";
582
+ const backend = serversFor(agentType);
583
+ const key = backendKey(agentType, projectId);
584
+ const prevClient = backend.getServer(projectId)?.client;
563
585
  const ensure = opts.refreshConfig
564
- ? servers.ensureFreshServer(projectId, projectPath)
565
- : servers.ensureServer(projectId, projectPath);
586
+ ? backend.ensureFreshServer(projectId, projectPath)
587
+ : backend.ensureServer(projectId, projectPath);
566
588
  const res = await ensure;
567
589
  if (!res.ok) return { ok: false, error: res.error };
568
590
  const handle = res.data;
569
591
  // Server headless dapat diganti (crash lalu ensure ulang, resume, atau
570
592
  // restart config). Bila client berbeda dari sebelumnya, subscription
571
593
  // lama tidak lagi menempel — buang dan subscribe ulang ke instance baru.
572
- const sub = activeSubscriptions.get(projectId);
594
+ const sub = activeSubscriptions.get(key);
573
595
  if (prevClient && handle.client !== prevClient) {
574
596
  sub?.unsubscribe();
575
- activeSubscriptions.delete(projectId);
597
+ activeSubscriptions.delete(key);
576
598
  }
577
- if (!activeSubscriptions.get(projectId)?.active) {
578
- const prev = activeSubscriptions.get(projectId);
599
+ if (!activeSubscriptions.get(key)?.active) {
600
+ const prev = activeSubscriptions.get(key);
579
601
  prev?.unsubscribe();
580
602
  const unsubscribe = handle.client.subscribeEvents((ev) => handleEvent(projectId, ev));
581
- activeSubscriptions.set(projectId, { active: true, unsubscribe });
603
+ activeSubscriptions.set(key, { active: true, unsubscribe });
582
604
  }
583
605
  // Server baru (atau hasil restart) — izinkan event exit berikutnya diproses.
584
- exitNotified.delete(projectId);
606
+ exitNotified.delete(key);
585
607
  return { ok: true, data: { client: handle.client } };
586
608
  }
587
609
 
@@ -595,7 +617,10 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
595
617
 
596
618
  async function createSession(req: CreateSessionRequest): Promise<CreateSessionResult> {
597
619
  // (1) tipe CLI_Agent didukung (Requirement 1.3)
598
- if (!SUPPORTED_AGENT_TYPES.includes(req.agentType)) {
620
+ if (
621
+ !SUPPORTED_AGENT_TYPES.includes(req.agentType) ||
622
+ (req.agentType === "claude-code" && !claudeServers)
623
+ ) {
599
624
  return { ok: false, error: "UNSUPPORTED_AGENT_TYPE" };
600
625
  }
601
626
  // (2) Project ada di Session_Store (Requirement 10.10)
@@ -612,7 +637,9 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
612
637
  if (!dirExists) return { ok: false, error: "PROJECT_DIR_NOT_FOUND" };
613
638
 
614
639
  // (4) pastikan server headless untuk Project (spawn bila perlu)
615
- const serverRes = await ensureServerFor(project.id, project.path);
640
+ const serverRes = await ensureServerFor(project.id, project.path, {
641
+ agentType: req.agentType,
642
+ });
616
643
  if (!serverRes.ok) return { ok: false, error: serverRes.error };
617
644
  const client = serverRes.data.client;
618
645
 
@@ -680,7 +707,7 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
680
707
  if (cur.data.status !== "running") return { ok: false, error: "SESSION_NOT_RUNNING" };
681
708
  // Abort turn berjalan (best-effort, tidak menunggu).
682
709
  if (cur.data.ocSessionId) {
683
- const handle = servers.getServer(cur.data.projectId);
710
+ const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
684
711
  void handle?.client.abortSession(cur.data.ocSessionId);
685
712
  }
686
713
  // Simpan parts yang sudah terkumpul sebelum Session ditutup.
@@ -705,7 +732,7 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
705
732
  if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
706
733
  // Abort turn berjalan (best-effort, tidak menunggu).
707
734
  if (cur.data.status === "running" && cur.data.ocSessionId) {
708
- const handle = servers.getServer(cur.data.projectId);
735
+ const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
709
736
  void handle?.client.abortSession(cur.data.ocSessionId);
710
737
  }
711
738
  // Buang turn parsial (tidak disimpan) + broadcast turn tidak aktif.
@@ -734,7 +761,9 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
734
761
  if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
735
762
 
736
763
  // (1) Pastikan server headless Project hidup (spawn ulang bila perlu).
737
- const serverRes = await ensureServerFor(project.data.id, project.data.path);
764
+ const serverRes = await ensureServerFor(project.data.id, project.data.path, {
765
+ agentType: cur.data.agentType,
766
+ });
738
767
  if (!serverRes.ok) return { ok: false, error: serverRes.error };
739
768
 
740
769
  // (2) Verifikasi ocSessionId lama; bila tidak dikenal -> sesi remote baru.
@@ -781,13 +810,17 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
781
810
  if (!res.ok) return res;
782
811
  }
783
812
  await servers.stopServer(projectId);
813
+ await claudeServers?.stopServer(projectId);
784
814
  // Subscription SSE ikut dilepas — server-nya sudah tidak ada.
785
- const sub = activeSubscriptions.get(projectId);
786
- if (sub) {
787
- sub.unsubscribe();
788
- activeSubscriptions.delete(projectId);
815
+ for (const agentType of SUPPORTED_AGENT_TYPES) {
816
+ const key = backendKey(agentType, projectId);
817
+ const sub = activeSubscriptions.get(key);
818
+ if (sub) {
819
+ sub.unsubscribe();
820
+ activeSubscriptions.delete(key);
821
+ }
822
+ exitNotified.delete(key);
789
823
  }
790
- exitNotified.delete(projectId);
791
824
  return { ok: true };
792
825
  }
793
826
 
@@ -799,12 +832,16 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
799
832
  * (opencode serve tidak hot-reload config). Restart ditahan bila ada
800
833
  * Session `running` agar turn yang sedang berjalan tidak terputus.
801
834
  */
802
- async function listModels(projectId: string): Promise<Result<ModelOption[]>> {
835
+ async function listModels(
836
+ projectId: string,
837
+ agentType: AgentType = "opencode",
838
+ ): Promise<Result<ModelOption[]>> {
803
839
  const project = store.getProjectById(projectId);
804
840
  if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
805
841
  const hasRunning = store.listProjectSessions(projectId).some((s) => s.status === "running");
806
842
  const serverRes = await ensureServerFor(project.data.id, project.data.path, {
807
843
  refreshConfig: !hasRunning,
844
+ agentType,
808
845
  });
809
846
  if (!serverRes.ok) return { ok: false, error: serverRes.error };
810
847
  return serverRes.data.client.listModels();
@@ -817,12 +854,16 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
817
854
  * memicu restart server agar daftar ikut terbaru (dengan guard yang sama
818
855
  * seperti `listModels`).
819
856
  */
820
- async function listAgents(projectId: string): Promise<Result<AgentOption[]>> {
857
+ async function listAgents(
858
+ projectId: string,
859
+ agentType: AgentType = "opencode",
860
+ ): Promise<Result<AgentOption[]>> {
821
861
  const project = store.getProjectById(projectId);
822
862
  if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
823
863
  const hasRunning = store.listProjectSessions(projectId).some((s) => s.status === "running");
824
864
  const serverRes = await ensureServerFor(project.data.id, project.data.path, {
825
865
  refreshConfig: !hasRunning,
866
+ agentType,
826
867
  });
827
868
  if (!serverRes.ok) return { ok: false, error: serverRes.error };
828
869
  return serverRes.data.client.listAgents();
@@ -833,12 +874,16 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
833
874
  * daftar MCP dibaca dari config opencode di direktori Project. Config yang
834
875
  * berubah memicu restart (guard sama dengan `listModels`).
835
876
  */
836
- async function listMcp(projectId: string): Promise<Result<McpServerInfo[]>> {
877
+ async function listMcp(
878
+ projectId: string,
879
+ agentType: AgentType = "opencode",
880
+ ): Promise<Result<McpServerInfo[]>> {
837
881
  const project = store.getProjectById(projectId);
838
882
  if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
839
883
  const hasRunning = store.listProjectSessions(projectId).some((s) => s.status === "running");
840
884
  const serverRes = await ensureServerFor(project.data.id, project.data.path, {
841
885
  refreshConfig: !hasRunning,
886
+ agentType,
842
887
  });
843
888
  if (!serverRes.ok) return { ok: false, error: serverRes.error };
844
889
  return serverRes.data.client.listMcp();
@@ -848,10 +893,13 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
848
893
  * Skill yang terdaftar untuk Project. Skill di dalam folder Project
849
894
  * ditandai `project` sehingga UI dapat membedakannya dari skill global.
850
895
  */
851
- async function listSkills(projectId: string): Promise<Result<SkillInfo[]>> {
896
+ async function listSkills(
897
+ projectId: string,
898
+ agentType: AgentType = "opencode",
899
+ ): Promise<Result<SkillInfo[]>> {
852
900
  const project = store.getProjectById(projectId);
853
901
  if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
854
- const serverRes = await ensureServerFor(project.data.id, project.data.path);
902
+ const serverRes = await ensureServerFor(project.data.id, project.data.path, { agentType });
855
903
  if (!serverRes.ok) return { ok: false, error: serverRes.error };
856
904
  return serverRes.data.client.listSkills(project.data.path);
857
905
  }
@@ -900,9 +948,15 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
900
948
  onSkillsRefreshed?.(projectId);
901
949
  }
902
950
 
903
- async function refreshSkills(projectId: string): Promise<Result<{ refreshed: boolean }>> {
951
+ async function refreshSkills(
952
+ projectId: string,
953
+ agentType: AgentType = "opencode",
954
+ ): Promise<Result<{ refreshed: boolean }>> {
904
955
  const project = store.getProjectById(projectId);
905
956
  if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
957
+ // Claude Code: daftar skill dibaca langsung dari disk dan proses `claude`
958
+ // memuat skill sendiri — tidak ada cache server yang perlu dibuang.
959
+ if (agentType === "claude-code") return { ok: true, data: { refreshed: true } };
906
960
  const handle = servers.getServer(projectId);
907
961
  // Server belum hidup -> skill terbaca saat server di-spawn nanti.
908
962
  if (!handle) {
@@ -945,11 +999,13 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
945
999
  const cur = store.getSession(sessionId);
946
1000
  if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
947
1001
  const cwd = cur.data.cwd;
948
- const handle = servers.getServer(cur.data.projectId);
1002
+ const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
1003
+ // Task list disimpan per session REMOTE (opencode `ses_...` / UUID Claude).
1004
+ const remoteId = cur.data.ocSessionId ?? sessionId;
949
1005
 
950
1006
  const [git, todos, changes, mcp] = await Promise.all([
951
1007
  readGitInfo(cwd).catch(() => EMPTY_GIT_INFO),
952
- handle ? handle.client.listTodos(sessionId) : Promise.resolve(null),
1008
+ handle ? handle.client.listTodos(remoteId) : Promise.resolve(null),
953
1009
  handle ? handle.client.fileStatus() : Promise.resolve(null),
954
1010
  handle ? handle.client.listMcp() : Promise.resolve(null),
955
1011
  ]);
@@ -967,10 +1023,14 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
967
1023
  };
968
1024
  }
969
1025
 
970
- async function findFiles(projectId: string, query: string): Promise<Result<string[]>> {
1026
+ async function findFiles(
1027
+ projectId: string,
1028
+ query: string,
1029
+ agentType: AgentType = "opencode",
1030
+ ): Promise<Result<string[]>> {
971
1031
  const project = store.getProjectById(projectId);
972
1032
  if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
973
- const serverRes = await ensureServerFor(project.data.id, project.data.path);
1033
+ const serverRes = await ensureServerFor(project.data.id, project.data.path, { agentType });
974
1034
  if (!serverRes.ok) return { ok: false, error: serverRes.error };
975
1035
  return serverRes.data.client.findFiles(query);
976
1036
  }
@@ -1017,7 +1077,7 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
1017
1077
  turns.finish(sessionId);
1018
1078
  // Bila masih running, abort turn remote agar model berhenti dieksekusi.
1019
1079
  if (cur.data.status === "running" && cur.data.ocSessionId) {
1020
- const runningHandle = servers.getServer(cur.data.projectId);
1080
+ const runningHandle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
1021
1081
  void runningHandle?.client.abortSession(cur.data.ocSessionId);
1022
1082
  }
1023
1083
  unmapOcSessions(sessionId);
@@ -1028,11 +1088,13 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
1028
1088
  // dan hanya akan dihentikan saat shutdown.
1029
1089
  const ocSessionId = cur.data.ocSessionId;
1030
1090
  if (ocSessionId) {
1031
- let client = servers.getServer(cur.data.projectId)?.client;
1091
+ let client = serversFor(cur.data.agentType).getServer(cur.data.projectId)?.client;
1032
1092
  if (!client) {
1033
1093
  const project = store.getProjectById(cur.data.projectId);
1034
1094
  if (!project.ok) return { ok: false, error: project.error };
1035
- const serverRes = await ensureServerFor(project.data.id, project.data.path);
1095
+ const serverRes = await ensureServerFor(project.data.id, project.data.path, {
1096
+ agentType: cur.data.agentType,
1097
+ });
1036
1098
  if (!serverRes.ok) return { ok: false, error: serverRes.error };
1037
1099
  client = serverRes.data.client;
1038
1100
  }
@@ -1073,7 +1135,7 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
1073
1135
  if (!ocSessionId) return { ok: false, error: "SESSION_NOT_ACTIVE" };
1074
1136
 
1075
1137
  // Pastikan server masih hidup SEBELUM menyimpan echo user.
1076
- const handle = servers.getServer(cur.data.projectId);
1138
+ const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
1077
1139
  if (!handle) return { ok: false, error: "SESSION_NOT_ACTIVE" };
1078
1140
 
1079
1141
  // Gambar upload dipetakan ke info + path absolut. Kegagalan resolve
@@ -1202,7 +1264,7 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
1202
1264
 
1203
1265
  const cur = store.getSession(sessionId);
1204
1266
  if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
1205
- const handle = servers.getServer(cur.data.projectId);
1267
+ const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
1206
1268
  if (!handle) return { ok: false, error: "SESSION_NOT_ACTIVE" };
1207
1269
 
1208
1270
  // Kumpulkan request pending yang identik (grup kartu yang sama di UI):
@@ -1316,7 +1378,7 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
1316
1378
  const running = store.listSessions().filter((s) => s.status === "running");
1317
1379
  const results = running.map((s) => store.insertStatusHistory(s.id, "running", now()));
1318
1380
  await raceWithTimeout(Promise.resolve(results), shutdownBudgetMs);
1319
- await servers.stopAll();
1381
+ await Promise.all([servers.stopAll(), claudeServers?.stopAll()]);
1320
1382
  }
1321
1383
 
1322
1384
  /** Balapan dengan timer budget; timer selalu dibersihkan setelah selesai. */
@@ -1333,19 +1395,22 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
1333
1395
  }
1334
1396
 
1335
1397
  // Saat server keluar tak terduga, tandai Session project tsb `crashed`.
1336
- servers.onServerExit((projectId) => {
1337
- if (exitNotified.has(projectId)) return;
1338
- exitNotified.add(projectId);
1398
+ const onBackendExit = (agentType: AgentType) => (projectId: string) => {
1399
+ const key = backendKey(agentType, projectId);
1400
+ if (exitNotified.has(key)) return;
1401
+ exitNotified.add(key);
1339
1402
  const crashedAt = now();
1340
1403
  for (const s of store.listSessions()) {
1341
- if (s.projectId === projectId && s.status === "running") {
1404
+ if (s.projectId === projectId && s.agentType === agentType && s.status === "running") {
1342
1405
  // Server mati di tengah turn: selamatkan parts yang sudah ter-stream.
1343
1406
  turns.finish(s.id);
1344
1407
  updateStatus(s.id, "crashed", crashedAt);
1345
1408
  unmapOcSessions(s.id);
1346
1409
  }
1347
1410
  }
1348
- });
1411
+ };
1412
+ servers.onServerExit(onBackendExit("opencode"));
1413
+ claudeServers?.onServerExit(onBackendExit("claude-code"));
1349
1414
 
1350
1415
  return {
1351
1416
  createSession,
@@ -12,6 +12,7 @@
12
12
  * setelah `FINISHED_TTL_MS` agar memori tidak tumbuh terus.
13
13
  */
14
14
  import { randomUUID } from "node:crypto";
15
+ import type { AgentType } from "../../types";
15
16
  import type { Result } from "../result";
16
17
  import type { InstallResult, SkillsRegistry } from "./skills-registry";
17
18
 
@@ -28,6 +29,8 @@ export interface InstallJob {
28
29
  projectName: string;
29
30
  source: string;
30
31
  skillId: string;
32
+ /** Agent tujuan instalasi (`.agents/skills` dan/atau `.claude/skills`). */
33
+ agentTypes: AgentType[];
31
34
  status: InstallJobStatus;
32
35
  /** Kode error domain bila `failed`. */
33
36
  error: string | null;
@@ -58,6 +61,8 @@ export interface StartInstallInput {
58
61
  projectPath: string;
59
62
  source: string;
60
63
  skillId: string;
64
+ /** Default `["opencode"]`. */
65
+ agentTypes?: readonly AgentType[];
61
66
  }
62
67
 
63
68
  export interface SkillInstallJobs {
@@ -84,8 +89,11 @@ export interface SkillInstallJobs {
84
89
 
85
90
  export interface SkillInstallJobsOptions {
86
91
  registry: SkillsRegistry;
87
- /** Refresh skill server opencode Project setelah terpasang. */
88
- refreshSkills: (projectId: string) => Promise<Result<{ refreshed: boolean }>>;
92
+ /** Refresh daftar skill agent Project setelah terpasang. */
93
+ refreshSkills: (
94
+ projectId: string,
95
+ agentType: AgentType,
96
+ ) => Promise<Result<{ refreshed: boolean }>>;
89
97
  now?: () => number;
90
98
  /** Validasi source/skill sebelum job dibuat. */
91
99
  validate: (source: string, skillId: string) => boolean;
@@ -138,6 +146,7 @@ export function createSkillInstallJobs(opts: SkillInstallJobsOptions): SkillInst
138
146
  job.skillId,
139
147
  (l) => push(st, l),
140
148
  signal,
149
+ job.agentTypes,
141
150
  );
142
151
  } catch (e) {
143
152
  res = { ok: false, error: "SKILL_INSTALL_FAILED" };
@@ -154,12 +163,15 @@ export function createSkillInstallJobs(opts: SkillInstallJobsOptions): SkillInst
154
163
  return;
155
164
  }
156
165
  push(st, "Reloading the agent's skill list…");
157
- let refreshed = false;
158
- try {
159
- const r = await opts.refreshSkills(job.projectId);
160
- refreshed = r.ok ? r.data.refreshed : false;
161
- } catch {
162
- refreshed = false;
166
+ // Refresh tiap agent tujuan; "aktif" hanya bila semuanya ter-refresh.
167
+ let refreshed = true;
168
+ for (const agentType of job.agentTypes) {
169
+ try {
170
+ const r = await opts.refreshSkills(job.projectId, agentType);
171
+ if (!r.ok || !r.data.refreshed) refreshed = false;
172
+ } catch {
173
+ refreshed = false;
174
+ }
163
175
  }
164
176
  push(
165
177
  st,
@@ -186,6 +198,7 @@ export function createSkillInstallJobs(opts: SkillInstallJobsOptions): SkillInst
186
198
  projectName: input.projectName,
187
199
  source: input.source,
188
200
  skillId: input.skillId,
201
+ agentTypes: [...(input.agentTypes ?? ["opencode"])],
189
202
  status: "running",
190
203
  error: null,
191
204
  refreshed: null,
@@ -7,13 +7,16 @@
7
7
  * - Audit keamanan: `GET https://skills.sh/api/v1/skills/audit/{id}` (saat
8
8
  * ini dapat diakses tanpa token; 404 = belum pernah diaudit).
9
9
  * - Instalasi: `bunx skills@<ver> add <source> --skill <name> --agent
10
- * opencode -y --json` di direktori Project. CLI memasang ke
11
- * `.agents/skills/<name>/` yang langsung dibaca opencode; server headless
12
- * Project perlu `POST /instance/dispose` agar daftar skill ter-refresh.
10
+ * <opencode> <claude-code> -y --json` di direktori Project (satu atau
11
+ * kedua agent sekaligus). CLI memasang ke
12
+ * `.agents/skills/<name>/` (dibaca opencode; server headless Project perlu
13
+ * `POST /instance/dispose` agar daftar skill ter-refresh); untuk Claude
14
+ * Code ditambah symlink `.claude/skills/<name>/`.
13
15
  *
14
16
  * Argumen CLI dibangun sebagai array (tanpa shell) dan `source`/`skill`
15
17
  * divalidasi ketat sebelum dipakai, sehingga tidak ada celah injeksi.
16
18
  */
19
+ import type { AgentType } from "../../types";
17
20
  import type { Result } from "../result";
18
21
 
19
22
  /** Versi CLI `skills` yang dipin (hindari perubahan perilaku mendadak). */
@@ -154,7 +157,11 @@ export function parseAuditResponse(payload: unknown): SkillAudit {
154
157
  }
155
158
 
156
159
  /** Argumen `bunx` untuk instalasi (array — tanpa shell). */
157
- export function buildInstallArgs(source: string, skillId: string): string[] {
160
+ export function buildInstallArgs(
161
+ source: string,
162
+ skillId: string,
163
+ agentTypes: readonly AgentType[] = ["opencode"],
164
+ ): string[] {
158
165
  return [
159
166
  "bunx",
160
167
  `skills@${SKILLS_CLI_VERSION}`,
@@ -162,8 +169,10 @@ export function buildInstallArgs(source: string, skillId: string): string[] {
162
169
  source,
163
170
  "--skill",
164
171
  skillId,
172
+ // Id agent CLI `skills` kebetulan sama dengan `AgentType`; beberapa
173
+ // nilai setelah satu `--agent` diterima CLI.
165
174
  "--agent",
166
- "opencode",
175
+ ...agentTypes,
167
176
  "-y",
168
177
  "--json",
169
178
  ];
@@ -235,6 +244,8 @@ export interface SkillsRegistry {
235
244
  skillId: string,
236
245
  onLine?: (line: string) => void,
237
246
  signal?: AbortSignal,
247
+ /** Agent tujuan (`--agent`); default `["opencode"]`. */
248
+ agentTypes?: readonly AgentType[],
238
249
  ): Promise<Result<InstallResult>>;
239
250
  }
240
251
 
@@ -418,6 +429,7 @@ export function createSkillsRegistry(opts: SkillsRegistryOptions = {}): SkillsRe
418
429
  skillId: string,
419
430
  onLine?: (line: string) => void,
420
431
  signal?: AbortSignal,
432
+ agentTypes: readonly AgentType[] = ["opencode"],
421
433
  ): Promise<Result<InstallResult>> {
422
434
  if (!isValidSource(source) || !isValidSkillId(skillId)) {
423
435
  return { ok: false, error: "INVALID_SKILL_ID" };
@@ -435,7 +447,7 @@ export function createSkillsRegistry(opts: SkillsRegistryOptions = {}): SkillsRe
435
447
  onLine(line);
436
448
  };
437
449
  try {
438
- const args = buildInstallArgs(source, skillId);
450
+ const args = buildInstallArgs(source, skillId, agentTypes);
439
451
  emit(`$ ${args.join(" ")}`);
440
452
  const res = await spawn(args, projectPath, INSTALL_TIMEOUT_MS, emit, signal);
441
453
  if (signal?.aborted) {
@@ -34,7 +34,7 @@ export interface MessagePart {
34
34
  * `input` (argumen tool, mis. `filePath` untuk read/edit), dan `output`
35
35
  * (hasil). Dipakai renderer untuk menampilkan target tiap tool call.
36
36
  */
37
- state?: { input?: Record<string, unknown>; output?: unknown };
37
+ state?: { input?: Record<string, unknown>; output?: unknown; [k: string]: unknown };
38
38
  /**
39
39
  * Timing asli dari opencode (ReasoningPart, TextPart, ToolState).
40
40
  * `start`/`end` adalah Unix ms. Hadir di part yang sudah selesai di-stream;
@@ -1,102 +0,0 @@
1
- /**
2
- * Satu seksi di panel samping halaman Project (Instructions / MCP / Skills).
3
- *
4
- * Tiap seksi = satu kartu (gaya boxed list seperti Settings): header di dalam
5
- * kartu (ikon, judul, ringkasan, aksi, chevron) dan isi di bawah garis
6
- * pemisah. Status buka/tutup diingat per seksi di `localStorage`.
7
- *
8
- * Isi kartu tidak diberi padding otomatis: `PanelList` (baris berdivider),
9
- * `PanelHint`, dan `PanelBody` mengatur jaraknya sendiri agar baris daftar
10
- * bisa rata tepi ke tepi.
11
- */
12
- import { ChevronDownIcon } from "lucide-react";
13
- import type { ReactNode } from "react";
14
- import { useEffect, useState } from "react";
15
- import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
16
- import { cn } from "@/lib/utils";
17
-
18
- const STORAGE_PREFIX = "kcg.projectPanel.";
19
-
20
- /** Buka/tutup seksi yang diingat di localStorage (default terbuka). */
21
- function usePersistedOpen(id: string): [boolean, (open: boolean) => void] {
22
- const [open, setOpen] = useState(() => {
23
- try {
24
- return localStorage.getItem(STORAGE_PREFIX + id) !== "0";
25
- } catch {
26
- return true;
27
- }
28
- });
29
- useEffect(() => {
30
- try {
31
- localStorage.setItem(STORAGE_PREFIX + id, open ? "1" : "0");
32
- } catch {
33
- // Storage tidak tersedia (mode privat) — cukup state di memori.
34
- }
35
- }, [id, open]);
36
- return [open, setOpen];
37
- }
38
-
39
- export interface PanelSectionProps {
40
- /** Id stabil untuk mengingat status lipat (mis. "mcp"). */
41
- id: string;
42
- title: string;
43
- /** Ikon seksi di kiri judul. */
44
- icon: ReactNode;
45
- /** Hitungan / ringkasan kecil di bawah judul. */
46
- meta?: ReactNode;
47
- /** Tombol aksi kecil di kanan header (tidak ikut melipat). */
48
- action?: ReactNode;
49
- children: ReactNode;
50
- }
51
-
52
- export function PanelSection({ id, title, icon, meta, action, children }: PanelSectionProps) {
53
- const [open, setOpen] = usePersistedOpen(id);
54
- return (
55
- <Collapsible open={open} onOpenChange={setOpen} asChild>
56
- <section
57
- aria-label={title}
58
- className="overflow-hidden rounded-xl border bg-card text-card-foreground shadow-xs"
59
- >
60
- <div className="flex items-center gap-1 pr-2">
61
- <CollapsibleTrigger className="group flex min-h-14 min-w-0 flex-1 items-center gap-3 py-2.5 pl-3.5 text-left outline-none transition-colors hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:ring-inset">
62
- <span
63
- className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground [&_svg]:size-4"
64
- aria-hidden
65
- >
66
- {icon}
67
- </span>
68
- <span className="flex min-w-0 flex-1 flex-col">
69
- <span className="text-sm font-semibold leading-5 tracking-tight">{title}</span>
70
- {meta !== undefined && (
71
- <span className="truncate text-xs tabular-nums text-muted-foreground">{meta}</span>
72
- )}
73
- </span>
74
- <ChevronDownIcon
75
- className="mr-1.5 size-4 shrink-0 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180"
76
- aria-hidden
77
- />
78
- </CollapsibleTrigger>
79
- {action && <div className="flex shrink-0 items-center">{action}</div>}
80
- </div>
81
- <CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
82
- <div className="border-t">{children}</div>
83
- </CollapsibleContent>
84
- </section>
85
- </Collapsible>
86
- );
87
- }
88
-
89
- /** Isi kartu dengan padding standar (form, pencarian, teks). */
90
- export function PanelBody({ className, children }: { className?: string; children: ReactNode }) {
91
- return <div className={cn("px-3.5 py-3", className)}>{children}</div>;
92
- }
93
-
94
- /** Baris-baris berdivider, rata tepi ke tepi di dalam kartu. */
95
- export function PanelList({ className, children }: { className?: string; children: ReactNode }) {
96
- return <ul className={cn("flex flex-col divide-y", className)}>{children}</ul>;
97
- }
98
-
99
- /** Keterangan saat daftar kosong — satu gaya untuk semua seksi. */
100
- export function PanelHint({ children }: { children: ReactNode }) {
101
- return <p className="px-3.5 py-3.5 text-xs leading-relaxed text-muted-foreground">{children}</p>;
102
- }