agentlas 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +48 -6
  2. package/bin/agentlas.cjs +55 -8
  3. package/engine/agentlas-api-agent.cjs +1 -1
  4. package/engine/agentlas-banner.cjs +40 -56
  5. package/engine/agentlas-capabilities.cjs +3 -0
  6. package/engine/agentlas-cloud-runtime.cjs +65 -11
  7. package/engine/agentlas-composer.cjs +112 -44
  8. package/engine/agentlas-doctor.cjs +40 -12
  9. package/engine/agentlas-i18n.cjs +136 -12
  10. package/engine/agentlas-input.cjs +118 -19
  11. package/engine/agentlas-native-host.cjs +381 -83
  12. package/engine/agentlas-parity.cjs +315 -45
  13. package/engine/agentlas-permissions.cjs +90 -0
  14. package/engine/agentlas-repl.cjs +239 -70
  15. package/engine/agentlas-tasks.cjs +111 -0
  16. package/engine/agentlas-tools.cjs +174 -12
  17. package/engine/agentlas-ui.cjs +352 -23
  18. package/engine/agentlas.cjs +2819 -351
  19. package/engine/semver.cjs +64 -0
  20. package/package.json +1 -1
  21. package/test/bootstrap-race.cjs +47 -0
  22. package/test/capture-runtime-guard.cjs +122 -0
  23. package/test/cloud-asset-restore.cjs +423 -0
  24. package/test/cloud-cas-client.cjs +333 -0
  25. package/test/cloud-owner-restore.cjs +183 -0
  26. package/test/cloud-runtime-paths.cjs +40 -0
  27. package/test/cloud-save-publish.cjs +453 -0
  28. package/test/credential-env-regression.cjs +52 -0
  29. package/test/login-loopback-security.cjs +115 -0
  30. package/test/mcp-config-isolation.cjs +36 -0
  31. package/test/permission-mapping.cjs +180 -0
  32. package/test/route-regression.cjs +121 -0
  33. package/test/run-api-regression.cjs +322 -0
  34. package/test/runtime-env-protection.cjs +45 -0
  35. package/test/semver-precedence.cjs +39 -0
  36. package/test/smoke.sh +20 -0
  37. package/test/sqlite-driver-probe.cjs +22 -0
  38. package/test/terminal-ui-regression.cjs +472 -0
  39. package/test/timeout-regression.cjs +218 -0
  40. package/test/tool-workspace-boundary.cjs +165 -0
  41. package/test/update-safety.cjs +376 -0
@@ -8,22 +8,152 @@
8
8
  * (멀티턴은 --resume <session_id>)
9
9
  * - codex: codex exec --json --skip-git-repo-check -C <cwd> [sandbox] <prompt>
10
10
  * (멀티턴은 codex exec resume <thread_id> ...)
11
- * - gemini: gemini -p <system+prompt> [--yolo] (stdout 평문 스트리밍)
11
+ * - gemini: gemini -p <system+prompt> --approval-mode <mode>
12
12
  *
13
13
  * 스키마는 실측으로 확인됨 (cli/agentlas.cjs 상단 주석 참고).
14
14
  */
15
15
  const { spawn } = require("node:child_process");
16
+ const crypto = require("node:crypto");
16
17
  const fs = require("node:fs");
17
18
  const os = require("node:os");
18
19
  const path = require("node:path");
20
+ const permissions = require("./agentlas-permissions.cjs");
21
+ const i18n = require("./agentlas-i18n.cjs");
19
22
 
