agentlas 1.0.10 → 1.0.12

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.
@@ -20,7 +20,8 @@ const { renderBanner, readVersion } = require("../agentlas-banner.cjs");
20
20
  const { Orchestrator, maxParallel } = require("../sessions/orchestrator.cjs");
21
21
  const { Renderer } = require("./renderer.cjs");
22
22
  const { findAgent, listAgents } = require("../agents/registry.cjs");
23
- const { resolveRuntime, NoRuntimeError } = require("../runtimes/resolve.cjs");
23
+ const { resolveRuntimeForAgent } = require("../runtimes/overrides.cjs");
24
+ const { EFFORTS } = require("../agentlas-workload-routing.cjs");
24
25
  const permissions = require("../agentlas-permissions.cjs");
25
26
  const i18n = require("../agentlas-i18n.cjs");
26
27
  const { tokenizeCommandLine } = require("../agentlas-input.cjs");
@@ -133,8 +134,18 @@ async function startRepl(ctx, opts = {}) {
133
134
  const renderer = new Renderer(ui);
134
135
  let permission = permissions.normalize(opts.permission || (ctx.prefs && ctx.prefs.permission) || "write");
135
136
  let runtimeOverride = opts.runtime || null;
136
-
137
- const resolveRt = () => resolveRuntime({ db, prefs: ctx.prefs, explicit: runtimeOverride });
137
+ let modelOverride = opts.model || null;
138
+ let effortOverride = opts.effort || null;
139
+
140
+ const resolveRt = (agentId = null) => resolveRuntimeForAgent({
141
+ db,
142
+ prefs: ctx.prefs,
143
+ explicit: runtimeOverride,
144
+ model: modelOverride,
145
+ effort: effortOverride,
146
+ role: "orchestrator",
147
+ agentId,
148
+ });
138
149
 
139
150
  let resumeChatId = opts.chatId || null;
140
151
  const ensureMainSession = (agentToken) => {
@@ -146,7 +157,14 @@ async function startRepl(ctx, opts = {}) {
146
157
  }
147
158
  const active = orch.active();
148
159
  if (active && active.agent.id === agent.id) return active;
149
- const session = orch.spawn({ agent, runtime: resolveRt(), permission, cwd: process.cwd(), activate: true, chatId: resumeChatId });
160
+ const session = orch.spawn({
161
+ agent,
162
+ runtime: resolveRt(agent.id),
163
+ permission,
164
+ cwd: process.cwd(),
165
+ activate: true,
166
+ chatId: resumeChatId,
167
+ });
150
168
  resumeChatId = null; // 재개는 첫 세션에만 적용
151
169
  renderer.attach(session, { replay: false });
152
170
  return session;
@@ -318,7 +336,18 @@ async function startRepl(ctx, opts = {}) {
318
336
 
319
337
  if (input.startsWith("/")) {
320
338
  try {
321
- const quit = handleSlash(ctx, input.slice(1), { orch, renderer, ensureMainSession, resolveRt, track: trackCommand, setPermission: (p) => { permission = p; }, getPermission: () => permission, setRuntime: (r) => { runtimeOverride = r; } });
339
+ const quit = handleSlash(ctx, input.slice(1), {
340
+ orch,
341
+ renderer,
342
+ ensureMainSession,
343
+ resolveRt,
344
+ track: trackCommand,
345
+ setPermission: (p) => { permission = p; },
346
+ getPermission: () => permission,
347
+ setRuntime: (r) => { runtimeOverride = r; },
348
+ setModel: (model) => { modelOverride = model; },
349
+ setEffort: (effort) => { effortOverride = effort; },
350
+ });
322
351
  if (quit === "quit") { rl.close(); return; }
323
352
  } catch (e) {
324
353
  ui.error(String((e && e.message) || e));
@@ -369,15 +398,19 @@ async function startRepl(ctx, opts = {}) {
369
398
  */
370
399
  const finish = () => { orch.shutdown(); ui.ensureNl(); resolve(0); };
371
400
  if (!pendingCommands.size) { finish(); return; }
372
- ui.ensureNl();
373
- ui.line(ui.c.dim(en
374
- ? `finishing ${pendingCommands.size} command(s)…`
375
- : `실행 중인 명령 ${pendingCommands.size}개를 마무리하는 중…`));
376
401
  let settled = false;
377
- const once = () => { if (settled) return; settled = true; finish(); };
378
- const timer = setTimeout(once, 30_000);
379
- if (timer.unref) timer.unref();
380
- Promise.allSettled([...pendingCommands]).then(() => { clearTimeout(timer); once(); }, once);
402
+ const once = () => { if (settled) return; settled = true; clearTimeout(notice); finish(); };
403
+ // 끝나는 명령까지 매번 고지하면 종료 화면이 시끄러워진다 — 실제로 기다릴 때만 알린다.
404
+ const notice = setTimeout(() => {
405
+ ui.ensureNl();
406
+ ui.line(ui.c.dim(en
407
+ ? `finishing ${pendingCommands.size} command(s)…`
408
+ : `실행 중인 명령 ${pendingCommands.size}개를 마무리하는 중…`));
409
+ }, 400);
410
+ if (notice.unref) notice.unref();
411
+ const cap = setTimeout(once, 30_000);
412
+ if (cap.unref) cap.unref();
413
+ Promise.allSettled([...pendingCommands]).then(() => { clearTimeout(cap); once(); }, once);
381
414
  });
382
415
 
383
416
  prompt();
@@ -478,6 +511,18 @@ function printSessions(ctx, orch) {
478
511
  }
479
512
  }
480
513
 
514
+ /*
515
+ * 세션 키 파싱. 인자가 없으면 사용법을 낸다 — 예전에는 `s${undefined}` 가 그대로
516
+ * 조립돼 `/kill` 이 "no such session: sundefined" 를, `/steer` 는 rest[0].length 에서
517
+ * 날 TypeError 를 냈다. 팔레트가 `/steer <n> <msg>` 라고 안내하므로 인자 없이 Enter 를
518
+ * 눌러 사용법을 보려는 것은 정상적인 탐색이다.
519
+ */
520
+ function sessionKeyArg(rest, usage) {
521
+ const token = rest[0];
522
+ if (!token) throw new Error(usage);
523
+ return String(token).startsWith("s") ? token : `s${token}`;
524
+ }
525
+
481
526
  function handleSlash(ctx, cmdline, api) {
482
527
  const en = ctx.lang === "en";
483
528
  const ui = ctx.uiInstance;
@@ -520,7 +565,7 @@ function handleSlash(ctx, cmdline, api) {
520
565
  case "sessions": case "tree": printSessions(ctx, orch); return;
521
566
 
522
567
  case "s": case "switch": {
523
- const key = rest[0] && rest[0].startsWith("s") ? rest[0] : `s${rest[0]}`;
568
+ const key = sessionKeyArg(rest, `Usage: /${cmd} <n>`);
524
569
  const session = orch.setActive(key);
525
570
  renderer.attach(session, { replay: true });
526
571
  return;
@@ -548,7 +593,7 @@ function handleSlash(ctx, cmdline, api) {
548
593
  }
549
594
 
550
595
  case "steer": {
551
- const key = rest[0] && rest[0].startsWith("s") ? rest[0] : `s${rest[0]}`;
596
+ const key = sessionKeyArg(rest, `Usage: /${cmd} <n> <message>`);
552
597
  const msg = restStr.slice(rest[0].length).trim();
553
598
  if (!msg) throw new Error("Usage: /steer <n> <message>");
554
599
  orch.sendTo(key, msg);
@@ -557,12 +602,12 @@ function handleSlash(ctx, cmdline, api) {
557
602
  }
558
603
 
559
604
  case "kill": {
560
- const key = rest[0] && rest[0].startsWith("s") ? rest[0] : `s${rest[0]}`;
605
+ const key = sessionKeyArg(rest, `Usage: /${cmd} <n>`);
561
606
  orch.kill(key);
562
607
  return;
563
608
  }
564
609
  case "rm": {
565
- const key = rest[0] && rest[0].startsWith("s") ? rest[0] : `s${rest[0]}`;
610
+ const key = sessionKeyArg(rest, `Usage: /${cmd} <n>`);
566
611
  orch.remove(key);
567
612
  const active = orch.active();
568
613
  if (active) renderer.attach(active, { replay: false });
@@ -590,6 +635,27 @@ function handleSlash(ctx, cmdline, api) {
590
635
  ctx.out(ui.c.dim(`runtime: ${rest[0]} (${en ? "applies to new sessions" : "새 세션부터 적용"})`));
591
636
  return;
592
637
  }
638
+ case "model": {
639
+ const model = String(rest[0] || "").trim();
640
+ if (!model) throw new Error("Usage: /model <provider-model-id|default>");
641
+ const next = ["default", "inherit"].includes(model.toLowerCase()) ? null : model;
642
+ api.setModel(next);
643
+ ctx.out(ui.c.dim(
644
+ `model: ${next || "default"} (${en ? "applies to new sessions" : "새 세션부터 적용"})`,
645
+ ));
646
+ return;
647
+ }
648
+ case "effort": {
649
+ const effort = String(rest[0] || "").trim().toLowerCase();
650
+ if (!EFFORTS.includes(effort)) {
651
+ throw new Error(`Usage: /effort ${EFFORTS.join("|")}`);
652
+ }
653
+ api.setEffort(effort === "none" ? null : effort);
654
+ ctx.out(ui.c.dim(
655
+ `effort: ${effort} (${en ? "applies to new sessions" : "새 세션부터 적용"})`,
656
+ ));
657
+ return;
658
+ }
593
659
  case "permission": {
594
660
  if (!["read", "write", "full"].includes(String(rest[0] || ""))) {
595
661
  throw new Error("Usage: /permission read|write|full");
@@ -89,7 +89,48 @@ function buildArgs(kind, systemPrompt, prompt, permission, runtimeOptions = {})
89
89
  const model = runtimeOptions.model ? String(runtimeOptions.model) : null;
90
90
  const effort = runtimeOptions.effort ? String(runtimeOptions.effort) : null;
91
91
  const noAuthority = runtimeOptions.authorityMode === "no-authority";
92
+ // read-only 권한: 워크포스가 tool:file-read 능력만 부여했을 때. plan 모드(쓰기 불가)에
93
+ // 정확-도구 allowlist를 겹쳐 읽기 3종으로 고정한다. 이것이 "증명 가능한 경계"의 실체다 —
94
+ // 예전에는 이 모드가 없어서 도구를 주면 곧바로 acceptEdits(쓰기)+기본 도구 전체가 됐고,
95
+ // 그래서 권한 부여 자체를 항상 거부할 수밖에 없었다(2026-07-27 코드 감사 불가의 근본).
96
+ const readOnlyAuthority = runtimeOptions.authorityMode === "read-only";
97
+ const readOnlyTools = Array.isArray(runtimeOptions.allowedNativeTools) && runtimeOptions.allowedNativeTools.length
98
+ ? [...new Set(runtimeOptions.allowedNativeTools.map((t) => String(t)))].sort()
99
+ : [];
100
+ if (readOnlyAuthority && !readOnlyTools.length) {
101
+ throw new Error("read-only capture requires an explicit native tool allowlist");
102
+ }
103
+ // read-only 분기를 가진 런타임은 claude-code뿐이다. 다른 kind로 들어오면 아래에서
104
+ // 일반 권한 분기로 떨어져 조용히 쓰기/셸까지 열린다 — 권한 승격이므로 정직 정지.
105
+ // (역할별 모델 배정으로 워커가 codex/gemini에서 돌 수 있게 되며 실재 경로가 됐다.)
106
+ if (readOnlyAuthority && kind !== "claude-code") {
107
+ throw new Error(`read-only capture is not provable on ${kind}`);
108
+ }
92
109
  if (kind === "claude-code") {
110
+ if (readOnlyAuthority) {
111
+ const thinkingRo = effort === "max" || effort === "xhigh" ? "Ultrathink. " : effort === "high" ? "Think hard. " : effort === "medium" ? "Think. " : "";
112
+ const claudeEffortRo = effort === "minimal" ? "low" : effort === "xhigh" ? "max" : effort;
113
+ return [
114
+ "-p", thinkingRo + prompt,
115
+ "--append-system-prompt", systemPrompt,
116
+ ...(model ? ["--model", model] : []),
117
+ ...(claudeEffortRo && claudeEffortRo !== "none" ? ["--effort", claudeEffortRo] : []),
118
+ // 도구를 쓰는 워커는 최종 답까지 stdout에 한 글자도 내지 않는다 — 무도구
119
+ // 워커에는 없던 문제다. 유휴 타이머는 그 침묵을 "죽었다"로 읽고 10분에
120
+ // 처형한다(2026-07-27 라이브 실측 AGENTLAS_CAPTURE_IDLE_TIMEOUT). 이벤트
121
+ // 스트림으로 받아 도구 호출마다 진행 신호가 흐르게 하면 유휴 판정이 비로소
122
+ // 진짜 정지 신호가 된다. 최종 텍스트는 capturedRuntimeAgentText가 result
123
+ // 이벤트에서 뽑는다(이미 이 프로토콜을 파싱한다). 부분 토큰 델타는 켜지
124
+ // 않는다 — 진행 신호는 도구 호출 단위로 충분하고 출력량이 폭증한다.
125
+ "--output-format", "stream-json",
126
+ "--verbose",
127
+ // plan 모드는 쓰기/실행을 거부한다. allowedTools로 읽기 3종만 남긴다.
128
+ "--permission-mode", "plan",
129
+ "--allowedTools", readOnlyTools.join(","),
130
+ "--disallowedTools", "Write,Edit,MultiEdit,NotebookEdit,Bash,BashOutput,KillShell,WebFetch,WebSearch,Task",
131
+ ...native.claudeMcpIsolationArgs(),
132
+ ];
133
+ }
93
134
  const perm = native.claudePermissionArgs(noAuthority ? "read" : level);
94
135
  // 백그라운드/캡처 경로에는 검토된 MCP 서버 목록이 없다. full 권한은 툴 권한이지
95
136
  // MCP 동의가 아니므로 이 경로는 항상 정확-공백 MCP 격리를 유지한다.
@@ -102,6 +143,16 @@ function buildArgs(kind, systemPrompt, prompt, permission, runtimeOptions = {})
102
143
  "--append-system-prompt", systemPrompt,
103
144
  ...(model ? ["--model", model] : []),
104
145
  ...effortArgs,
146
+ // 이 분기도 read-only 분기와 같은 이유로 이벤트 스트림이 필요하다. 위 주석은
147
+ // 침묵 문제를 "도구를 쓰는 워커"의 것으로 한정했지만, 그건 도구 유무가 아니라
148
+ // "최종 답 전에는 stdout에 아무것도 없다"는 성질이고 이 분기도 똑같다.
149
+ // effort가 높으면 "Ultrathink."가 앞에 붙어 사고만 10분을 넘길 수 있고,
150
+ // 유휴 타이머는 그 침묵을 죽음으로 읽는다 — 2026-07-28 라이브에서 무도구
151
+ // 워커 3개가 전부 600초 유휴로 처형됐다. 진행 신호가 흐르는 채널로 재야
152
+ // 유휴 판정이 실제 정지를 뜻한다. 최종 텍스트는
153
+ // capturedRuntimeAgentText가 result 이벤트에서 그대로 뽑는다.
154
+ "--output-format", "stream-json",
155
+ "--verbose",
105
156
  ...perm,
106
157
  ...(noAuthority ? ["--tools", ""] : []),
107
158
  ...mcp,
@@ -166,6 +217,98 @@ function codexCaptureAgentText(jsonl) {
166
217
  return [...latest.values()].join("");
167
218
  }
168
219
 
220
+ function nonNegativeTokenCount(value) {
221
+ return Number.isInteger(value) && value >= 0 ? value : null;
222
+ }
223
+
224
+ function usageFromObject(value, inputKeys, outputKeys, inputAdditiveKeys = []) {
225
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
226
+ const firstCount = (keys) => {
227
+ for (const key of keys) {
228
+ const count = nonNegativeTokenCount(value[key]);
229
+ if (count != null) return count;
230
+ }
231
+ return null;
232
+ };
233
+ let inputTokens = firstCount(inputKeys);
234
+ const outputTokens = firstCount(outputKeys);
235
+ if (inputTokens != null) {
236
+ for (const key of inputAdditiveKeys) {
237
+ const count = nonNegativeTokenCount(value[key]);
238
+ if (count != null) inputTokens += count;
239
+ }
240
+ }
241
+ // Do not manufacture a zero for providers that expose only one side.
242
+ return inputTokens == null || outputTokens == null
243
+ ? null
244
+ : { inputTokens, outputTokens };
245
+ }
246
+
247
+ function capturedRuntimeUsage(kind, raw) {
248
+ const events = [];
249
+ for (const line of String(raw || "").split(/\r?\n/)) {
250
+ if (!line.trim()) continue;
251
+ try {
252
+ events.push(JSON.parse(line));
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+ const genericUsage = (value) =>
258
+ usageFromObject(
259
+ value,
260
+ ["inputTokens", "input_tokens", "promptTokens", "prompt_tokens", "promptTokenCount"],
261
+ ["outputTokens", "output_tokens", "completionTokens", "completion_tokens", "candidatesTokenCount"],
262
+ );
263
+ for (const event of [...events].reverse()) {
264
+ if (kind === "claude-code") {
265
+ const claudeUsage = usageFromObject(
266
+ event?.usage,
267
+ ["input_tokens"],
268
+ ["output_tokens"],
269
+ ["cache_creation_input_tokens", "cache_read_input_tokens"],
270
+ );
271
+ if (claudeUsage) return claudeUsage;
272
+ const modelUsage = event?.modelUsage;
273
+ if (modelUsage && typeof modelUsage === "object" && !Array.isArray(modelUsage)) {
274
+ let inputTokens = 0;
275
+ let outputTokens = 0;
276
+ let observed = false;
277
+ for (const row of Object.values(modelUsage)) {
278
+ const usage = genericUsage(row);
279
+ if (!usage) continue;
280
+ inputTokens += usage.inputTokens;
281
+ outputTokens += usage.outputTokens;
282
+ observed = true;
283
+ }
284
+ if (observed) return { inputTokens, outputTokens };
285
+ }
286
+ }
287
+ const direct =
288
+ genericUsage(event?.usage) ||
289
+ genericUsage(event?.usageMetadata) ||
290
+ genericUsage(event?.stats);
291
+ if (direct) return direct;
292
+ if (kind === "gemini") {
293
+ const models = event?.stats?.models;
294
+ if (models && typeof models === "object" && !Array.isArray(models)) {
295
+ let inputTokens = 0;
296
+ let outputTokens = 0;
297
+ let observed = false;
298
+ for (const row of Object.values(models)) {
299
+ const usage = genericUsage(row);
300
+ if (!usage) continue;
301
+ inputTokens += usage.inputTokens;
302
+ outputTokens += usage.outputTokens;
303
+ observed = true;
304
+ }
305
+ if (observed) return { inputTokens, outputTokens };
306
+ }
307
+ }
308
+ }
309
+ return null;
310
+ }
311
+
169
312
  function capturedRuntimeAgentText(kind, raw) {
170
313
  const text = String(raw || "");
171
314
  const events = [];
@@ -219,7 +362,9 @@ function capturedRuntimeAgentText(kind, raw) {
219
362
  /**
220
363
  * 네이티브 CLI 헤드리스 1턴 캡처 — 최종 텍스트를 resolve한다.
221
364
  * opts: { cwd, env, permission, model, effort, authorityMode, noToolsPolicyPath,
222
- * timeoutConfig, outputLimitBytes, signal, spawn(테스트 주입) }
365
+ * envelope, timeoutConfig, outputLimitBytes, signal, spawn(테스트 주입) }
366
+ * envelope=true면 { text, usage }를 반환한다. usage는 양쪽 토큰을 실제로 관측한
367
+ * 경우만 채우고, 불완전한 provider 이벤트를 0으로 위조하지 않는다.
223
368
  */
224
369
  function captureRuntime(kind, systemPrompt, prompt, opts) {
225
370
  opts = opts || {};
@@ -247,6 +392,7 @@ function captureRuntime(kind, systemPrompt, prompt, opts) {
247
392
  effort: opts.effort,
248
393
  authorityMode: opts.authorityMode,
249
394
  noToolsPolicyPath: opts.noToolsPolicyPath,
395
+ allowedNativeTools: opts.allowedNativeTools,
250
396
  }), {
251
397
  cwd,
252
398
  stdio: ["ignore", "pipe", "pipe"],
@@ -369,15 +515,17 @@ function captureRuntime(kind, systemPrompt, prompt, opts) {
369
515
  return;
370
516
  }
371
517
  const raw = stdout.trim() || stderr.trim();
518
+ let text;
372
519
  if (kind === "codex" && opts.authorityMode === "no-authority") {
373
- finishResolve(codexCaptureAgentText(raw));
374
- return;
375
- }
376
- if (kind === "claude-code" || kind === "gemini") {
377
- finishResolve(capturedRuntimeAgentText(kind, raw));
378
- return;
520
+ text = codexCaptureAgentText(raw);
521
+ } else if (kind === "claude-code" || kind === "gemini") {
522
+ text = capturedRuntimeAgentText(kind, raw);
523
+ } else {
524
+ text = raw;
379
525
  }
380
- finishResolve(raw);
526
+ finishResolve(opts.envelope
527
+ ? { text, usage: capturedRuntimeUsage(kind, raw) }
528
+ : text);
381
529
  };
382
530
  onAbort = () => {
383
531
  const reason = opts.signal && opts.signal.reason;
@@ -399,7 +547,7 @@ function captureRuntime(kind, systemPrompt, prompt, opts) {
399
547
  });
400
548
  }
401
549
 
402
- // ── API 러너 (BYOK / Ollama) — 비스트리밍, 최종 텍스트 반환 (v1 runApi 포팅) ──
550
+ // ── API 러너 (BYOK / Ollama) — 비스트리밍, 최종 텍스트/usage 반환 (v1 runApi 포팅) ──
403
551
  // engine/agentlas-api-agent.cjs 는 스트리밍+툴 루프(대화형)용이다. 워크포스 워커는
404
552
  // zero-tools 원샷이 계약이므로 v1의 비스트리밍 원샷 경로를 그대로 쓴다.
405
553
  const DEFAULT_API_MODEL = {
@@ -483,6 +631,9 @@ function readCustomApiBaseUrl() {
483
631
  */
484
632
  async function runApi(backend, model, system, prompt, options) {
485
633
  options = options || {};
634
+ const finish = (text, usage) => options.envelope
635
+ ? { text: String(text || ""), usage: usage || null }
636
+ : String(text || "");
486
637
  model = model || DEFAULT_API_MODEL[backend];
487
638
  const fetchImpl = options.fetch || globalThis.fetch;
488
639
  if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable in this runtime (run through the app runtime).");
@@ -494,7 +645,10 @@ async function runApi(backend, model, system, prompt, options) {
494
645
  });
495
646
  if (!resp.ok) throw new Error(`Ollama ${resp.status} — run 'ollama serve' and check the model`);
496
647
  const j = await resp.json();
497
- return (j.message && j.message.content) || "";
648
+ return finish(
649
+ (j.message && j.message.content) || "",
650
+ usageFromObject(j, ["prompt_eval_count"], ["eval_count"]),
651
+ );
498
652
  }
499
653
  const supported = backend === "anthropic" || backend === "openai" || backend === "google" ||
500
654
  backend === "upstage" || backend === "custom" || !!ANTHROPIC_COMPAT_API[backend];
@@ -509,14 +663,25 @@ async function runApi(backend, model, system, prompt, options) {
509
663
  const authHeaders = anthropicCompat
510
664
  ? { "x-api-key": key, authorization: "Bearer " + key }
511
665
  : { "x-api-key": key };
666
+ // Prompt caching: 진짜 Anthropic만 cache_control을 존중한다. 크고 안정된
667
+ // system 프리픽스를 캐시 경계로 표시하면 적중 시 입력이 ~90% 싸게 과금되고,
668
+ // 모델별 최소치(~1024 토큰) 미만이면 서버가 조용히 무시하므로 무해하다.
669
+ // 호환 엔드포인트(GLM/Kimi/DeepSeek)는 문자열 형태를 유지한다 — 서버측
670
+ // 자동 캐시가 있고 추가 필드를 거부할 수 있다(데스크탑 byok.ts와 동일 계약).
671
+ const systemField = backend === "anthropic"
672
+ ? [{ type: "text", text: system, cache_control: { type: "ephemeral" } }]
673
+ : system;
512
674
  const resp = await fetchImpl(`${base}/v1/messages`, {
513
675
  method: "POST",
514
676
  headers: { "content-type": "application/json", ...authHeaders, "anthropic-version": "2023-06-01" },
515
- body: JSON.stringify({ model, max_tokens: 4096, system, messages: [{ role: "user", content: prompt }] }),
677
+ body: JSON.stringify({ model, max_tokens: 4096, system: systemField, messages: [{ role: "user", content: prompt }] }),
516
678
  });
517
679
  if (!resp.ok) throw new Error(`${label} ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
518
680
  const j = await resp.json();
519
- return (j.content && j.content[0] && j.content[0].text) || "";
681
+ return finish(
682
+ (j.content && j.content[0] && j.content[0].text) || "",
683
+ usageFromObject(j.usage, ["input_tokens"], ["output_tokens"]),
684
+ );
520
685
  }
521
686
  if (backend === "openai" || backend === "upstage" || backend === "custom") {
522
687
  const base = backend === "upstage"
@@ -534,7 +699,10 @@ async function runApi(backend, model, system, prompt, options) {
534
699
  });
535
700
  if (!resp.ok) throw new Error(`${label} ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
536
701
  const j = await resp.json();
537
- return (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || "";
702
+ return finish(
703
+ (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || "",
704
+ usageFromObject(j.usage, ["prompt_tokens"], ["completion_tokens"]),
705
+ );
538
706
  }
539
707
  if (backend === "google") {
540
708
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`;
@@ -546,7 +714,10 @@ async function runApi(backend, model, system, prompt, options) {
546
714
  if (!resp.ok) throw new Error(`Google ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
547
715
  const j = await resp.json();
548
716
  const c = j.candidates && j.candidates[0];
549
- return (c && c.content && c.content.parts && c.content.parts[0] && c.content.parts[0].text) || "";
717
+ return finish(
718
+ (c && c.content && c.content.parts && c.content.parts[0] && c.content.parts[0].text) || "",
719
+ usageFromObject(j.usageMetadata, ["promptTokenCount"], ["candidatesTokenCount"]),
720
+ );
550
721
  }
551
722
  throw new Error("Unsupported backend: " + backend);
552
723
  }
@@ -641,6 +812,19 @@ const UNTRUSTED_PROTECTED_ENV_KEYS_CLI = new Set([
641
812
  "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "GRPC_PROXY", "NPM_CONFIG_PROXY",
642
813
  "AGENTLAS_SESSION", "AGENTLAS_MCP_BASE_URL", "AGENTLAS_WEB_BASE_URL", "AGENTLAS_API_BASE_URL",
643
814
  "AGENTLAS_HUB_BASE_URL", "AGENTLAS_CLOUD_BASE_URL", "OLLAMA_HOST",
815
+ // 모델 제공사 엔드포인트/게이트웨이 — Agentlas 자체 base URL과 같은 부류인데 빠져
816
+ // 있었다. 2026-07-27 실측: 클론 레포의 .env 한 줄이 자식 CLI의 ANTHROPIC_BASE_URL과
817
+ // AUTH_TOKEN을 갈아치워, 모든 워커 프롬프트가 공격자 서버로 나가고 그 서버가 쓴
818
+ // 답변이 핸드오프·합성으로 흘러든다. 키 자체(*_API_KEY)는 프로젝트별 BYOK 기능이라
819
+ // 계속 허용한다 — 막는 것은 "데이터가 어디로 가는가"뿐이다.
820
+ "ANTHROPIC_BASE_URL", "ANTHROPIC_API_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_CUSTOM_HEADERS",
821
+ "ANTHROPIC_BEDROCK_BASE_URL", "ANTHROPIC_VERTEX_BASE_URL",
822
+ "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_SKIP_BEDROCK_AUTH",
823
+ "CLAUDE_CODE_SKIP_VERTEX_AUTH", "AWS_ENDPOINT_URL", "AWS_ENDPOINT_URL_BEDROCK",
824
+ "OPENAI_BASE_URL", "OPENAI_API_BASE", "OPENAI_ORGANIZATION", "AZURE_OPENAI_ENDPOINT",
825
+ "OPENAI_PROXY", "CODEX_BASE_URL", "CODEX_API_URL",
826
+ "GOOGLE_GEMINI_BASE_URL", "GOOGLE_VERTEX_BASE_URL", "GEMINI_API_BASE_URL",
827
+ "GOOGLE_CLOUD_PROJECT", "GOOGLE_APPLICATION_CREDENTIALS",
644
828
  ]);
645
829
  function isProtectedChildEnvKeyCli(key, trusted) {
646
830
  const k = String(key || "").trim().toUpperCase();
@@ -690,6 +874,7 @@ module.exports = {
690
874
  buildArgs,
691
875
  codexCaptureAgentText,
692
876
  capturedRuntimeAgentText,
877
+ capturedRuntimeUsage,
693
878
  captureOutputLimit,
694
879
  directCaptureOutputLimit,
695
880
  captureRuntime,