kcg-code 0.1.0 → 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.
- package/README.md +2 -0
- package/package.json +2 -1
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +170 -8
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +69 -6
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +174 -21
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +876 -159
- package/src/cli/index.ts +10 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +6 -6
- package/src/components/layout/AppShell.tsx +8 -3
- package/src/components/projects/HomeChat.tsx +85 -48
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/ProjectInstructions.tsx +27 -38
- package/src/components/projects/ProjectSidePanel.tsx +382 -316
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentPicker.tsx +13 -4
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
- package/src/components/sessions/ContextPanel.tsx +159 -59
- package/src/components/sessions/ModelPicker.tsx +65 -27
- package/src/components/sessions/ModelSearchList.tsx +137 -57
- package/src/components/sessions/NewSessionDialog.tsx +100 -117
- package/src/components/sessions/SessionComposer.tsx +2 -1
- package/src/components/sessions/SessionHeader.tsx +1 -0
- package/src/components/sessions/SessionList.tsx +173 -66
- package/src/components/sessions/SessionRow.tsx +176 -76
- package/src/components/sessions/SessionView.tsx +17 -13
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
- package/src/components/settings/LhrRemoteAccess.tsx +20 -25
- package/src/components/settings/RemoteAccessGroup.tsx +242 -267
- package/src/components/settings/prefs.tsx +69 -1
- package/src/components/skills/InstallNotifications.tsx +4 -3
- package/src/components/skills/InstallSkillDialog.tsx +256 -99
- package/src/components/skills/SkillCards.tsx +52 -8
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/config.ts +32 -3
- package/src/hooks/useAgentPicker.ts +6 -2
- package/src/hooks/useSessionList.ts +95 -2
- package/src/lib/__tests__/project-extensions.test.ts +32 -1
- package/src/lib/__tests__/project-overview.test.ts +15 -0
- package/src/lib/__tests__/routes.test.ts +2 -2
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/skill-installs.test.ts +1 -0
- package/src/lib/__tests__/skills-catalog.test.ts +36 -8
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/project-extensions.ts +66 -4
- package/src/lib/project-overview.ts +14 -8
- package/src/lib/routes.ts +13 -4
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/skill-installs.ts +3 -1
- package/src/lib/skills-catalog.ts +56 -17
- package/src/pages/projects/ProjectDetailPage.tsx +13 -12
- package/src/pages/settings/SettingsPage.tsx +212 -145
- package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
- package/src/pages/skills/SkillsPage.tsx +89 -45
- package/src/server/__tests__/app.test.ts +15 -2
- package/src/server/app.ts +13 -1
- package/src/server/routes/projects.routes.ts +14 -4
- package/src/server/routes/sessions.routes.ts +1 -1
- package/src/server/routes/skills.routes.ts +24 -5
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/session-manager.test.ts +40 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
- package/src/server/services/__tests__/skills-registry.test.ts +6 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/session-manager.ts +117 -52
- package/src/server/services/skill-install-jobs.ts +21 -8
- package/src/server/services/skills-registry.ts +18 -6
- package/src/types/message.ts +1 -1
- package/src/components/projects/PanelSection.tsx +0 -102
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Penerjemah pesan Claude Agent SDK -> event bergaya opencode.
|
|
3
|
+
*
|
|
4
|
+
* Session_Manager & TurnStream dibangun di atas event SSE opencode
|
|
5
|
+
* (`message.updated`, `message.part.updated`, `message.part.delta`,
|
|
6
|
+
* `session.idle`, `session.error`, `session.status`). Agar backend Claude
|
|
7
|
+
* Code bisa memakai jalur yang sama tanpa cabang khusus, setiap `SDKMessage`
|
|
8
|
+
* diterjemahkan ke event tersebut di sini — fungsi/kelas murni tanpa I/O.
|
|
9
|
+
*
|
|
10
|
+
* Pemetaan (terverifikasi terhadap stream SDK 0.3.x):
|
|
11
|
+
* - Satu turn = SATU pesan assistant lokal (`msg_<uuid>`); seluruh pesan API
|
|
12
|
+
* Anthropic dalam turn (termasuk sub-agent) menjadi parts pesan itu.
|
|
13
|
+
* - `stream_event` text/thinking -> part `text`/`reasoning` + delta token.
|
|
14
|
+
* Pesan `assistant` membawa blok yang SAMA secara utuh: blok teks dari
|
|
15
|
+
* pesan API yang sudah di-stream dilewati agar tidak terduplikasi.
|
|
16
|
+
* - `tool_use` (dari pesan `assistant`, input lengkap) -> part `tool`
|
|
17
|
+
* status running; `tool_result` (pesan `user`) -> status completed/error.
|
|
18
|
+
* - Nama tool & key input dinormalisasi ke gaya opencode (`Read` -> `read`,
|
|
19
|
+
* `file_path` -> `filePath`) agar renderer, Reference, dan label izin
|
|
20
|
+
* yang sudah ada langsung bekerja.
|
|
21
|
+
* - `system/api_retry` -> `session.status` retry (banner retry provider).
|
|
22
|
+
*/
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
24
|
+
import type { MessagePart } from "../../types";
|
|
25
|
+
import type { OpenCodeEvent, OpenCodeTodo } from "./opencode-client";
|
|
26
|
+
|
|
27
|
+
/** Batas panjang output tool yang disimpan (hindari blob raksasa di DB). */
|
|
28
|
+
export const TOOL_OUTPUT_MAX_LENGTH = 50_000;
|
|
29
|
+
|
|
30
|
+
/** Nama tool Claude Code -> nama tool gaya opencode (dipakai UI). */
|
|
31
|
+
const TOOL_NAMES: Record<string, string> = {
|
|
32
|
+
Read: "read",
|
|
33
|
+
Write: "write",
|
|
34
|
+
Edit: "edit",
|
|
35
|
+
MultiEdit: "edit",
|
|
36
|
+
NotebookEdit: "edit",
|
|
37
|
+
Bash: "bash",
|
|
38
|
+
BashOutput: "bash",
|
|
39
|
+
Glob: "glob",
|
|
40
|
+
Grep: "grep",
|
|
41
|
+
LS: "list",
|
|
42
|
+
WebFetch: "webfetch",
|
|
43
|
+
WebSearch: "websearch",
|
|
44
|
+
TodoWrite: "todowrite",
|
|
45
|
+
Task: "task",
|
|
46
|
+
Agent: "task",
|
|
47
|
+
Skill: "skill",
|
|
48
|
+
AskUserQuestion: "question",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Nama tool untuk part `tool` (tool MCP `mcp__server__tool` apa adanya). */
|
|
52
|
+
export function toolName(name: string): string {
|
|
53
|
+
return TOOL_NAMES[name] ?? (name.startsWith("mcp__") ? name : name.toLowerCase());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Nama permission gaya opencode untuk kartu izin — `parsePermissionTitle`
|
|
58
|
+
* di UI memetakan nama ini ke label & tingkat risiko. Tool penulis file
|
|
59
|
+
* dikelompokkan ke `edit` seperti di opencode.
|
|
60
|
+
*/
|
|
61
|
+
export function permissionName(name: string): string {
|
|
62
|
+
const mapped = toolName(name);
|
|
63
|
+
return mapped === "write" ? "edit" : mapped;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Salinan input tool dengan alias key gaya opencode (`filePath`). */
|
|
67
|
+
export function normalizeToolInput(input: unknown): Record<string, unknown> {
|
|
68
|
+
if (typeof input !== "object" || input === null) return {};
|
|
69
|
+
const out: Record<string, unknown> = { ...(input as Record<string, unknown>) };
|
|
70
|
+
const path = out.file_path ?? out.notebook_path;
|
|
71
|
+
if (typeof path === "string" && out.filePath === undefined) out.filePath = path;
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Target utama tool call untuk judul kartu izin (path/perintah/url). */
|
|
76
|
+
export function permissionTarget(input: Record<string, unknown>): string | null {
|
|
77
|
+
for (const key of ["file_path", "notebook_path", "command", "url", "pattern", "path", "query"]) {
|
|
78
|
+
const v = input[key];
|
|
79
|
+
if (typeof v === "string" && v.trim() !== "") return v;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Task list dari input `TodoWrite` (`{ todos: [{ content, status }] }`). */
|
|
85
|
+
export function todosFromInput(input: unknown): OpenCodeTodo[] | null {
|
|
86
|
+
if (typeof input !== "object" || input === null) return null;
|
|
87
|
+
const raw = (input as { todos?: unknown }).todos;
|
|
88
|
+
if (!Array.isArray(raw)) return null;
|
|
89
|
+
const out: OpenCodeTodo[] = [];
|
|
90
|
+
for (const t of raw) {
|
|
91
|
+
if (typeof t !== "object" || t === null) continue;
|
|
92
|
+
const { content, status, priority } = t as Record<string, unknown>;
|
|
93
|
+
if (typeof content !== "string") continue;
|
|
94
|
+
out.push({
|
|
95
|
+
content,
|
|
96
|
+
status: typeof status === "string" ? status : "pending",
|
|
97
|
+
// TodoWrite Claude Code tidak punya prioritas — UI tetap butuh nilai.
|
|
98
|
+
priority: typeof priority === "string" ? priority : "medium",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Teks dari konten `tool_result` (string atau array blok). */
|
|
105
|
+
export function toolResultText(content: unknown): string {
|
|
106
|
+
let text: string;
|
|
107
|
+
if (typeof content === "string") {
|
|
108
|
+
text = content;
|
|
109
|
+
} else if (Array.isArray(content)) {
|
|
110
|
+
text = content
|
|
111
|
+
.map((b) => {
|
|
112
|
+
if (typeof b !== "object" || b === null) return "";
|
|
113
|
+
const block = b as { type?: unknown; text?: unknown };
|
|
114
|
+
if (block.type === "text" && typeof block.text === "string") return block.text;
|
|
115
|
+
if (block.type === "image") return "[image]";
|
|
116
|
+
return "";
|
|
117
|
+
})
|
|
118
|
+
.filter((s) => s !== "")
|
|
119
|
+
.join("\n");
|
|
120
|
+
} else {
|
|
121
|
+
text = "";
|
|
122
|
+
}
|
|
123
|
+
return text.length > TOOL_OUTPUT_MAX_LENGTH
|
|
124
|
+
? `${text.slice(0, TOOL_OUTPUT_MAX_LENGTH)}\n… (truncated)`
|
|
125
|
+
: text;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Hasil sampingan satu pesan SDK yang perlu disimpan klien. */
|
|
129
|
+
export interface TranslateEffects {
|
|
130
|
+
/** Task list terbaru (TodoWrite). */
|
|
131
|
+
todos?: OpenCodeTodo[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Blok streaming yang sedang dibangun (text/reasoning). */
|
|
135
|
+
interface StreamBlock {
|
|
136
|
+
part: MessagePart;
|
|
137
|
+
/**
|
|
138
|
+
* Part sudah dikirim. Blok baru dikirim saat token pertama tiba: thinking
|
|
139
|
+
* yang disembunyikan provider (tanpa delta) tidak menjadi part kosong.
|
|
140
|
+
*/
|
|
141
|
+
emitted: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type Msg = Record<string, unknown>;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Penerjemah stateful per Session Claude Code. `emit` menerima event gaya
|
|
148
|
+
* opencode yang sudah memuat `sessionID`.
|
|
149
|
+
*/
|
|
150
|
+
export class ClaudeTurnTranslator {
|
|
151
|
+
/** Id pesan assistant lokal turn aktif; null = belum ada turn. */
|
|
152
|
+
private messageId: string | null = null;
|
|
153
|
+
/** Id pesan API Anthropic yang teksnya sudah di-stream (lewati di `assistant`). */
|
|
154
|
+
private readonly streamed = new Set<string>();
|
|
155
|
+
/** Blok streaming aktif per pesan API: `${apiMsgId}:${index}` -> part. */
|
|
156
|
+
private readonly blocks = new Map<string, StreamBlock>();
|
|
157
|
+
/** Pesan API aktif per jalur (induk / sub-agent `parent_tool_use_id`). */
|
|
158
|
+
private readonly currentApiMsg = new Map<string, string>();
|
|
159
|
+
/** Part tool per tool_use id (untuk melengkapi hasilnya). */
|
|
160
|
+
private readonly tools = new Map<string, MessagePart>();
|
|
161
|
+
|
|
162
|
+
constructor(
|
|
163
|
+
private readonly sessionID: string,
|
|
164
|
+
private readonly emit: (ev: OpenCodeEvent) => void,
|
|
165
|
+
private readonly now: () => number = Date.now,
|
|
166
|
+
) {}
|
|
167
|
+
|
|
168
|
+
/** Mulai turn baru: pesan assistant lokal baru + `message.updated`. */
|
|
169
|
+
beginTurn(): void {
|
|
170
|
+
this.messageId = `msg_${randomUUID()}`;
|
|
171
|
+
this.streamed.clear();
|
|
172
|
+
this.blocks.clear();
|
|
173
|
+
this.currentApiMsg.clear();
|
|
174
|
+
this.tools.clear();
|
|
175
|
+
this.emit({
|
|
176
|
+
type: "message.updated",
|
|
177
|
+
sessionID: this.sessionID,
|
|
178
|
+
info: { id: this.messageId, role: "assistant" },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Pastikan ada pesan assistant (event datang tanpa prompt dari bridge). */
|
|
183
|
+
private ensureTurn(): string {
|
|
184
|
+
if (this.messageId === null) this.beginTurn();
|
|
185
|
+
return this.messageId as string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private emitPart(part: MessagePart): void {
|
|
189
|
+
const messageID = this.ensureTurn();
|
|
190
|
+
this.emit({
|
|
191
|
+
type: "message.part.updated",
|
|
192
|
+
sessionID: this.sessionID,
|
|
193
|
+
part: { ...part, messageID },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private emitDelta(partID: string, delta: string): void {
|
|
198
|
+
if (this.messageId === null || delta === "") return;
|
|
199
|
+
this.emit({
|
|
200
|
+
type: "message.part.delta",
|
|
201
|
+
sessionID: this.sessionID,
|
|
202
|
+
messageID: this.messageId,
|
|
203
|
+
partID,
|
|
204
|
+
field: "text",
|
|
205
|
+
delta,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Terjemahkan satu pesan SDK; kembalikan efek samping untuk klien. */
|
|
210
|
+
handle(msg: Msg): TranslateEffects {
|
|
211
|
+
switch (msg.type) {
|
|
212
|
+
case "stream_event":
|
|
213
|
+
this.handleStreamEvent(msg);
|
|
214
|
+
return {};
|
|
215
|
+
case "assistant":
|
|
216
|
+
return this.handleAssistant(msg);
|
|
217
|
+
case "user":
|
|
218
|
+
this.handleUser(msg);
|
|
219
|
+
return {};
|
|
220
|
+
case "system":
|
|
221
|
+
if (msg.subtype === "api_retry") this.handleRetry(msg);
|
|
222
|
+
return {};
|
|
223
|
+
default:
|
|
224
|
+
return {};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private handleStreamEvent(msg: Msg): void {
|
|
229
|
+
const ev = msg.event as Msg | undefined;
|
|
230
|
+
if (!ev || typeof ev.type !== "string") return;
|
|
231
|
+
const lane = typeof msg.parent_tool_use_id === "string" ? msg.parent_tool_use_id : "";
|
|
232
|
+
if (ev.type === "message_start") {
|
|
233
|
+
const id = (ev.message as { id?: unknown } | undefined)?.id;
|
|
234
|
+
if (typeof id === "string") {
|
|
235
|
+
this.currentApiMsg.set(lane, id);
|
|
236
|
+
this.streamed.add(id);
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const apiMsgId = this.currentApiMsg.get(lane);
|
|
241
|
+
if (apiMsgId === undefined || typeof ev.index !== "number") return;
|
|
242
|
+
const key = `${apiMsgId}:${ev.index}`;
|
|
243
|
+
if (ev.type === "content_block_start") {
|
|
244
|
+
const block = ev.content_block as { type?: unknown } | undefined;
|
|
245
|
+
const type =
|
|
246
|
+
block?.type === "text" ? "text" : block?.type === "thinking" ? "reasoning" : null;
|
|
247
|
+
if (type === null) return; // tool_use dirakit dari pesan `assistant`
|
|
248
|
+
const part: MessagePart = {
|
|
249
|
+
id: `prt_${apiMsgId}_${ev.index}`,
|
|
250
|
+
type,
|
|
251
|
+
text: "",
|
|
252
|
+
time: { start: this.now() },
|
|
253
|
+
};
|
|
254
|
+
this.blocks.set(key, { part, emitted: false });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const cur = this.blocks.get(key);
|
|
258
|
+
if (!cur) return;
|
|
259
|
+
if (ev.type === "content_block_delta") {
|
|
260
|
+
const delta = ev.delta as { type?: unknown; text?: unknown; thinking?: unknown } | undefined;
|
|
261
|
+
const piece =
|
|
262
|
+
delta?.type === "text_delta" && typeof delta.text === "string"
|
|
263
|
+
? delta.text
|
|
264
|
+
: delta?.type === "thinking_delta" && typeof delta.thinking === "string"
|
|
265
|
+
? delta.thinking
|
|
266
|
+
: "";
|
|
267
|
+
if (piece === "") return;
|
|
268
|
+
if (!cur.emitted) {
|
|
269
|
+
cur.emitted = true;
|
|
270
|
+
this.emitPart(cur.part);
|
|
271
|
+
}
|
|
272
|
+
cur.part = { ...cur.part, text: `${cur.part.text ?? ""}${piece}` };
|
|
273
|
+
this.emitDelta(cur.part.id as string, piece);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (ev.type === "content_block_stop") {
|
|
277
|
+
this.blocks.delete(key);
|
|
278
|
+
if (!cur.emitted) return; // tanpa token sama sekali
|
|
279
|
+
cur.part = { ...cur.part, time: { ...cur.part.time, end: this.now() } };
|
|
280
|
+
this.emitPart(cur.part);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private handleAssistant(msg: Msg): TranslateEffects {
|
|
285
|
+
const message = msg.message as { id?: unknown; content?: unknown } | undefined;
|
|
286
|
+
if (!message || !Array.isArray(message.content)) return {};
|
|
287
|
+
const apiMsgId = typeof message.id === "string" ? message.id : null;
|
|
288
|
+
const wasStreamed = apiMsgId !== null && this.streamed.has(apiMsgId);
|
|
289
|
+
const uuid = typeof msg.uuid === "string" ? msg.uuid : randomUUID();
|
|
290
|
+
const effects: TranslateEffects = {};
|
|
291
|
+
message.content.forEach((raw, i) => {
|
|
292
|
+
if (typeof raw !== "object" || raw === null) return;
|
|
293
|
+
const block = raw as Msg;
|
|
294
|
+
if (block.type === "text" || block.type === "thinking") {
|
|
295
|
+
// Sudah tampil lewat stream_event — jangan diulang.
|
|
296
|
+
if (wasStreamed) return;
|
|
297
|
+
const text = block.type === "text" ? block.text : block.thinking;
|
|
298
|
+
if (typeof text !== "string" || text === "") return;
|
|
299
|
+
const t = this.now();
|
|
300
|
+
this.emitPart({
|
|
301
|
+
id: `prt_${uuid}_${i}`,
|
|
302
|
+
type: block.type === "text" ? "text" : "reasoning",
|
|
303
|
+
text,
|
|
304
|
+
time: { start: t, end: t },
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (block.type === "tool_use" || block.type === "server_tool_use") {
|
|
309
|
+
if (typeof block.id !== "string" || typeof block.name !== "string") return;
|
|
310
|
+
const input = normalizeToolInput(block.input);
|
|
311
|
+
const part: MessagePart = {
|
|
312
|
+
id: `prt_${block.id}`,
|
|
313
|
+
type: "tool",
|
|
314
|
+
tool: toolName(block.name),
|
|
315
|
+
callID: block.id,
|
|
316
|
+
state: { status: "running", input, time: { start: this.now() } },
|
|
317
|
+
};
|
|
318
|
+
this.tools.set(block.id, part);
|
|
319
|
+
this.emitPart(part);
|
|
320
|
+
if (block.name === "TodoWrite") {
|
|
321
|
+
const todos = todosFromInput(block.input);
|
|
322
|
+
if (todos) effects.todos = todos;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
return effects;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private handleUser(msg: Msg): void {
|
|
330
|
+
const message = msg.message as { content?: unknown } | undefined;
|
|
331
|
+
if (!message || !Array.isArray(message.content)) return;
|
|
332
|
+
for (const raw of message.content) {
|
|
333
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
334
|
+
const block = raw as Msg;
|
|
335
|
+
if (block.type !== "tool_result" || typeof block.tool_use_id !== "string") continue;
|
|
336
|
+
const part = this.tools.get(block.tool_use_id);
|
|
337
|
+
if (!part) continue;
|
|
338
|
+
const prev = (part.state ?? {}) as { input?: Record<string, unknown>; time?: unknown };
|
|
339
|
+
const startedAt =
|
|
340
|
+
typeof prev.time === "object" && prev.time !== null
|
|
341
|
+
? (prev.time as { start?: number }).start
|
|
342
|
+
: undefined;
|
|
343
|
+
const done: MessagePart = {
|
|
344
|
+
...part,
|
|
345
|
+
state: {
|
|
346
|
+
status: block.is_error === true ? "error" : "completed",
|
|
347
|
+
input: prev.input,
|
|
348
|
+
output: toolResultText(block.content),
|
|
349
|
+
time: { start: startedAt, end: this.now() },
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
this.tools.set(block.tool_use_id, done);
|
|
353
|
+
this.emitPart(done);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private handleRetry(msg: Msg): void {
|
|
358
|
+
const status = typeof msg.error_status === "number" ? ` (HTTP ${msg.error_status})` : "";
|
|
359
|
+
const reason = typeof msg.error === "string" ? msg.error.replace(/_/g, " ") : "request failed";
|
|
360
|
+
this.emit({
|
|
361
|
+
type: "session.status",
|
|
362
|
+
sessionID: this.sessionID,
|
|
363
|
+
status: {
|
|
364
|
+
type: "retry",
|
|
365
|
+
attempt: typeof msg.attempt === "number" ? msg.attempt : undefined,
|
|
366
|
+
message: `${reason}${status}`,
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daftar MCP server Claude Code — dibaca dari config di disk.
|
|
3
|
+
*
|
|
4
|
+
* Status MCP Claude Code hanya diketahui dari proses `claude` yang sedang
|
|
5
|
+
* berjalan (`mcpServerStatus()` / pesan `system/init`). Tanpa proses itu
|
|
6
|
+
* daftarnya kosong, padahal server sudah dikonfigurasi. Modul ini membaca
|
|
7
|
+
* NAMA server dari lokasi yang sama dengan CLI `claude` (isi config — env,
|
|
8
|
+
* header, token — tidak pernah dibaca keluar):
|
|
9
|
+
* - project : `<project>/.mcp.json` -> `mcpServers`
|
|
10
|
+
* - local : `<config>/.claude.json` -> `projects[<path>].mcpServers`
|
|
11
|
+
* - user : `<config>/.claude.json` -> `mcpServers`
|
|
12
|
+
* `<config>` = `CLAUDE_CONFIG_DIR` bila diset, selain itu `HOME` dari env
|
|
13
|
+
* proses `claude` (fallback home user OS).
|
|
14
|
+
*
|
|
15
|
+
* Server `.mcp.json` yang ditolak user (`disabledMcpjsonServers`) bertanda
|
|
16
|
+
* `disabled`; lainnya `unknown` sampai status live tersedia
|
|
17
|
+
* (`mergeClaudeMcp`).
|
|
18
|
+
*/
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import type { McpServerInfo } from "./opencode-client";
|
|
22
|
+
|
|
23
|
+
export type ClaudeMcpScope = "project" | "local" | "user";
|
|
24
|
+
|
|
25
|
+
export interface ClaudeMcpConfigEntry {
|
|
26
|
+
name: string;
|
|
27
|
+
scope: ClaudeMcpScope;
|
|
28
|
+
/** Server `.mcp.json` yang ditolak user di Claude Code. */
|
|
29
|
+
disabled: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Path `.claude.json` untuk `env` tertentu. */
|
|
33
|
+
export function claudeJsonPath(env: Record<string, string | undefined>): string {
|
|
34
|
+
const configDir = env.CLAUDE_CONFIG_DIR?.trim();
|
|
35
|
+
if (configDir) return join(configDir, ".claude.json");
|
|
36
|
+
return join(env.HOME?.trim() || homedir(), ".claude.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function serverNames(value: unknown): string[] {
|
|
40
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return [];
|
|
41
|
+
return Object.keys(value).filter((k) => k !== "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stringList(value: unknown): Set<string> {
|
|
45
|
+
return new Set(Array.isArray(value) ? value.filter((v) => typeof v === "string") : []);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Gabungkan isi `.mcp.json` dan `.claude.json` (sudah di-parse) menjadi
|
|
50
|
+
* daftar unik per nama. Prioritas sama dengan Claude Code: local > project >
|
|
51
|
+
* user. Murni — diuji.
|
|
52
|
+
*/
|
|
53
|
+
export function parseClaudeMcpConfig(
|
|
54
|
+
projectPath: string,
|
|
55
|
+
mcpJson: unknown,
|
|
56
|
+
claudeJson: unknown,
|
|
57
|
+
): ClaudeMcpConfigEntry[] {
|
|
58
|
+
const root = (typeof claudeJson === "object" && claudeJson !== null ? claudeJson : {}) as {
|
|
59
|
+
mcpServers?: unknown;
|
|
60
|
+
projects?: Record<string, unknown>;
|
|
61
|
+
};
|
|
62
|
+
const projectState = (root.projects?.[projectPath.replace(/\/+$/, "")] ?? {}) as {
|
|
63
|
+
mcpServers?: unknown;
|
|
64
|
+
disabledMcpjsonServers?: unknown;
|
|
65
|
+
};
|
|
66
|
+
const rejected = stringList(projectState.disabledMcpjsonServers);
|
|
67
|
+
|
|
68
|
+
const byName = new Map<string, ClaudeMcpConfigEntry>();
|
|
69
|
+
const add = (names: string[], scope: ClaudeMcpScope) => {
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
if (!byName.has(name)) {
|
|
72
|
+
byName.set(name, { name, scope, disabled: scope === "project" && rejected.has(name) });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
add(serverNames(projectState.mcpServers), "local");
|
|
77
|
+
add(serverNames((mcpJson as { mcpServers?: unknown } | null)?.mcpServers), "project");
|
|
78
|
+
add(serverNames(root.mcpServers), "user");
|
|
79
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readJson(path: string): Promise<unknown> {
|
|
83
|
+
try {
|
|
84
|
+
const file = Bun.file(path);
|
|
85
|
+
if (!(await file.exists())) return null;
|
|
86
|
+
return await file.json();
|
|
87
|
+
} catch {
|
|
88
|
+
// File rusak / tak terbaca — anggap tidak ada server dari sumber ini.
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Baca config MCP Claude Code untuk sebuah Project dari disk. */
|
|
94
|
+
export async function readClaudeMcpConfig(
|
|
95
|
+
projectPath: string,
|
|
96
|
+
env: Record<string, string | undefined>,
|
|
97
|
+
): Promise<ClaudeMcpConfigEntry[]> {
|
|
98
|
+
const [mcpJson, claudeJson] = await Promise.all([
|
|
99
|
+
readJson(join(projectPath, ".mcp.json")),
|
|
100
|
+
readJson(claudeJsonPath(env)),
|
|
101
|
+
]);
|
|
102
|
+
return parseClaudeMcpConfig(projectPath, mcpJson, claudeJson);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Daftar akhir: server dari config + status live bila ada. Server live yang
|
|
107
|
+
* tidak ada di config (plugin, konektor claude.ai) tetap ikut. Murni — diuji.
|
|
108
|
+
*/
|
|
109
|
+
export function mergeClaudeMcp(
|
|
110
|
+
configured: readonly ClaudeMcpConfigEntry[],
|
|
111
|
+
live: readonly McpServerInfo[],
|
|
112
|
+
): McpServerInfo[] {
|
|
113
|
+
const liveByName = new Map(live.map((s) => [s.name, s]));
|
|
114
|
+
const out: McpServerInfo[] = configured.map((c) => {
|
|
115
|
+
const hit = liveByName.get(c.name);
|
|
116
|
+
if (hit) return hit;
|
|
117
|
+
return { name: c.name, status: c.disabled ? "disabled" : "unknown", error: null };
|
|
118
|
+
});
|
|
119
|
+
const known = new Set(configured.map((c) => c.name));
|
|
120
|
+
for (const s of live) if (!known.has(s.name)) out.push(s);
|
|
121
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
122
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daftar skill Claude Code — dibaca langsung dari disk.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code tidak punya server headless untuk ditanya (beda dengan
|
|
5
|
+
* `GET /skill` opencode), jadi skill dipindai dari lokasi yang sama dengan
|
|
6
|
+
* yang dibaca CLI `claude`:
|
|
7
|
+
* - Project : `<project>/.claude/skills/<nama>/SKILL.md`
|
|
8
|
+
* - Global : `<CLAUDE_CONFIG_DIR | ~/.claude>/skills/<nama>/SKILL.md`
|
|
9
|
+
*
|
|
10
|
+
* CLI `skills` (`--agent claude-code`) memasang ke `.claude/skills/` berupa
|
|
11
|
+
* symlink ke `.agents/skills/`, jadi symlink diikuti. Skill dari plugin
|
|
12
|
+
* Claude Code tidak ikut (tidak dipasang lewat halaman Skills).
|
|
13
|
+
*/
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import type { SkillInfo, SkillSource } from "./opencode-client";
|
|
17
|
+
|
|
18
|
+
/** Direktori skill global Claude Code untuk `env` tertentu. */
|
|
19
|
+
export function claudeGlobalSkillsDir(env: Record<string, string | undefined>): string {
|
|
20
|
+
// `HOME` dari env proses `claude` (sama dengan yang dipakai CLI-nya).
|
|
21
|
+
const configDir = env.CLAUDE_CONFIG_DIR?.trim() || join(env.HOME?.trim() || homedir(), ".claude");
|
|
22
|
+
return join(configDir, "skills");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function unquote(v: string): string {
|
|
26
|
+
const t = v.trim();
|
|
27
|
+
if (t.length >= 2 && (t[0] === '"' || t[0] === "'") && t.at(-1) === t[0]) {
|
|
28
|
+
return t.slice(1, -1);
|
|
29
|
+
}
|
|
30
|
+
return t;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Ambil `name` & `description` dari frontmatter YAML SKILL.md. Cukup untuk
|
|
35
|
+
* bentuk yang umum: nilai satu baris (boleh berkutip) atau blok `>` / `|`
|
|
36
|
+
* yang baris lanjutannya menjorok. Murni — diuji.
|
|
37
|
+
*/
|
|
38
|
+
export function parseSkillFrontmatter(text: string): {
|
|
39
|
+
name: string | null;
|
|
40
|
+
description: string | null;
|
|
41
|
+
} {
|
|
42
|
+
const lines = text.replace(/^/, "").split(/\r?\n/);
|
|
43
|
+
if (lines[0]?.trim() !== "---") return { name: null, description: null };
|
|
44
|
+
const fields: Record<string, string> = {};
|
|
45
|
+
for (let i = 1; i < lines.length; i++) {
|
|
46
|
+
const line = lines[i] ?? "";
|
|
47
|
+
if (line.trim() === "---") break;
|
|
48
|
+
const m = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
|
|
49
|
+
if (!m?.[1]) continue;
|
|
50
|
+
const key = m[1];
|
|
51
|
+
let value = m[2] ?? "";
|
|
52
|
+
if (value === "" || /^[>|][+-]?$/.test(value.trim())) {
|
|
53
|
+
// Blok berlapis: kumpulkan baris menjorok berikutnya.
|
|
54
|
+
const parts: string[] = [];
|
|
55
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1] ?? "")) {
|
|
56
|
+
i++;
|
|
57
|
+
parts.push((lines[i] ?? "").trim());
|
|
58
|
+
}
|
|
59
|
+
value = parts.join(" ");
|
|
60
|
+
}
|
|
61
|
+
fields[key] = unquote(value);
|
|
62
|
+
}
|
|
63
|
+
const str = (v: string | undefined) => (v && v !== "" ? v : null);
|
|
64
|
+
return { name: str(fields.name), description: str(fields.description) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function scanDir(root: string, source: SkillSource): Promise<SkillInfo[]> {
|
|
68
|
+
const out: SkillInfo[] = [];
|
|
69
|
+
const glob = new Bun.Glob("*/SKILL.md");
|
|
70
|
+
try {
|
|
71
|
+
for await (const rel of glob.scan({ cwd: root, followSymlinks: true, onlyFiles: true })) {
|
|
72
|
+
const location = join(root, rel);
|
|
73
|
+
const dirName = rel.split("/")[0] ?? rel;
|
|
74
|
+
let meta: ReturnType<typeof parseSkillFrontmatter> = { name: null, description: null };
|
|
75
|
+
try {
|
|
76
|
+
meta = parseSkillFrontmatter(await Bun.file(location).text());
|
|
77
|
+
} catch {
|
|
78
|
+
// SKILL.md tak terbaca (symlink rusak, izin) — tetap tampil pakai nama folder.
|
|
79
|
+
}
|
|
80
|
+
out.push({ name: meta.name ?? dirName, description: meta.description, location, source });
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Direktori belum ada — tidak ada skill.
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Skill Claude Code untuk sebuah Project (Project + global), terurut nama.
|
|
90
|
+
* Nama yang sama di dua lokasi tetap tampil keduanya (dibedakan `source`),
|
|
91
|
+
* sama seperti daftar opencode.
|
|
92
|
+
*/
|
|
93
|
+
export async function listClaudeSkills(
|
|
94
|
+
projectPath: string,
|
|
95
|
+
globalDir: string,
|
|
96
|
+
): Promise<SkillInfo[]> {
|
|
97
|
+
const [project, global] = await Promise.all([
|
|
98
|
+
scanDir(join(projectPath, ".claude", "skills"), "project"),
|
|
99
|
+
scanDir(globalDir, "global"),
|
|
100
|
+
]);
|
|
101
|
+
return [...project, ...global].sort((a, b) => a.name.localeCompare(b.name));
|
|
102
|
+
}
|