20
- function userDataDir() {
21
- const override = process.env.AGENTLAS_USER_DATA_DIR;
23
+ function uiText(ui, key, ...args) {
24
+ return ui && typeof ui.t === "function" ? ui.t(key, ...args) : i18n.t("en", key, ...args);
25
+ }
26
+
27
+ const NATIVE_TIMEOUT_DEFAULTS = Object.freeze({
28
+ idleMs: 10 * 60_000,
29
+ totalMs: 4 * 60 * 60_000,
30
+ killGraceMs: 3_000,
31
+ });
32
+
33
+ function finiteTimeoutMs(value, fallback, min, max) {
34
+ const parsed = Number(value);
35
+ if (!Number.isFinite(parsed)) return fallback;
36
+ return Math.min(max, Math.max(min, Math.trunc(parsed)));
37
+ }
38
+
39
+ function nativeTimeoutConfig(env = process.env) {
40
+ const totalMs = finiteTimeoutMs(env.AGENTLAS_NATIVE_TOTAL_TIMEOUT_MS, NATIVE_TIMEOUT_DEFAULTS.totalMs, 30_000, 12 * 60 * 60_000);
41
+ return {
42
+ idleMs: Math.min(totalMs, finiteTimeoutMs(env.AGENTLAS_NATIVE_IDLE_TIMEOUT_MS, NATIVE_TIMEOUT_DEFAULTS.idleMs, 5_000, 60 * 60_000)),
43
+ totalMs,
44
+ killGraceMs: finiteTimeoutMs(env.AGENTLAS_NATIVE_KILL_GRACE_MS, NATIVE_TIMEOUT_DEFAULTS.killGraceMs, 100, 15_000),
45
+ };
46
+ }
47
+
48
+ // Programmatic override is used by the deterministic regression harness; user env always uses the safer bounds above.
49
+ function directNativeTimeoutConfig(value = {}) {
50
+ const totalMs = finiteTimeoutMs(value.totalMs, NATIVE_TIMEOUT_DEFAULTS.totalMs, 10, 12 * 60 * 60_000);
51
+ return {
52
+ idleMs: Math.min(totalMs, finiteTimeoutMs(value.idleMs, NATIVE_TIMEOUT_DEFAULTS.idleMs, 10, 60 * 60_000)),
53
+ totalMs,
54
+ killGraceMs: finiteTimeoutMs(value.killGraceMs, NATIVE_TIMEOUT_DEFAULTS.killGraceMs, 10, 15_000),
55
+ };
56
+ }
57
+
58
+ function nativeTimeoutMessage(kind, ms) {
59
+ return kind === "idle"
60
+ ? `native runtime idle timeout: ${ms}ms 동안 출력이 없습니다.`
61
+ : `native runtime total timeout: 전체 실행 시간이 ${ms}ms를 초과했습니다.`;
62
+ }
63
+
64
+ function userDataDir(env = process.env) {
65
+ const override = env.AGENTLAS_USER_DATA_DIR;
22
66
  if (override) return override;
23
67
  const home = os.homedir();
24
68
  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");
69
+ if (process.platform === "win32") return path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "Agentlas");
70
+ return path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "Agentlas");
71
+ }
72
+
73
+ const EMPTY_CLAUDE_MCP_CONFIG = '{"mcpServers":{}}';
74
+
75
+ function claudeMcpIsolationArgs() {
76
+ return ["--strict-mcp-config", "--mcp-config", EMPTY_CLAUDE_MCP_CONFIG];
77
+ }
78
+
79
+ function geminiMcpIsolationArgs() {
80
+ // Gemini treats a non-empty allow-list as exclusive. A per-turn random name
81
+ // cannot match a configured or extension-provided server, so read/write gets none.
82
+ return ["--allowed-mcp-server-names", `__agentlas_no_mcp_${crypto.randomUUID()}__`];
83
+ }
84
+
85
+ function writeManagedFile(file, content) {
86
+ const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
87
+ try {
88
+ fs.writeFileSync(temp, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
89
+ try {
90
+ fs.renameSync(temp, file);
91
+ } catch (error) {
92
+ // Windows cannot always replace an existing destination atomically.
93
+ if (!error || !["EEXIST", "EPERM"].includes(error.code)) throw error;
94
+ fs.rmSync(file, { force: true });
95
+ fs.renameSync(temp, file);
96
+ }
97
+ } finally {
98
+ try { fs.rmSync(temp, { force: true }); } catch { /* best-effort cleanup */ }
99
+ }
100
+ try { fs.chmodSync(file, 0o600); } catch { /* Windows/best-effort */ }
101
+ }
102
+
103
+ function prepareCodexRuntimeEnv(env = process.env) {
104
+ const base = { ...env };
105
+ const sourceHome = path.resolve(base.CODEX_HOME || path.join(os.homedir(), ".codex"));
106
+ const dataHome = path.resolve(userDataDir(base));
107
+ const explicitTarget = Boolean(base.AGENTLAS_CODEX_HOME);
108
+ const targetHome = path.resolve(base.AGENTLAS_CODEX_HOME || path.join(dataHome, "runtime-homes", "codex"));
109
+ fs.mkdirSync(dataHome, { recursive: true, mode: 0o700 });
110
+ fs.mkdirSync(targetHome, { recursive: true, mode: 0o700 });
111
+ try { fs.chmodSync(targetHome, 0o700); } catch { /* Windows/best-effort */ }
112
+ const realDataHome = fs.realpathSync(dataHome);
113
+ const realTargetHome = fs.realpathSync(targetHome);
114
+ let realSourceHome = sourceHome;
115
+ try { realSourceHome = fs.realpathSync(sourceHome); } catch (error) { if (!error || error.code !== "ENOENT") throw error; }
116
+ const targetRelative = path.relative(realDataHome, realTargetHome);
117
+ if (!explicitTarget && (path.isAbsolute(targetRelative) || targetRelative === ".." || targetRelative.startsWith(`..${path.sep}`))) {
118
+ throw new Error("Agentlas Codex runtime home escapes the Agentlas data directory");
119
+ }
120
+ if (realTargetHome === realSourceHome) {
121
+ throw new Error("Agentlas Codex runtime home must be isolated from the user's Codex home");
122
+ }
123
+
124
+ // CODEX_HOME has no replace-config CLI flag: profiles and `mcp_servers={}`
125
+ // merge with the user's global config. A dedicated home is the only reliable
126
+ // way to exclude global/project/plugin MCP while keeping Agentlas sessions.
127
+ writeManagedFile(
128
+ path.join(targetHome, "config.toml"),
129
+ "# Managed by Agentlas Terminal. MCP is supplied only for explicit full-access turns.\n",
130
+ );
131
+
132
+ if (sourceHome !== targetHome) {
133
+ const sourceAuth = path.join(sourceHome, "auth.json");
134
+ const targetAuth = path.join(realTargetHome, "auth.json");
135
+ let targetExists = false;
136
+ try { fs.lstatSync(targetAuth); targetExists = true; } catch (error) { if (!error || error.code !== "ENOENT") throw error; }
137
+ if (!targetExists && fs.existsSync(sourceAuth)) {
138
+ try {
139
+ fs.symlinkSync(sourceAuth, targetAuth, "file");
140
+ } catch {
141
+ try {
142
+ fs.linkSync(sourceAuth, targetAuth);
143
+ } catch {
144
+ fs.copyFileSync(sourceAuth, targetAuth, fs.constants.COPYFILE_EXCL);
145
+ try { fs.chmodSync(targetAuth, 0o600); } catch { /* Windows/best-effort */ }
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ base.CODEX_HOME = realTargetHome;
152
+ return base;
153
+ }
154
+
155
+ function runtimeEnvForKind(kind, env = process.env) {
156
+ return kind === "codex" ? prepareCodexRuntimeEnv(env) : env;
27
157
  }
28
158
 
29
159
  // MCP 서버 이름 → TOML/JSON 안전 키 (하이픈/공백 → _).
@@ -33,20 +163,35 @@ function mcpKey(s) {
33
163
  function mcpStdioArgs(s) {
34
164
  try { return JSON.parse((s && s.args_json) || "[]"); } catch { return []; }
35
165
  }
36
- // claude --mcp-config 파일을 쓴다. playwright(항상) + DB에 enabled stdio MCP 서버들.
166
+ // Full-access turns only: claude --mcp-config with Playwright + enabled DB stdio servers.
37
167
  function cliMcpConfigPath(servers) {
38
168
  const dir = path.join(userDataDir(), "mcp");
39
- fs.mkdirSync(dir, { recursive: true });
40
- const file = path.join(dir, "agentlas-cli-mcp.json");
169
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
170
+ try { fs.chmodSync(dir, 0o700); } catch { /* Windows/best-effort */ }
41
171
  const mcpServers = { playwright: { command: "npx", args: ["-y", "@playwright/mcp@latest"] } };
42
172
  for (const s of servers || []) {
43
173
  if (!s || s.enabled === 0 || s.transport !== "stdio" || !s.command) continue;
44
174
  mcpServers[mcpKey(s)] = { command: s.command, args: mcpStdioArgs(s) };
45
175
  }
46
- fs.writeFileSync(file, JSON.stringify({ mcpServers }, null, 2), "utf8");
176
+ const body = JSON.stringify({ mcpServers }, null, 2);
177
+ // 서로 다른 동시 실행이 하나의 agentlas-cli-mcp.json을 덮어쓰지 않도록 내용 주소 파일을 쓴다.
178
+ const digest = crypto.createHash("sha256").update(body).digest("hex").slice(0, 20);
179
+ const file = path.join(dir, `agentlas-cli-mcp-${digest}.json`);
180
+ let current = null;
181
+ try { current = fs.readFileSync(file, "utf8"); } catch { /* first write */ }
182
+ if (current !== body) {
183
+ const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
184
+ try {
185
+ fs.writeFileSync(temp, body, { encoding: "utf8", mode: 0o600, flag: "wx" });
186
+ fs.renameSync(temp, file);
187
+ } finally {
188
+ try { fs.rmSync(temp, { force: true }); } catch { /* noop */ }
189
+ }
190
+ }
191
+ try { fs.chmodSync(file, 0o600); } catch { /* Windows/best-effort */ }
47
192
  return { file, names: Object.keys(mcpServers) };
48
193
  }
49
- // codex -c mcp_servers.<key>.command/args playwright(항상) + DB stdio 서버들.
194
+ // Full-access turns only: codex -c mcp_servers.* with Playwright + enabled DB stdio servers.
50
195
  function codexMcpArgs(servers) {
51
196
  const out = [
52
197
  "-c", 'mcp_servers.playwright.command="npx"',
@@ -83,11 +228,12 @@ function summarizeToolInput(name, input) {
83
228
  );
84
229
  }
85
230
 
86
- // child.stdout → 줄 단위 콜백. 종료 시 잔여 버퍼 flush.
87
- function lineReader(stream, onLine) {
231
+ // child.stdout → 줄 단위 콜백. 종료 시 잔여 버퍼 flush. cleanup 반환.
232
+ function lineReader(stream, onLine, onActivity) {
88
233
  let buf = "";
89
234
  stream.setEncoding("utf8");
90
- stream.on("data", (chunk) => {
235
+ const onData = (chunk) => {
236
+ if (onActivity) onActivity();
91
237
  buf += chunk;
92
238
  let nl;
93
239
  while ((nl = buf.indexOf("\n")) >= 0) {
@@ -95,20 +241,44 @@ function lineReader(stream, onLine) {
95
241
  buf = buf.slice(nl + 1);
96
242
  if (line.trim()) onLine(line);
97
243
  }
98
- });
99
- stream.on("end", () => {
244
+ };
245
+ const onEnd = () => {
100
246
  if (buf.trim()) onLine(buf);
101
- });
247
+ };
248
+ stream.on("data", onData);
249
+ stream.on("end", onEnd);
250
+ return () => {
251
+ stream.removeListener("data", onData);
252
+ stream.removeListener("end", onEnd);
253
+ };
254
+ }
255
+
256
+ function structuredToolResult(content, fallbackText) {
257
+ if (content && typeof content === "object" && !Array.isArray(content)) return content;
258
+ if (Array.isArray(content)) {
259
+ for (const block of content) {
260
+ if (!block || typeof block !== "object") continue;
261
+ if (block.json && typeof block.json === "object") return block.json;
262
+ if (block.content && typeof block.content === "object" && !Array.isArray(block.content)) return block.content;
263
+ }
264
+ }
265
+ const text = String(fallbackText || "").trim();
266
+ if (!text || text.length > 256 * 1024) return null;
267
+ const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(text);
268
+ try { return JSON.parse(fenced ? fenced[1] : text); } catch { return null; }
102
269
  }
103
270
 
104
271
  // ── claude-code ──────────────────────────────────────────
272
+ function claudePermissionArgs(permission) {
273
+ const level = permissions.normalize(permission);
274
+ if (level === "full") return ["--dangerously-skip-permissions"];
275
+ if (level === "write") return ["--permission-mode", "acceptEdits"];
276
+ return ["--permission-mode", "plan"];
277
+ }
278
+
105
279
  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
- : [];
280
+ const level = permissions.normalize(permission);
281
+ const perm = claudePermissionArgs(level);
112
282
  // /effort → Claude Code는 think 키워드로 reasoning 예산을 올린다(전용 CLI 플래그 없음).
113
283
  const thinkKw =
114
284
  effort === "max" ? "Ultrathink. " : effort === "high" ? "Think hard. " : effort === "medium" ? "Think. " : "";
@@ -121,9 +291,13 @@ function claudeArgs({ prompt, systemPrompt, permission, session, model, effort,
121
291
  "--verbose",
122
292
  ...perm,
123
293
  ];
124
- if (permission === "write" || permission === "full") {
294
+ // MCP tools can mutate state outside the workspace sandbox. Until the desktop schema
295
+ // carries a trustworthy readOnlyHint per tool, only explicit full access may inject them.
296
+ if (level === "full") {
125
297
  const mcpCfg = cliMcpConfigPath(mcpServers);
126
- args.push("--mcp-config", mcpCfg.file, "--allowedTools", mcpCfg.names.map((n) => "mcp__" + n).join(","));
298
+ args.push("--strict-mcp-config", "--mcp-config", mcpCfg.file, "--allowedTools", mcpCfg.names.map((n) => "mcp__" + n).join(","));
299
+ } else {
300
+ args.push(...claudeMcpIsolationArgs());
127
301
  }
128
302
  if (model) args.push("--model", model); // alias (sonnet/opus) or full id — /model parity
129
303
  if (session && session.id) {
@@ -157,11 +331,13 @@ function handleClaudeLine(line, st, ui) {
157
331
  if (ev.type === "content_block_start") {
158
332
  const cb = ev.content_block || {};
159
333
  if (cb.type === "tool_use") {
160
- st.tools[ev.index] = { name: cb.name || "tool", input: "" };
334
+ const tool = { id: cb.id || String(ev.index), name: cb.name || "tool", input: "" };
335
+ st.tools[ev.index] = tool;
336
+ st.toolById[tool.id] = tool;
161
337
  // 인자가 다 모이는 content_block_stop에서 한 줄로(⏺ Name(arg)) 출력 — Claude Code 스타일
162
338
  } else if (cb.type === "thinking") {
163
339
  st.think[ev.index] = "";
164
- ui.status("thinking");
340
+ ui.status(uiText(ui, "runtime.thinking"));
165
341
  } else if (cb.type === "text") {
166
342
  ui.streamStart();
167
343
  }
@@ -184,7 +360,9 @@ function handleClaudeLine(line, st, ui) {
184
360
  } catch {
185
361
  parsed = null;
186
362
  }
363
+ ui.applyTaskTool?.(t.name, parsed, t.id);
187
364
  ui.tool(prettyToolName(t.name), summarizeToolInput(t.name, parsed));
365
+ delete st.tools[ev.index];
188
366
  } else if (st.think[ev.index] != null) {
189
367
  const th = String(st.think[ev.index] || "").trim();
190
368
  if (th) ui.line(ui.c.faint(" " + ui.c.italic(truncateLines(th, 3))));
@@ -198,6 +376,7 @@ function handleClaudeLine(line, st, ui) {
198
376
  case "user": {
199
377
  // tool_result 들
200
378
  const content = obj.message && obj.message.content;
379
+ const outerToolResult = obj.toolUseResult ?? obj.tool_use_result ?? obj.message?.toolUseResult ?? obj.message?.tool_use_result;
201
380
  if (Array.isArray(content)) {
202
381
  for (const block of content) {
203
382
  if (block.type === "tool_result") {
@@ -206,6 +385,12 @@ function handleClaudeLine(line, st, ui) {
206
385
  : typeof block.content === "string"
207
386
  ? block.content
208
387
  : "";
388
+ const taskTool = st.toolById[block.tool_use_id];
389
+ if (taskTool) {
390
+ const structured = structuredToolResult(block.content, txt) || structuredToolResult(outerToolResult, "");
391
+ ui.applyTaskResult?.(taskTool.name, structured, taskTool.id);
392
+ delete st.toolById[block.tool_use_id];
393
+ }
209
394
  ui.toolResult(txt, !block.is_error);
210
395
  }
211
396
  }
@@ -224,7 +409,7 @@ function handleClaudeLine(line, st, ui) {
224
409
  return;
225
410
  case "rate_limit_event":
226
411
  if (obj.rate_limit_info && obj.rate_limit_info.status === "rejected") {
227
- ui.warn("claude rate limit reached");
412
+ ui.warn(uiText(ui, "runtime.rateLimit", "Claude"));
228
413
  }
229
414
  return;
230
415
  default:
@@ -241,13 +426,17 @@ function prettyToolName(name) {
241
426
  }
242
427
 
243
428
  // ── codex ────────────────────────────────────────────────
429
+ function codexPermissionArgs(permission) {
430
+ const level = permissions.normalize(permission);
431
+ if (level === "full") return ["--dangerously-bypass-approvals-and-sandbox"];
432
+ // `codex exec` has no -a flag. The installed CLI accepts approval_policy via -c.
433
+ return ["--sandbox", level === "write" ? "workspace-write" : "read-only", "-c", 'approval_policy="never"'];
434
+ }
435
+
244
436
  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) : [];
437
+ const level = permissions.normalize(permission);
438
+ const sandbox = codexPermissionArgs(level);
439
+ const mcp = level === "full" ? codexMcpArgs(mcpServers) : [];
251
440
  const mdl = model ? ["-m", model] : []; // /model parity
252
441
  // /effort parity → codex reasoning effort (low|medium|high). max는 high로 매핑.
253
442
  const eff = effort ? ["-c", `model_reasoning_effort="${effort === "max" ? "high" : effort}"`] : [];
@@ -273,7 +462,7 @@ function handleCodexLine(line, st, ui) {
273
462
  if (obj.thread_id) st.session.id = obj.thread_id;
274
463
  return;
275
464
  case "turn.started":
276
- ui.status("thinking");
465
+ ui.status(uiText(ui, "runtime.thinking"));
277
466
  return;
278
467
  case "item.started":
279
468
  case "item.updated":
@@ -324,7 +513,7 @@ function renderCodexItem(item, done, st, ui) {
324
513
  if (done && item.text) {
325
514
  ui.line(ui.c.faint(" " + ui.c.italic(truncateLines(item.text, 3))));
326
515
  } else {
327
- ui.status("reasoning");
516
+ ui.status(uiText(ui, "runtime.reasoning"));
328
517
  }
329
518
  return;
330
519
  }
@@ -363,6 +552,12 @@ function renderCodexItem(item, done, st, ui) {
363
552
  if (done && (item.result || item.output)) ui.toolResult(item.result || item.output, true);
364
553
  return;
365
554
  }
555
+ case "todo_list": {
556
+ // Codex 0.144 JSONL exposes actual plan state as a todo_list item. Keep this
557
+ // separate from ordinary Bash/Edit activity so Ctrl-T never invents tasks.
558
+ ui.replaceTasks?.(item, "codex");
559
+ return;
560
+ }
366
561
  default:
367
562
  // 알 수 없는 item — 우아하게 한 줄.
368
563
  if (done && (item.text || item.summary)) {
@@ -387,15 +582,22 @@ function truncateLines(s, n) {
387
582
 
388
583
  // ── gemini (stream-json 구조화 렌더 — claude/codex와 동일 파리티) ──
389
584
  // gemini-cli는 -o stream-json 으로 init/message(delta)/tool_use/tool_result/result 이벤트를 낸다(실측).
585
+ function geminiPermissionArgs(permission) {
586
+ const level = permissions.normalize(permission);
587
+ const approvalMode = level === "full" ? "yolo" : level === "write" ? "auto_edit" : "plan";
588
+ return ["--approval-mode", approvalMode];
589
+ }
590
+
390
591
  function geminiArgs({ prompt, systemPrompt, permission, model }) {
391
- // read = 읽기전용(plan), write/full = 자동승인(yolo).
392
- const approval =
393
- permission === "full" || permission === "write" ? ["--yolo"] : ["--approval-mode", "plan"];
592
+ const level = permissions.normalize(permission);
593
+ // Gemini CLI 0.50 exposes three matching modes: plan, auto_edit, and yolo.
594
+ const approval = geminiPermissionArgs(level);
394
595
  const mdl = model ? ["-m", model] : []; // /model parity
395
596
  return [
396
597
  "--output-format", "stream-json",
397
598
  "--skip-trust", // 헤드리스: 이 세션 동안 워크스페이스 신뢰 (untrusted dir exit 55 방지)
398
599
  ...approval,
600
+ ...(level === "full" ? [] : geminiMcpIsolationArgs()),
399
601
  ...mdl,
400
602
  "--prompt", systemPrompt ? `[SYSTEM]\n${systemPrompt}\n\n${prompt}` : prompt,
401
603
  ];
@@ -437,6 +639,7 @@ function handleGeminiLine(line, st, ui) {
437
639
  ui.streamEnd();
438
640
  st.geminiStreaming = false;
439
641
  }
642
+ ui.applyTaskTool?.(obj.tool_name, p, obj.tool_id || obj.id);
440
643
  ui.tool(prettyGeminiTool(obj.tool_name), arg);
441
644
  return;
442
645
  }
@@ -495,6 +698,7 @@ function runNativeTurn(req) {
495
698
  error: null,
496
699
  session: req.session || {},
497
700
  tools: {},
701
+ toolById: {},
498
702
  think: {},
499
703
  geminiStreaming: false,
500
704
  itemText: {},
@@ -504,83 +708,151 @@ function runNativeTurn(req) {
504
708
  let args;
505
709
  let lineHandler;
506
710
  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}` });
711
+ try {
712
+ if (kind === "claude-code") {
713
+ args = claudeArgs(req);
714
+ lineHandler = (l) => handleClaudeLine(l, st, ui);
715
+ } else if (kind === "codex") {
716
+ args = codexArgs({ ...req, cwd });
717
+ lineHandler = (l) => handleCodexLine(l, st, ui);
718
+ } else if (kind === "gemini") {
719
+ args = geminiArgs(req);
720
+ lineHandler = (l) => handleGeminiLine(l, st, ui);
721
+ } else {
722
+ return Promise.resolve({ text: "", session: st.session, error: `unknown runtime: ${kind}` });
723
+ }
724
+ } catch (error) {
725
+ const message = error && error.message ? error.message : String(error);
726
+ ui.error(uiText(ui, "runtime.failed", kind, message));
727
+ return Promise.resolve({ text: "", session: st.session, error: message });
518
728
  }
519
729
 
730
+ const timeout = req.timeoutConfig ? directNativeTimeoutConfig(req.timeoutConfig) : nativeTimeoutConfig(req.env || process.env);
520
731
  return new Promise((resolve) => {
521
- ui.status(`starting ${kind === "claude-code" ? "claude" : kind}…`);
732
+ ui.status(uiText(ui, "runtime.starting", kind === "claude-code" ? "Claude" : kind));
522
733
  let child;
523
734
  try {
524
- child = spawn(bin, args, {
735
+ const spawnImpl = req.spawn || spawn;
736
+ const childEnv = req.prepareRuntimeEnv === false
737
+ ? (req.env || process.env)
738
+ : runtimeEnvForKind(kind, req.env || process.env);
739
+ child = spawnImpl(bin, args, {
525
740
  cwd,
526
741
  stdio: ["ignore", "pipe", "pipe"],
527
- env: req.env || process.env,
742
+ env: childEnv,
528
743
  });
529
744
  } catch (e) {
530
- ui.error(`failed to run ${kind}: ${e.message}`);
745
+ ui.error(uiText(ui, "runtime.failed", kind, e.message));
531
746
  return resolve({ text: "", session: st.session, error: e.message });
532
747
  }
533
748
 
534
- // Ctrl-C 자식 종료
535
- const onAbort = () => {
536
- try {
537
- child.kill("SIGTERM");
538
- } catch {
539
- /* ignore */
540
- }
749
+ let settled = false;
750
+ let termination = null;
751
+ let idleTimer = null;
752
+ let totalTimer = null;
753
+ let killTimer = null;
754
+ let forceTimer = null;
755
+ let removeLineReader = () => {};
756
+ let stderrBuf = "";
757
+ const clearWatchdogs = () => {
758
+ if (idleTimer) clearTimeout(idleTimer);
759
+ if (totalTimer) clearTimeout(totalTimer);
760
+ if (killTimer) clearTimeout(killTimer);
761
+ if (forceTimer) clearTimeout(forceTimer);
762
+ idleTimer = totalTimer = killTimer = forceTimer = null;
541
763
  };
542
- if (req.signal) {
543
- if (req.signal.aborted) onAbort();
544
- else req.signal.addEventListener("abort", onAbort, { once: true });
545
- }
764
+ const cleanup = () => {
765
+ clearWatchdogs();
766
+ removeLineReader();
767
+ child.stderr?.removeListener("data", onStderr);
768
+ if (req.signal) req.signal.removeEventListener?.("abort", onAbort);
769
+ };
770
+ const finish = (result) => {
771
+ if (settled) return;
772
+ settled = true;
773
+ cleanup();
774
+ resolve(result);
775
+ };
776
+ const terminationResult = () => {
777
+ ui.streamEnd();
778
+ ui.stopSpinner();
779
+ const text = (st.finalText || st.text || "").trim();
780
+ if (st.usage) ui.cost(st.usage);
781
+ finish({ text, session: st.session, usage: st.usage, error: termination ? termination.message : "native runtime stopped" });
782
+ };
783
+ const requestStop = (reason) => {
784
+ if (termination || settled) return;
785
+ const message = reason === "abort" ? "aborted" : nativeTimeoutMessage(reason, reason === "idle" ? timeout.idleMs : timeout.totalMs);
786
+ termination = { reason, message };
787
+ st.error = message;
788
+ st.errorShown = true;
789
+ if (reason !== "abort") ui.error(message);
790
+ if (idleTimer) clearTimeout(idleTimer);
791
+ if (totalTimer) clearTimeout(totalTimer);
792
+ idleTimer = totalTimer = null;
793
+ try { child.kill("SIGTERM"); } catch { /* ignore */ }
794
+ if (settled) return;
795
+ killTimer = setTimeout(() => {
796
+ if (settled) return;
797
+ try { child.kill("SIGKILL"); } catch { /* ignore */ }
798
+ if (settled) return;
799
+ forceTimer = setTimeout(terminationResult, Math.max(250, Math.min(1_000, timeout.killGraceMs)));
800
+ }, timeout.killGraceMs);
801
+ };
802
+ const onAbort = () => requestStop("abort");
803
+ const armIdle = () => {
804
+ if (settled || termination) return;
805
+ if (idleTimer) clearTimeout(idleTimer);
806
+ idleTimer = setTimeout(() => requestStop("idle"), timeout.idleMs);
807
+ };
808
+ const markActivity = () => armIdle();
546
809
 
547
810
  if (plainStream) {
548
811
  let plainStarted = false;
549
- lineReader(child.stdout, (l) => {
812
+ removeLineReader = lineReader(child.stdout, (l) => {
550
813
  if (!plainStarted) {
551
814
  ui.streamStart();
552
815
  plainStarted = true;
553
816
  }
554
817
  ui.streamDelta(l + "\n");
555
818
  st.text += l + "\n";
556
- });
819
+ }, markActivity);
557
820
  } else {
558
- lineReader(child.stdout, lineHandler);
821
+ removeLineReader = lineReader(child.stdout, lineHandler, markActivity);
559
822
  }
560
823
 
561
- let stderrBuf = "";
562
824
  child.stderr.setEncoding("utf8");
563
- child.stderr.on("data", (d) => {
825
+ const onStderr = (d) => {
826
+ markActivity();
564
827
  stderrBuf += d;
565
828
  if (stderrBuf.length > 4000) stderrBuf = stderrBuf.slice(-4000);
566
- });
829
+ };
830
+ child.stderr.on("data", onStderr);
567
831
 
568
832
  child.on("error", (err) => {
833
+ if (settled) return;
834
+ if (termination) {
835
+ terminationResult();
836
+ return;
837
+ }
569
838
  ui.stopSpinner();
570
- ui.error(`failed to run ${kind}: ${err.message}`);
571
- resolve({ text: "", session: st.session, error: err.message });
839
+ ui.error(uiText(ui, "runtime.failed", kind, err.message));
840
+ finish({ text: "", session: st.session, error: err.message });
572
841
  });
573
842
  child.on("close", (code) => {
574
- if (req.signal) req.signal.removeEventListener?.("abort", onAbort);
843
+ if (settled) return;
844
+ if (termination) {
845
+ terminationResult();
846
+ return;
847
+ }
575
848
  ui.streamEnd();
576
849
  ui.stopSpinner();
577
850
  const text = (st.finalText || st.text || "").trim();
578
- const aborted = req.signal && req.signal.aborted;
579
851
  const errTail = stripAnsi(stderrBuf).replace(/\s+/g, " ").trim(); // ANSI 제거 + 한 줄로
580
852
  if (st.error && !st.errorShown) {
581
853
  // claude `result` is_error 등 — 이전에 표시되지 않은 에러를 노출
582
854
  ui.error(String(st.error));
583
- } else if (code !== 0 && !text && !aborted) {
855
+ } else if (code !== 0 && !text) {
584
856
  // Runtime Doctor — 아는 시스템 원인(미인증 OAuth MCP 플러그인 등)이면 즉시 수리하고
585
857
  // 1회 자동 재시도한다(2026-07-08 notion@openai-curated가 codex 전멸시킨 사고).
586
858
  if (!req._doctorRetried) {
@@ -588,9 +860,11 @@ function runNativeTurn(req) {
588
860
  const { runRuntimeDoctor } = require("./agentlas-doctor.cjs");
589
861
  const report = runRuntimeDoctor(`${kind} exited with code ${code}\n${stripAnsi(stderrBuf)}`);
590
862
  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(" 자동 수리 완료 — 같은 요청을 다시 시도합니다.");
863
+ ui.warn(uiText(ui, "runtime.doctor", report.summary));
864
+ for (const act of report.actions) ui.warn(` ${act.title} — ${act.detail}`);
865
+ ui.warn(uiText(ui, "runtime.doctorRetry"));
866
+ settled = true;
867
+ cleanup();
594
868
  resolve(runNativeTurn({ ...req, _doctorRetried: true }));
595
869
  return;
596
870
  }
@@ -598,15 +872,39 @@ function runNativeTurn(req) {
598
872
  /* 닥터 실패는 원래 에러 표출을 막지 않는다 */
599
873
  }
600
874
  }
601
- ui.error(`${kind} exited with code ${code}` + (errTail ? `\n ${errTail.slice(-400)}` : ""));
602
- } else if (!text && !st.error && !aborted) {
875
+ ui.error(uiText(ui, "runtime.exited", kind, String(code)) + (errTail ? `\n ${errTail.slice(-400)}` : ""));
876
+ } else if (!text && !st.error) {
603
877
  // 정상 종료인데 출력이 비어 있음(거부/차단 등) — 무음 실패 방지
604
- ui.warn(`${kind}: no output` + (errTail ? ` (${errTail.slice(-200)})` : ""));
878
+ ui.warn(uiText(ui, "runtime.noOutput", kind) + (errTail ? ` (${errTail.slice(-200)})` : ""));
605
879
  }
606
880
  if (st.usage) ui.cost(st.usage);
607
- resolve({ text, session: st.session, usage: st.usage, error: st.error });
881
+ finish({ text, session: st.session, usage: st.usage, error: st.error });
608
882
  });
883
+
884
+ armIdle();
885
+ totalTimer = setTimeout(() => requestStop("total"), timeout.totalMs);
886
+ if (req.signal) {
887
+ if (req.signal.aborted) onAbort();
888
+ else req.signal.addEventListener("abort", onAbort, { once: true });
889
+ }
609
890
  });
610
891
  }
611
892
 
612
- module.exports = { runNativeTurn, summarizeToolInput, claudeArgs, codexArgs };
893
+ module.exports = {
894
+ runNativeTurn,
895
+ summarizeToolInput,
896
+ claudeArgs,
897
+ claudePermissionArgs,
898
+ codexArgs,
899
+ codexPermissionArgs,
900
+ geminiArgs,
901
+ geminiPermissionArgs,
902
+ claudeMcpIsolationArgs,
903
+ geminiMcpIsolationArgs,
904
+ prepareCodexRuntimeEnv,
905
+ runtimeEnvForKind,
906
+ cliMcpConfigPath,
907
+ codexMcpArgs,
908
+ nativeTimeoutConfig,
909
+ directNativeTimeoutConfig,
910
+ };