agentlas 0.4.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/LICENSE +201 -0
- package/README.md +107 -0
- package/bin/agentlas.cjs +176 -0
- package/engine/ENGINE_META.json +8 -0
- package/engine/agentlas-api-agent.cjs +451 -0
- package/engine/agentlas-banner.cjs +108 -0
- package/engine/agentlas-capabilities.cjs +72 -0
- package/engine/agentlas-cloud-runtime.cjs +199 -0
- package/engine/agentlas-composer.cjs +256 -0
- package/engine/agentlas-config.cjs +29 -0
- package/engine/agentlas-doctor.cjs +173 -0
- package/engine/agentlas-i18n.cjs +317 -0
- package/engine/agentlas-input.cjs +437 -0
- package/engine/agentlas-native-host.cjs +612 -0
- package/engine/agentlas-onboard.cjs +71 -0
- package/engine/agentlas-parity.cjs +994 -0
- package/engine/agentlas-repl.cjs +1258 -0
- package/engine/agentlas-style.cjs +100 -0
- package/engine/agentlas-tools.cjs +196 -0
- package/engine/agentlas-ui.cjs +266 -0
- package/engine/agentlas.cjs +7503 -0
- package/engine/architecture.data.json +139 -0
- package/engine/bootstrap-schema.sql +568 -0
- package/install.ps1 +26 -0
- package/install.sh +53 -0
- package/package.json +49 -0
- package/scripts/gen-bootstrap-schema.sh +23 -0
- package/test/smoke.sh +56 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* native-host: claude / codex / gemini 를 headless 스트리밍으로 구동하고
|
|
4
|
+
* 그 이벤트를 agentlas TUI 안에서 렌더한다. (사용자 결정: "agentlas 터미널이 항상 호스트")
|
|
5
|
+
*
|
|
6
|
+
* 핵심: 사용자의 기존 claude/codex 구독 인증을 그대로 사용한다 (API 키 불필요).
|
|
7
|
+
* - claude: claude -p <prompt> --output-format stream-json --include-partial-messages --verbose
|
|
8
|
+
* (멀티턴은 --resume <session_id>)
|
|
9
|
+
* - codex: codex exec --json --skip-git-repo-check -C <cwd> [sandbox] <prompt>
|
|
10
|
+
* (멀티턴은 codex exec resume <thread_id> ...)
|
|
11
|
+
* - gemini: gemini -p <system+prompt> [--yolo] (stdout 평문 스트리밍)
|
|
12
|
+
*
|
|
13
|
+
* 스키마는 실측으로 확인됨 (cli/agentlas.cjs 상단 주석 참고).
|
|
14
|
+
*/
|
|
15
|
+
const { spawn } = require("node:child_process");
|
|
16
|
+
const fs = require("node:fs");
|
|
17
|
+
const os = require("node:os");
|
|
18
|
+
const path = require("node:path");
|
|
19
|
+
|
|
20
|
+
function userDataDir() {
|
|
21
|
+
const override = process.env.AGENTLAS_USER_DATA_DIR;
|
|
22
|
+
if (override) return override;
|
|
23
|
+
const home = os.homedir();
|
|
24
|
+
if (process.platform === "darwin") return path.join(home, "Library", "Application Support", "Agentlas");
|
|
25
|
+
if (process.platform === "win32") return path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "Agentlas");
|
|
26
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, ".config"), "Agentlas");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// MCP 서버 이름 → TOML/JSON 안전 키 (하이픈/공백 → _).
|
|
30
|
+
function mcpKey(s) {
|
|
31
|
+
return String((s && (s.name || s.id)) || "mcp").toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "mcp";
|
|
32
|
+
}
|
|
33
|
+
function mcpStdioArgs(s) {
|
|
34
|
+
try { return JSON.parse((s && s.args_json) || "[]"); } catch { return []; }
|
|
35
|
+
}
|
|
36
|
+
// claude --mcp-config 파일을 쓴다. playwright(항상) + DB에 enabled 된 stdio MCP 서버들.
|
|
37
|
+
function cliMcpConfigPath(servers) {
|
|
38
|
+
const dir = path.join(userDataDir(), "mcp");
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
const file = path.join(dir, "agentlas-cli-mcp.json");
|
|
41
|
+
const mcpServers = { playwright: { command: "npx", args: ["-y", "@playwright/mcp@latest"] } };
|
|
42
|
+
for (const s of servers || []) {
|
|
43
|
+
if (!s || s.enabled === 0 || s.transport !== "stdio" || !s.command) continue;
|
|
44
|
+
mcpServers[mcpKey(s)] = { command: s.command, args: mcpStdioArgs(s) };
|
|
45
|
+
}
|
|
46
|
+
fs.writeFileSync(file, JSON.stringify({ mcpServers }, null, 2), "utf8");
|
|
47
|
+
return { file, names: Object.keys(mcpServers) };
|
|
48
|
+
}
|
|
49
|
+
// codex -c mcp_servers.<key>.command/args — playwright(항상) + DB stdio 서버들.
|
|
50
|
+
function codexMcpArgs(servers) {
|
|
51
|
+
const out = [
|
|
52
|
+
"-c", 'mcp_servers.playwright.command="npx"',
|
|
53
|
+
"-c", 'mcp_servers.playwright.args=["-y","@playwright/mcp@latest"]',
|
|
54
|
+
];
|
|
55
|
+
for (const s of servers || []) {
|
|
56
|
+
if (!s || s.enabled === 0 || s.transport !== "stdio" || !s.command) continue;
|
|
57
|
+
const k = mcpKey(s);
|
|
58
|
+
out.push("-c", `mcp_servers.${k}.command=${JSON.stringify(s.command)}`);
|
|
59
|
+
out.push("-c", `mcp_servers.${k}.args=${JSON.stringify(mcpStdioArgs(s))}`);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 툴 input(JSON)에서 사람이 읽을 대표 인자 한 줄 추출.
|
|
65
|
+
function summarizeToolInput(name, input) {
|
|
66
|
+
if (!input || typeof input !== "object") return "";
|
|
67
|
+
// TodoWrite/플랜류 — todos 배열이면 진행 중 항목 또는 개수로 요약.
|
|
68
|
+
if (Array.isArray(input.todos)) {
|
|
69
|
+
const ip = input.todos.find((t) => t && t.status === "in_progress");
|
|
70
|
+
return ip ? String(ip.content || ip.activeForm || "").slice(0, 80) : `${input.todos.length} todos`;
|
|
71
|
+
}
|
|
72
|
+
const pick = (k) => (typeof input[k] === "string" ? input[k] : undefined);
|
|
73
|
+
return (
|
|
74
|
+
pick("file_path") ||
|
|
75
|
+
pick("path") ||
|
|
76
|
+
pick("command") ||
|
|
77
|
+
pick("pattern") ||
|
|
78
|
+
pick("query") ||
|
|
79
|
+
pick("url") ||
|
|
80
|
+
pick("notebook_path") ||
|
|
81
|
+
(pick("prompt") ? pick("prompt").slice(0, 80) : "") ||
|
|
82
|
+
""
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// child.stdout → 줄 단위 콜백. 종료 시 잔여 버퍼 flush.
|
|
87
|
+
function lineReader(stream, onLine) {
|
|
88
|
+
let buf = "";
|
|
89
|
+
stream.setEncoding("utf8");
|
|
90
|
+
stream.on("data", (chunk) => {
|
|
91
|
+
buf += chunk;
|
|
92
|
+
let nl;
|
|
93
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
94
|
+
const line = buf.slice(0, nl);
|
|
95
|
+
buf = buf.slice(nl + 1);
|
|
96
|
+
if (line.trim()) onLine(line);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
stream.on("end", () => {
|
|
100
|
+
if (buf.trim()) onLine(buf);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── claude-code ──────────────────────────────────────────
|
|
105
|
+
function claudeArgs({ prompt, systemPrompt, permission, session, model, effort, mcpServers }) {
|
|
106
|
+
const perm =
|
|
107
|
+
permission === "full"
|
|
108
|
+
? ["--permission-mode", "bypassPermissions"]
|
|
109
|
+
: permission === "write"
|
|
110
|
+
? ["--permission-mode", "acceptEdits"]
|
|
111
|
+
: [];
|
|
112
|
+
// /effort → Claude Code는 think 키워드로 reasoning 예산을 올린다(전용 CLI 플래그 없음).
|
|
113
|
+
const thinkKw =
|
|
114
|
+
effort === "max" ? "Ultrathink. " : effort === "high" ? "Think hard. " : effort === "medium" ? "Think. " : "";
|
|
115
|
+
const args = [
|
|
116
|
+
"-p",
|
|
117
|
+
thinkKw + prompt,
|
|
118
|
+
"--output-format",
|
|
119
|
+
"stream-json",
|
|
120
|
+
"--include-partial-messages",
|
|
121
|
+
"--verbose",
|
|
122
|
+
...perm,
|
|
123
|
+
];
|
|
124
|
+
if (permission === "write" || permission === "full") {
|
|
125
|
+
const mcpCfg = cliMcpConfigPath(mcpServers);
|
|
126
|
+
args.push("--mcp-config", mcpCfg.file, "--allowedTools", mcpCfg.names.map((n) => "mcp__" + n).join(","));
|
|
127
|
+
}
|
|
128
|
+
if (model) args.push("--model", model); // alias (sonnet/opus) or full id — /model parity
|
|
129
|
+
if (session && session.id) {
|
|
130
|
+
args.push("--resume", session.id);
|
|
131
|
+
} else if (systemPrompt) {
|
|
132
|
+
args.push("--append-system-prompt", systemPrompt);
|
|
133
|
+
}
|
|
134
|
+
return args;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ANSI 제거 (에러 stderr 정리용) — 외부 의존성 없이.
|
|
138
|
+
function stripAnsi(s) {
|
|
139
|
+
// eslint-disable-next-line no-control-regex
|
|
140
|
+
return String(s).replace(/\x1b\[[0-9;]*m/g, "");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function handleClaudeLine(line, st, ui) {
|
|
144
|
+
let obj;
|
|
145
|
+
try {
|
|
146
|
+
obj = JSON.parse(line);
|
|
147
|
+
} catch {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
switch (obj.type) {
|
|
151
|
+
case "system":
|
|
152
|
+
if (obj.subtype === "init" && obj.session_id) st.session.id = obj.session_id;
|
|
153
|
+
// hook_started / hook_response / status → 노이즈, 무시
|
|
154
|
+
return;
|
|
155
|
+
case "stream_event": {
|
|
156
|
+
const ev = obj.event || {};
|
|
157
|
+
if (ev.type === "content_block_start") {
|
|
158
|
+
const cb = ev.content_block || {};
|
|
159
|
+
if (cb.type === "tool_use") {
|
|
160
|
+
st.tools[ev.index] = { name: cb.name || "tool", input: "" };
|
|
161
|
+
// 인자가 다 모이는 content_block_stop에서 한 줄로(⏺ Name(arg)) 출력 — Claude Code 스타일
|
|
162
|
+
} else if (cb.type === "thinking") {
|
|
163
|
+
st.think[ev.index] = "";
|
|
164
|
+
ui.status("✻ thinking…");
|
|
165
|
+
} else if (cb.type === "text") {
|
|
166
|
+
ui.streamStart();
|
|
167
|
+
}
|
|
168
|
+
} else if (ev.type === "content_block_delta") {
|
|
169
|
+
const d = ev.delta || {};
|
|
170
|
+
if (d.type === "text_delta" && d.text) {
|
|
171
|
+
ui.streamDelta(d.text);
|
|
172
|
+
st.text += d.text;
|
|
173
|
+
} else if (d.type === "input_json_delta" && st.tools[ev.index]) {
|
|
174
|
+
st.tools[ev.index].input += d.partial_json || "";
|
|
175
|
+
} else if (d.type === "thinking_delta" && st.think[ev.index] != null) {
|
|
176
|
+
st.think[ev.index] += d.thinking || "";
|
|
177
|
+
}
|
|
178
|
+
} else if (ev.type === "content_block_stop") {
|
|
179
|
+
const t = st.tools[ev.index];
|
|
180
|
+
if (t) {
|
|
181
|
+
let parsed;
|
|
182
|
+
try {
|
|
183
|
+
parsed = JSON.parse(t.input || "{}");
|
|
184
|
+
} catch {
|
|
185
|
+
parsed = null;
|
|
186
|
+
}
|
|
187
|
+
ui.tool(prettyToolName(t.name), summarizeToolInput(t.name, parsed));
|
|
188
|
+
} else if (st.think[ev.index] != null) {
|
|
189
|
+
const th = String(st.think[ev.index] || "").trim();
|
|
190
|
+
if (th) ui.line(ui.c.faint(" " + ui.c.italic(truncateLines(th, 3))));
|
|
191
|
+
st.think[ev.index] = null;
|
|
192
|
+
} else {
|
|
193
|
+
ui.streamEnd();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
case "user": {
|
|
199
|
+
// tool_result 들
|
|
200
|
+
const content = obj.message && obj.message.content;
|
|
201
|
+
if (Array.isArray(content)) {
|
|
202
|
+
for (const block of content) {
|
|
203
|
+
if (block.type === "tool_result") {
|
|
204
|
+
const txt = Array.isArray(block.content)
|
|
205
|
+
? block.content.map((b) => (b.type === "text" ? b.text : "")).join("")
|
|
206
|
+
: typeof block.content === "string"
|
|
207
|
+
? block.content
|
|
208
|
+
: "";
|
|
209
|
+
ui.toolResult(txt, !block.is_error);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
case "result":
|
|
216
|
+
st.finalText = typeof obj.result === "string" ? obj.result : st.text;
|
|
217
|
+
st.usage = {
|
|
218
|
+
input_tokens: obj.usage && obj.usage.input_tokens,
|
|
219
|
+
output_tokens: obj.usage && obj.usage.output_tokens,
|
|
220
|
+
cost_usd: obj.total_cost_usd,
|
|
221
|
+
duration_ms: obj.duration_ms,
|
|
222
|
+
};
|
|
223
|
+
if (obj.is_error) st.error = obj.result || "claude error";
|
|
224
|
+
return;
|
|
225
|
+
case "rate_limit_event":
|
|
226
|
+
if (obj.rate_limit_info && obj.rate_limit_info.status === "rejected") {
|
|
227
|
+
ui.warn("claude rate limit reached");
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
default:
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function prettyToolName(name) {
|
|
236
|
+
if (!name) return "tool";
|
|
237
|
+
// mcp__server__tool → server·tool (Claude Code 처럼 깔끔하게)
|
|
238
|
+
const m = /^mcp__(.+?)__(.+)$/.exec(name);
|
|
239
|
+
if (m) return `${m[1]}·${m[2]}`;
|
|
240
|
+
return name;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── codex ────────────────────────────────────────────────
|
|
244
|
+
function codexArgs({ prompt, systemPrompt, permission, session, cwd, model, effort, mcpServers }) {
|
|
245
|
+
const sandbox =
|
|
246
|
+
permission === "full" || permission === "write"
|
|
247
|
+
? ["--dangerously-bypass-approvals-and-sandbox"]
|
|
248
|
+
: // `codex exec` 는 --ask-for-approval 플래그가 없다(그건 top-level codex 옵션). config 오버라이드로 지정.
|
|
249
|
+
["--sandbox", "read-only", "-c", 'approval_policy="never"'];
|
|
250
|
+
const mcp = permission === "write" || permission === "full" ? codexMcpArgs(mcpServers) : [];
|
|
251
|
+
const mdl = model ? ["-m", model] : []; // /model parity
|
|
252
|
+
// /effort parity → codex reasoning effort (low|medium|high). max는 high로 매핑.
|
|
253
|
+
const eff = effort ? ["-c", `model_reasoning_effort="${effort === "max" ? "high" : effort}"`] : [];
|
|
254
|
+
const full = systemPrompt && !(session && session.id) ? `[SYSTEM]\n${systemPrompt}\n\n${prompt}` : prompt;
|
|
255
|
+
// -C/--sandbox/--skip-git-repo-check 는 `codex exec` 옵션이라 `resume <id>` 토큰 *앞에* 와야 한다.
|
|
256
|
+
// (codex-cli 0.133: resume 뒤에 두면 `unexpected argument` 로 거부 → 멀티턴 전부 실패. 실측 검증됨.)
|
|
257
|
+
const base = ["exec", "--json", "--skip-git-repo-check", "-C", cwd, ...mdl, ...eff, ...sandbox, ...mcp];
|
|
258
|
+
if (session && session.id) {
|
|
259
|
+
return [...base, "resume", session.id, full];
|
|
260
|
+
}
|
|
261
|
+
return [...base, full];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function handleCodexLine(line, st, ui) {
|
|
265
|
+
let obj;
|
|
266
|
+
try {
|
|
267
|
+
obj = JSON.parse(line);
|
|
268
|
+
} catch {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
switch (obj.type) {
|
|
272
|
+
case "thread.started":
|
|
273
|
+
if (obj.thread_id) st.session.id = obj.thread_id;
|
|
274
|
+
return;
|
|
275
|
+
case "turn.started":
|
|
276
|
+
ui.status("thinking…");
|
|
277
|
+
return;
|
|
278
|
+
case "item.started":
|
|
279
|
+
case "item.updated":
|
|
280
|
+
case "item.completed": {
|
|
281
|
+
const item = obj.item || {};
|
|
282
|
+
const done = obj.type === "item.completed";
|
|
283
|
+
renderCodexItem(item, done, st, ui);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
case "turn.completed":
|
|
287
|
+
if (obj.usage) {
|
|
288
|
+
st.usage = {
|
|
289
|
+
input_tokens: obj.usage.input_tokens,
|
|
290
|
+
output_tokens: obj.usage.output_tokens,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
st.finalText = st.text;
|
|
294
|
+
return;
|
|
295
|
+
case "turn.failed":
|
|
296
|
+
case "error":
|
|
297
|
+
st.error = (obj.error && (obj.error.message || obj.error)) || "codex error";
|
|
298
|
+
ui.error(String(st.error));
|
|
299
|
+
st.errorShown = true;
|
|
300
|
+
return;
|
|
301
|
+
default:
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function renderCodexItem(item, done, st, ui) {
|
|
307
|
+
const type = item.type || "";
|
|
308
|
+
switch (type) {
|
|
309
|
+
case "agent_message": {
|
|
310
|
+
const text = item.text || "";
|
|
311
|
+
// 증분 스트리밍 (item.updated 가 누적 text를 줄 때)
|
|
312
|
+
const prev = st.itemText[item.id] || "";
|
|
313
|
+
if (text.length > prev.length) {
|
|
314
|
+
if (!prev) ui.streamStart();
|
|
315
|
+
const slice = text.slice(prev.length);
|
|
316
|
+
ui.streamDelta(slice);
|
|
317
|
+
st.itemText[item.id] = text;
|
|
318
|
+
st.text += slice; // 누적 — 한 턴에 agent_message item이 여러 개여도 합쳐서 보존
|
|
319
|
+
}
|
|
320
|
+
if (done) ui.streamEnd();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
case "reasoning": {
|
|
324
|
+
if (done && item.text) {
|
|
325
|
+
ui.line(ui.c.faint(" " + ui.c.italic(truncateLines(item.text, 3))));
|
|
326
|
+
} else {
|
|
327
|
+
ui.status("reasoning…");
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
case "command_execution":
|
|
332
|
+
case "command": {
|
|
333
|
+
if (!st.itemSeen[item.id]) {
|
|
334
|
+
ui.tool("Bash", item.command || item.cmd || "");
|
|
335
|
+
st.itemSeen[item.id] = true;
|
|
336
|
+
}
|
|
337
|
+
if (done) {
|
|
338
|
+
const out = item.aggregated_output || item.stdout || item.output || "";
|
|
339
|
+
const ok = item.exit_code == null || item.exit_code === 0;
|
|
340
|
+
if (out) ui.toolResult(out, ok);
|
|
341
|
+
else ui.toolResult(ok ? "done" : `exit ${item.exit_code}`, ok);
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
case "file_change":
|
|
346
|
+
case "patch": {
|
|
347
|
+
if (!st.itemSeen[item.id]) {
|
|
348
|
+
const files = item.changes
|
|
349
|
+
? item.changes.map((c) => c.path).join(", ")
|
|
350
|
+
: item.path || "";
|
|
351
|
+
ui.tool("Edit", files);
|
|
352
|
+
st.itemSeen[item.id] = true;
|
|
353
|
+
}
|
|
354
|
+
if (done && item.diff) ui.toolResult(item.diff, true);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
case "mcp_tool_call":
|
|
358
|
+
case "tool_call": {
|
|
359
|
+
if (!st.itemSeen[item.id]) {
|
|
360
|
+
ui.tool(item.name || item.tool || "tool", argSummary(item));
|
|
361
|
+
st.itemSeen[item.id] = true;
|
|
362
|
+
}
|
|
363
|
+
if (done && (item.result || item.output)) ui.toolResult(item.result || item.output, true);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
default:
|
|
367
|
+
// 알 수 없는 item — 우아하게 한 줄.
|
|
368
|
+
if (done && (item.text || item.summary)) {
|
|
369
|
+
ui.info((type || "item") + ": " + truncateLines(item.text || item.summary, 1));
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function argSummary(item) {
|
|
376
|
+
try {
|
|
377
|
+
const a = typeof item.arguments === "string" ? JSON.parse(item.arguments) : item.arguments;
|
|
378
|
+
return summarizeToolInput(item.name, a);
|
|
379
|
+
} catch {
|
|
380
|
+
return "";
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function truncateLines(s, n) {
|
|
384
|
+
const lines = String(s).trim().split("\n").slice(0, n);
|
|
385
|
+
return lines.join(" ").slice(0, 200);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── gemini (stream-json 구조화 렌더 — claude/codex와 동일 파리티) ──
|
|
389
|
+
// gemini-cli는 -o stream-json 으로 init/message(delta)/tool_use/tool_result/result 이벤트를 낸다(실측).
|
|
390
|
+
function geminiArgs({ prompt, systemPrompt, permission, model }) {
|
|
391
|
+
// read = 읽기전용(plan), write/full = 자동승인(yolo).
|
|
392
|
+
const approval =
|
|
393
|
+
permission === "full" || permission === "write" ? ["--yolo"] : ["--approval-mode", "plan"];
|
|
394
|
+
const mdl = model ? ["-m", model] : []; // /model parity
|
|
395
|
+
return [
|
|
396
|
+
"--output-format", "stream-json",
|
|
397
|
+
"--skip-trust", // 헤드리스: 이 세션 동안 워크스페이스 신뢰 (untrusted dir exit 55 방지)
|
|
398
|
+
...approval,
|
|
399
|
+
...mdl,
|
|
400
|
+
"--prompt", systemPrompt ? `[SYSTEM]\n${systemPrompt}\n\n${prompt}` : prompt,
|
|
401
|
+
];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// gemini 툴명 → 친숙한 표시명 (claude/codex 표기와 통일)
|
|
405
|
+
const GEMINI_TOOL_NAMES = {
|
|
406
|
+
run_shell_command: "Bash",
|
|
407
|
+
read_file: "Read",
|
|
408
|
+
read_many_files: "Read",
|
|
409
|
+
write_file: "Write",
|
|
410
|
+
replace: "Edit",
|
|
411
|
+
edit: "Edit",
|
|
412
|
+
list_directory: "List",
|
|
413
|
+
glob: "Glob",
|
|
414
|
+
search_file_content: "Grep",
|
|
415
|
+
web_fetch: "Fetch",
|
|
416
|
+
google_web_search: "Search",
|
|
417
|
+
save_memory: "Memory",
|
|
418
|
+
};
|
|
419
|
+
function prettyGeminiTool(name) {
|
|
420
|
+
return GEMINI_TOOL_NAMES[name] || name || "tool";
|
|
421
|
+
}
|
|
422
|
+
function handleGeminiLine(line, st, ui) {
|
|
423
|
+
let obj;
|
|
424
|
+
try {
|
|
425
|
+
obj = JSON.parse(line);
|
|
426
|
+
} catch {
|
|
427
|
+
return; // 비-JSON 잡음(경고 등) 무시
|
|
428
|
+
}
|
|
429
|
+
switch (obj.type) {
|
|
430
|
+
case "init":
|
|
431
|
+
if (obj.session_id) st.session.id = obj.session_id;
|
|
432
|
+
return;
|
|
433
|
+
case "tool_use": {
|
|
434
|
+
const p = obj.parameters || {};
|
|
435
|
+
const arg = p.command || summarizeToolInput(obj.tool_name, p) || p.description || "";
|
|
436
|
+
if (st.geminiStreaming) {
|
|
437
|
+
ui.streamEnd();
|
|
438
|
+
st.geminiStreaming = false;
|
|
439
|
+
}
|
|
440
|
+
ui.tool(prettyGeminiTool(obj.tool_name), arg);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
case "tool_result": {
|
|
444
|
+
const out =
|
|
445
|
+
typeof obj.output === "string"
|
|
446
|
+
? obj.output
|
|
447
|
+
: obj.output != null
|
|
448
|
+
? JSON.stringify(obj.output)
|
|
449
|
+
: "";
|
|
450
|
+
ui.toolResult(out, obj.status == null || obj.status === "success");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
case "message": {
|
|
454
|
+
if (obj.role !== "assistant") return; // user echo 스킵
|
|
455
|
+
const txt = typeof obj.content === "string" ? obj.content : "";
|
|
456
|
+
if (!txt) return;
|
|
457
|
+
if (!st.geminiStreaming) {
|
|
458
|
+
ui.streamStart();
|
|
459
|
+
st.geminiStreaming = true;
|
|
460
|
+
}
|
|
461
|
+
ui.streamDelta(txt);
|
|
462
|
+
st.text += txt;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
case "result": {
|
|
466
|
+
if (st.geminiStreaming) {
|
|
467
|
+
ui.streamEnd();
|
|
468
|
+
st.geminiStreaming = false;
|
|
469
|
+
}
|
|
470
|
+
const s = obj.stats || {};
|
|
471
|
+
st.usage = {
|
|
472
|
+
input_tokens: s.input_tokens,
|
|
473
|
+
output_tokens: s.output_tokens,
|
|
474
|
+
duration_ms: s.duration_ms,
|
|
475
|
+
};
|
|
476
|
+
st.finalText = st.text;
|
|
477
|
+
if (obj.status && obj.status !== "success") st.error = `gemini ${obj.status}`;
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
default:
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── 공통 실행기 ───────────────────────────────────────────
|
|
486
|
+
// req = { kind, bin, prompt, systemPrompt, cwd, permission, session, ui, env, signal }
|
|
487
|
+
// 반환: Promise<{ text, session, usage, error }>
|
|
488
|
+
function runNativeTurn(req) {
|
|
489
|
+
const { kind, bin, ui } = req;
|
|
490
|
+
const cwd = req.cwd;
|
|
491
|
+
const st = {
|
|
492
|
+
text: "",
|
|
493
|
+
finalText: "",
|
|
494
|
+
usage: null,
|
|
495
|
+
error: null,
|
|
496
|
+
session: req.session || {},
|
|
497
|
+
tools: {},
|
|
498
|
+
think: {},
|
|
499
|
+
geminiStreaming: false,
|
|
500
|
+
itemText: {},
|
|
501
|
+
itemSeen: {},
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
let args;
|
|
505
|
+
let lineHandler;
|
|
506
|
+
let plainStream = false;
|
|
507
|
+
if (kind === "claude-code") {
|
|
508
|
+
args = claudeArgs(req);
|
|
509
|
+
lineHandler = (l) => handleClaudeLine(l, st, ui);
|
|
510
|
+
} else if (kind === "codex") {
|
|
511
|
+
args = codexArgs({ ...req, cwd });
|
|
512
|
+
lineHandler = (l) => handleCodexLine(l, st, ui);
|
|
513
|
+
} else if (kind === "gemini") {
|
|
514
|
+
args = geminiArgs(req);
|
|
515
|
+
lineHandler = (l) => handleGeminiLine(l, st, ui);
|
|
516
|
+
} else {
|
|
517
|
+
return Promise.resolve({ text: "", session: st.session, error: `unknown runtime: ${kind}` });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return new Promise((resolve) => {
|
|
521
|
+
ui.status(`starting ${kind === "claude-code" ? "claude" : kind}…`);
|
|
522
|
+
let child;
|
|
523
|
+
try {
|
|
524
|
+
child = spawn(bin, args, {
|
|
525
|
+
cwd,
|
|
526
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
527
|
+
env: req.env || process.env,
|
|
528
|
+
});
|
|
529
|
+
} catch (e) {
|
|
530
|
+
ui.error(`failed to run ${kind}: ${e.message}`);
|
|
531
|
+
return resolve({ text: "", session: st.session, error: e.message });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Ctrl-C → 자식 종료
|
|
535
|
+
const onAbort = () => {
|
|
536
|
+
try {
|
|
537
|
+
child.kill("SIGTERM");
|
|
538
|
+
} catch {
|
|
539
|
+
/* ignore */
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
if (req.signal) {
|
|
543
|
+
if (req.signal.aborted) onAbort();
|
|
544
|
+
else req.signal.addEventListener("abort", onAbort, { once: true });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (plainStream) {
|
|
548
|
+
let plainStarted = false;
|
|
549
|
+
lineReader(child.stdout, (l) => {
|
|
550
|
+
if (!plainStarted) {
|
|
551
|
+
ui.streamStart();
|
|
552
|
+
plainStarted = true;
|
|
553
|
+
}
|
|
554
|
+
ui.streamDelta(l + "\n");
|
|
555
|
+
st.text += l + "\n";
|
|
556
|
+
});
|
|
557
|
+
} else {
|
|
558
|
+
lineReader(child.stdout, lineHandler);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
let stderrBuf = "";
|
|
562
|
+
child.stderr.setEncoding("utf8");
|
|
563
|
+
child.stderr.on("data", (d) => {
|
|
564
|
+
stderrBuf += d;
|
|
565
|
+
if (stderrBuf.length > 4000) stderrBuf = stderrBuf.slice(-4000);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
child.on("error", (err) => {
|
|
569
|
+
ui.stopSpinner();
|
|
570
|
+
ui.error(`failed to run ${kind}: ${err.message}`);
|
|
571
|
+
resolve({ text: "", session: st.session, error: err.message });
|
|
572
|
+
});
|
|
573
|
+
child.on("close", (code) => {
|
|
574
|
+
if (req.signal) req.signal.removeEventListener?.("abort", onAbort);
|
|
575
|
+
ui.streamEnd();
|
|
576
|
+
ui.stopSpinner();
|
|
577
|
+
const text = (st.finalText || st.text || "").trim();
|
|
578
|
+
const aborted = req.signal && req.signal.aborted;
|
|
579
|
+
const errTail = stripAnsi(stderrBuf).replace(/\s+/g, " ").trim(); // ANSI 제거 + 한 줄로
|
|
580
|
+
if (st.error && !st.errorShown) {
|
|
581
|
+
// claude `result` is_error 등 — 이전에 표시되지 않은 에러를 노출
|
|
582
|
+
ui.error(String(st.error));
|
|
583
|
+
} else if (code !== 0 && !text && !aborted) {
|
|
584
|
+
// Runtime Doctor — 아는 시스템 원인(미인증 OAuth MCP 플러그인 등)이면 즉시 수리하고
|
|
585
|
+
// 1회 자동 재시도한다(2026-07-08 notion@openai-curated가 codex 전멸시킨 사고).
|
|
586
|
+
if (!req._doctorRetried) {
|
|
587
|
+
try {
|
|
588
|
+
const { runRuntimeDoctor } = require("./agentlas-doctor.cjs");
|
|
589
|
+
const report = runRuntimeDoctor(`${kind} exited with code ${code}\n${stripAnsi(stderrBuf)}`);
|
|
590
|
+
if (report.repaired) {
|
|
591
|
+
ui.warn(`🩺 Runtime Doctor: ${report.summary}`);
|
|
592
|
+
for (const act of report.actions) ui.warn(` 🔧 ${act.title} — ${act.detail}`);
|
|
593
|
+
ui.warn(" 자동 수리 완료 — 같은 요청을 다시 시도합니다.");
|
|
594
|
+
resolve(runNativeTurn({ ...req, _doctorRetried: true }));
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
} catch {
|
|
598
|
+
/* 닥터 실패는 원래 에러 표출을 막지 않는다 */
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
ui.error(`${kind} exited with code ${code}` + (errTail ? `\n ${errTail.slice(-400)}` : ""));
|
|
602
|
+
} else if (!text && !st.error && !aborted) {
|
|
603
|
+
// 정상 종료인데 출력이 비어 있음(거부/차단 등) — 무음 실패 방지
|
|
604
|
+
ui.warn(`${kind}: no output` + (errTail ? ` (${errTail.slice(-200)})` : ""));
|
|
605
|
+
}
|
|
606
|
+
if (st.usage) ui.cost(st.usage);
|
|
607
|
+
resolve({ text, session: st.session, usage: st.usage, error: st.error });
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
module.exports = { runNativeTurn, summarizeToolInput, claudeArgs, codexArgs };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* First-run onboarding wizard (openclaw-style): language → default runtime → default permission.
|
|
4
|
+
* Runs once; result is saved to cli-prefs.json. Re-run anytime with `agentlas setup`.
|
|
5
|
+
*/
|
|
6
|
+
const i18n = require("./agentlas-i18n.cjs");
|
|
7
|
+
const banner = require("./agentlas-banner.cjs");
|
|
8
|
+
|
|
9
|
+
// req = { ui, rl, helpers } → Promise<{ onboarded, lang, runtime, permission }>
|
|
10
|
+
async function runOnboard({ ui, rl, helpers }) {
|
|
11
|
+
const H = helpers;
|
|
12
|
+
const c = ui.c;
|
|
13
|
+
const ask = (q) => new Promise((res) => rl.question(q, (a) => res((a || "").trim())));
|
|
14
|
+
const pickNum = async (n) => {
|
|
15
|
+
// loop until a valid 1..n number; empty → 1 (first option as default)
|
|
16
|
+
for (;;) {
|
|
17
|
+
const a = await ask(" " + c.emerald(ui.t("wiz.pick")));
|
|
18
|
+
if (a === "") return 1;
|
|
19
|
+
if (/^\d+$/.test(a)) {
|
|
20
|
+
const i = parseInt(a, 10);
|
|
21
|
+
if (i >= 1 && i <= n) return i;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// small mascot + header
|
|
27
|
+
ui.line("");
|
|
28
|
+
banner.renderMascot(ui);
|
|
29
|
+
ui.line(" " + c.bold(c.emerald("Agentlas")) + c.dim(" · setup"));
|
|
30
|
+
ui.line(" " + c.dim(ui.t("wiz.welcome")));
|
|
31
|
+
|
|
32
|
+
// Step 1 — language
|
|
33
|
+
ui.line("");
|
|
34
|
+
ui.line(" " + c.bold(ui.t("wiz.langQ")));
|
|
35
|
+
i18n.LANGS.forEach((l, i) => ui.line(" " + c.faint(String(i + 1)) + " " + c.text(l.label)));
|
|
36
|
+
const li = await pickNum(i18n.LANGS.length);
|
|
37
|
+
const lang = i18n.LANGS[li - 1].code;
|
|
38
|
+
ui.lang = lang; // localize the rest of the wizard
|
|
39
|
+
|
|
40
|
+
// Step 2 — default runtime
|
|
41
|
+
ui.line("");
|
|
42
|
+
ui.line(" " + c.bold(ui.t("wiz.runtimeQ")));
|
|
43
|
+
const cliKinds = ["claude-code", "codex", "gemini"];
|
|
44
|
+
const rtOpts = [{ value: "auto", label: ui.t("wiz.runtimeAuto") }];
|
|
45
|
+
for (const k of cliKinds) {
|
|
46
|
+
const has = !!H.which(H.RUNTIME_BIN[k]);
|
|
47
|
+
rtOpts.push({ value: k, label: `${k} (${has ? ui.t("wiz.runtimeInstalled") : ui.t("wiz.runtimeMissing")})` });
|
|
48
|
+
}
|
|
49
|
+
rtOpts.forEach((o, i) => ui.line(" " + c.faint(String(i + 1)) + " " + c.text(o.label)));
|
|
50
|
+
const ri = await pickNum(rtOpts.length);
|
|
51
|
+
const runtime = rtOpts[ri - 1].value;
|
|
52
|
+
|
|
53
|
+
// Step 3 — default permission
|
|
54
|
+
ui.line("");
|
|
55
|
+
ui.line(" " + c.bold(ui.t("wiz.permQ")));
|
|
56
|
+
const permOpts = [
|
|
57
|
+
{ v: "read", l: ui.t("wiz.permRead") },
|
|
58
|
+
{ v: "write", l: ui.t("wiz.permWrite") },
|
|
59
|
+
{ v: "full", l: ui.t("wiz.permFull") },
|
|
60
|
+
];
|
|
61
|
+
permOpts.forEach((o, i) => ui.line(" " + c.faint(String(i + 1)) + " " + c.text(o.l)));
|
|
62
|
+
const pi = await pickNum(permOpts.length);
|
|
63
|
+
const permission = permOpts[pi - 1].v;
|
|
64
|
+
|
|
65
|
+
ui.line("");
|
|
66
|
+
ui.ok(ui.t("wiz.saved"));
|
|
67
|
+
ui.line(" " + c.faint(ui.t("wiz.changeLang")));
|
|
68
|
+
return { onboarded: true, lang, runtime, permission };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { runOnboard };
|