agentlas 0.7.0 → 0.9.2

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 (49) hide show
  1. package/CHANGELOG.md +199 -0
  2. package/README.md +161 -18
  3. package/bin/agentlas.cjs +8 -8
  4. package/engine/agentlas-core-harness.cjs +212 -0
  5. package/engine/agentlas-desktop-loadout.cjs +527 -0
  6. package/engine/agentlas-doctor.cjs +1 -1
  7. package/engine/agentlas-experience-exchange.cjs +835 -85
  8. package/engine/agentlas-experience-intake.cjs +444 -0
  9. package/engine/agentlas-experience-mcp.cjs +580 -18
  10. package/engine/agentlas-i18n.cjs +10 -10
  11. package/engine/agentlas-input.cjs +5 -4
  12. package/engine/agentlas-mcp-env.cjs +219 -0
  13. package/engine/agentlas-mcp-wrapper.cjs +51 -0
  14. package/engine/agentlas-memory-governance.cjs +1029 -0
  15. package/engine/agentlas-native-host.cjs +129 -39
  16. package/engine/agentlas-parity.cjs +339 -154
  17. package/engine/agentlas-repl.cjs +306 -31
  18. package/engine/agentlas-workforce.cjs +2991 -0
  19. package/engine/agentlas-workload-routing.cjs +523 -0
  20. package/engine/agentlas.cjs +1619 -234
  21. package/engine/bootstrap-schema.sql +1 -1
  22. package/engine/experience-taxonomy-v1.json +49 -0
  23. package/package.json +8 -4
  24. package/scripts/gen-bootstrap-schema.sh +0 -23
  25. package/test/bootstrap-race.cjs +0 -47
  26. package/test/capture-runtime-guard.cjs +0 -122
  27. package/test/cloud-asset-restore.cjs +0 -423
  28. package/test/cloud-cas-client.cjs +0 -333
  29. package/test/cloud-owner-restore.cjs +0 -183
  30. package/test/cloud-runtime-paths.cjs +0 -40
  31. package/test/cloud-save-publish.cjs +0 -487
  32. package/test/credential-env-regression.cjs +0 -52
  33. package/test/engine-hardening-regression.cjs +0 -74
  34. package/test/experience-exchange-contract.cjs +0 -569
  35. package/test/experience-mcp-contract.cjs +0 -391
  36. package/test/fixtures/portable-experience-bundle-v1-golden.json +0 -124
  37. package/test/login-loopback-security.cjs +0 -115
  38. package/test/mcp-config-isolation.cjs +0 -36
  39. package/test/permission-mapping.cjs +0 -180
  40. package/test/route-regression.cjs +0 -357
  41. package/test/run-api-regression.cjs +0 -322
  42. package/test/runtime-env-protection.cjs +0 -89
  43. package/test/semver-precedence.cjs +0 -39
  44. package/test/smoke.sh +0 -93
  45. package/test/sqlite-driver-probe.cjs +0 -22
  46. package/test/terminal-ui-regression.cjs +0 -477
  47. package/test/timeout-regression.cjs +0 -218
  48. package/test/tool-workspace-boundary.cjs +0 -165
  49. package/test/update-safety.cjs +0 -376
@@ -2,7 +2,7 @@
2
2
  /*
3
3
  * agentlas-parity: 데스크탑 앱 전용이던 기능의 터미널 패리티 구현.
4
4
  *
5
- * storm — Hephaestus Stormbreaker(route --auto-run) 파이프라인 실행 (+연구 증거)
5
+ * storm — Agentlas-owned Goal/UltraCode harness (Hephaestus route evidence + local parallel workers)
6
6
  * swarm — emergent 에이전트 스웜 (블랙보드 + `## Spawn` 그래프 성장 + 종합)
7
7
  * automation — 앱 스케줄러가 실행하는 자동화의 등록/목록/토글 (같은 SQLite)
8
8
  * usage — 로컬 실행/자동화/세션 집계
@@ -18,6 +18,12 @@ const fs = require("node:fs");
18
18
  const crypto = require("node:crypto");
19
19
  const { spawn } = require("node:child_process");
20
20
  const { Ui } = require("./agentlas-ui.cjs");
21
+ const workloadRouting = require("./agentlas-workload-routing.cjs");
22
+ const {
23
+ loadCoreStormbreakerHarness,
24
+ resolveCoreRuntimeRoot,
25
+ spawnCoreModule,
26
+ } = require("./agentlas-core-harness.cjs");
21
27
 
22
28
  // ── 스웜 상수 (앱 mcp/swarm-run.ts 와 동일한 안전 상한) ──
23
29
  const SWARM_MAX_TASKS = 24;
@@ -36,7 +42,7 @@ const MAX_LOGIN_SESSION_BYTES = 16 * 1024;
36
42
 
37
43
  function createLoginState(randomBytes = crypto.randomBytes) {
38
44
  const bytes = Buffer.from(randomBytes(32));
39
- if (bytes.length !== 32) throw new Error("로그인 state 생성에 실패했습니다.");
45
+ if (bytes.length !== 32) throw new Error("Could not create the login state.");
40
46
  return bytes.toString("base64url");
41
47
  }
42
48
 
@@ -59,7 +65,7 @@ function createLoginCallbackGuard(expectedState) {
59
65
  try {
60
66
  url = new URL(String(rawUrl || "/"), "http://127.0.0.1");
61
67
  } catch {
62
- return { handled: true, final: false, ok: false, statusCode: 400, message: "잘못된 로그인 콜백입니다." };
68
+ return { handled: true, final: false, ok: false, statusCode: 400, message: "Invalid login callback." };
63
69
  }
64
70
  if (url.pathname !== LOGIN_CALLBACK_PATH) {
65
71
  return { handled: false, final: false, ok: false, statusCode: 404, message: "not found" };
@@ -68,7 +74,7 @@ function createLoginCallbackGuard(expectedState) {
68
74
  return { handled: true, final: false, ok: false, statusCode: 405, message: "method not allowed" };
69
75
  }
70
76
  if (consumed) {
71
- return { handled: true, final: false, ok: false, statusCode: 410, message: "이미 사용된 로그인 콜백입니다." };
77
+ return { handled: true, final: false, ok: false, statusCode: 410, message: "Login callback has already been used." };
72
78
  }
73
79
  consumed = true;
74
80
 
@@ -78,7 +84,7 @@ function createLoginCallbackGuard(expectedState) {
78
84
  final: true,
79
85
  ok: false,
80
86
  statusCode: 400,
81
- message: "로그인 콜백 state 검증에 실패했습니다. agentlas login 다시 실행하세요.",
87
+ message: "Login callback state validation failed. Run agentlas login again.",
82
88
  };
83
89
  }
84
90
  const oauthError = url.searchParams.get("error");
@@ -89,7 +95,7 @@ function createLoginCallbackGuard(expectedState) {
89
95
  final: true,
90
96
  ok: false,
91
97
  statusCode: 400,
92
- message: `Agentlas 로그인 거부: ${safeCode}`,
98
+ message: `Agentlas login denied: ${safeCode}`,
93
99
  };
94
100
  }
95
101
  const value = url.searchParams.get("session") || url.searchParams.get("token") || "";
@@ -99,7 +105,7 @@ function createLoginCallbackGuard(expectedState) {
99
105
  final: true,
100
106
  ok: false,
101
107
  statusCode: 400,
102
- message: "콜백에 session 값이 없습니다.",
108
+ message: "The callback did not include a session value.",
103
109
  };
104
110
  }
105
111
  if (Buffer.byteLength(value, "utf8") > MAX_LOGIN_SESSION_BYTES) {
@@ -108,10 +114,10 @@ function createLoginCallbackGuard(expectedState) {
108
114
  final: true,
109
115
  ok: false,
110
116
  statusCode: 400,
111
- message: "로그인 session 값이 허용 크기를 초과했습니다.",
117
+ message: "The login session value is too large.",
112
118
  };
113
119
  }
114
- return { handled: true, final: true, ok: true, statusCode: 200, value, message: "Agentlas 로그인 완료" };
120
+ return { handled: true, final: true, ok: true, statusCode: 200, value, message: "Agentlas login complete" };
115
121
  },
116
122
  isConsumed() { return consumed; },
117
123
  };
@@ -130,7 +136,7 @@ function create(deps) {
130
136
  process.env.HEPHAESTUS_BIN,
131
137
  path.join(os.homedir(), ".agentlas", "runtime", "current", "bin", "hephaestus"),
132
138
  ];
133
- for (const c of candidates) {
139
+ for (const c of process.platform === "win32" ? [] : candidates) {
134
140
  try {
135
141
  if (c && fs.existsSync(c)) {
136
142
  fs.accessSync(c, fs.constants.X_OK);
@@ -138,15 +144,8 @@ function create(deps) {
138
144
  }
139
145
  } catch { /* 다음 후보 */ }
140
146
  }
141
- // 번들 (Resources/Hephaestus) — python3 로 bin/hephaestus 와 동일한 부트스트랩 실행
142
- const roots = [];
143
- if (process.resourcesPath) roots.push(path.join(process.resourcesPath, "Hephaestus"));
144
- if (process.platform === "darwin") roots.push("/Applications/Agentlas.app/Contents/Resources/Hephaestus");
145
- for (const root of roots) {
146
- try {
147
- if (fs.existsSync(path.join(root, "agentlas_cloud", "__main__.py"))) return { kind: "python", root };
148
- } catch { /* 다음 후보 */ }
149
- }
147
+ const root = resolveCoreRuntimeRoot();
148
+ if (root) return { kind: "python", root };
150
149
  return null;
151
150
  }
152
151
 
@@ -179,26 +178,17 @@ function create(deps) {
179
178
  return null;
180
179
  }
181
180
 
182
- const PY_BOOTSTRAP =
183
- "import os, runpy, sys; " +
184
- 'cwd=os.getcwd(); root=os.environ["HEPHAESTUS_RUNTIME_ROOT"]; ' +
185
- 'sys.path=[p for p in sys.path if p not in ("", cwd, root)]; ' +
186
- "sys.path.insert(0, root); " +
187
- "sys.argv=sys.argv[1:]; " +
188
- 'runpy.run_module(sys.argv[0], run_name="__main__", alter_sys=True)';
189
-
190
181
  function spawnHephaestus(args, opts) {
191
182
  const found = hephaestusBin();
192
183
  if (!found) return null;
193
184
  if (found.kind === "bin") return spawn(found.exec, args, opts);
194
- return spawn("python3", ["-c", PY_BOOTSTRAP, "agentlas_cloud", ...args], {
195
- ...opts,
196
- env: { ...(opts && opts.env ? opts.env : process.env), HEPHAESTUS_RUNTIME_ROOT: found.root },
197
- });
185
+ return spawnCoreModule("agentlas_cloud", args, opts, found.root);
198
186
  }
199
187
 
200
- // ── storm — stormbreakerRun()과 동일: route <goal> --auto-run ──
201
- // ctx: { ui?, cwd?, research?, background? }
188
+ // ── storm — Agentlas 자체 Goal/UltraCode 하네스 ──
189
+ // Core owns the exact Goal/UltraCode prompt. Terminal supplies only host
190
+ // runtime inventory, worker context, and execution; no local prompt fallback.
191
+ // ctx: { ui?, cwd?, research?, background?, runtimeOverride? }
202
192
  async function stormRun(db, goal, ctx = {}) {
203
193
  const ui = ctx.ui || newUi();
204
194
  goal = String(goal || "").trim();
@@ -207,40 +197,49 @@ function create(deps) {
207
197
  return { ok: false };
208
198
  }
209
199
  if (goal.startsWith("-")) {
210
- ui.error("goal '-'로 시작할 수 없습니다.");
200
+ ui.error("goal cannot start with '-'.");
211
201
  return { ok: false };
212
202
  }
213
- if (!hephaestusBin()) {
214
- ui.error("Hephaestus 런타임이 없습니다 — 데스크탑 앱 설치 또는 Hephaestus 인스톨러 실행 후 다시 시도하세요.");
215
- ui.info("설치: https://agentlas.cloud · 또는 HEPHAESTUS_BIN=<경로> 지정");
216
- return { ok: false };
203
+ const cwd = ctx.cwd || (typeof D.projectCwd === "function" ? D.projectCwd() : D.runCwd());
204
+ let executionHarness;
205
+ try {
206
+ executionHarness = await loadCoreStormbreakerHarness(cwd);
207
+ } catch (error) {
208
+ ui.error(`Stormbreaker Core harness unavailable: ${String((error && error.message) || error).slice(0, 400)}`);
209
+ return { ok: false, error: "stormbreaker-core-harness-unavailable" };
217
210
  }
218
- const cwd = ctx.cwd || D.runCwd();
219
- const args = ["route", goal, "--project", cwd, "--runtime", "terminal", "--auto-run"];
211
+ const args = ["route", goal, "--project", cwd, "--runtime", "terminal"];
220
212
  if (ctx.research) args.push("--research-evidence");
221
- if (ctx.background) args.push("--background");
222
-
223
- ui.beginTurn();
224
- ui.startSpinner(ui.lang === "ko" ? "Stormbreaker 라우팅/파이프라인 실행 중…" : "Stormbreaker routing/pipeline…");
225
- const result = await new Promise((resolve) => {
226
- const child = spawnHephaestus(args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
227
- let stdout = "";
228
- let stderrTail = [];
229
- child.stdout.on("data", (c) => { stdout += c.toString(); });
230
- child.stderr.on("data", (c) => {
231
- for (const ln of c.toString().split("\n")) {
232
- const line = ln.trim();
233
- if (!line) continue;
234
- stderrTail.push(line);
235
- if (stderrTail.length > 30) stderrTail.shift();
236
- ui.updateSpinner(line.slice(0, 100));
237
- }
213
+ if (ctx.background) {
214
+ ui.warn(ui.lang === "ko"
215
+ ? "Agentlas 자체 Stormbreaker 하네스는 현재 포그라운드에서 실행합니다. 세션이 끝나도 영수증으로 재개 지점을 보존합니다."
216
+ : "The Agentlas-owned Stormbreaker harness currently runs in the foreground and preserves resume receipts.");
217
+ }
218
+
219
+ let result = { code: 0, stdout: "", stderr: "" };
220
+ if (hephaestusBin()) {
221
+ ui.beginTurn();
222
+ ui.startSpinner(ui.lang === "ko" ? "Stormbreaker 라우팅 근거 수집 중…" : "Stormbreaker gathering route evidence…");
223
+ result = await new Promise((resolve) => {
224
+ const child = spawnHephaestus(args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
225
+ let stdout = "";
226
+ let stderrTail = [];
227
+ child.stdout.on("data", (c) => { stdout += c.toString(); });
228
+ child.stderr.on("data", (c) => {
229
+ for (const ln of c.toString().split("\n")) {
230
+ const line = ln.trim();
231
+ if (!line) continue;
232
+ stderrTail.push(line);
233
+ if (stderrTail.length > 30) stderrTail.shift();
234
+ ui.updateSpinner(line.slice(0, 100));
235
+ }
236
+ });
237
+ child.on("error", (err) => resolve({ code: 1, stdout, stderr: String(err.message) }));
238
+ child.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr: stderrTail.join("\n") }));
238
239
  });
239
- child.on("error", (err) => resolve({ code: 1, stdout, stderr: String(err.message) }));
240
- child.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr: stderrTail.join("\n") }));
241
- });
242
- ui.stopSpinner();
243
- ui.endTurn();
240
+ ui.stopSpinner();
241
+ ui.endTurn();
242
+ }
244
243
 
245
244
  let json = null;
246
245
  try {
@@ -249,6 +248,7 @@ function create(deps) {
249
248
  if (s >= 0 && e > s) json = JSON.parse(result.stdout.slice(s, e + 1));
250
249
  } catch { /* 비JSON 출력 */ }
251
250
 
251
+ let routeContext = "";
252
252
  if (json) {
253
253
  const action = json.action || json.route_action || (json.route_decision && json.route_decision.action) || json.status || "?";
254
254
  ui.line("");
@@ -273,6 +273,11 @@ function create(deps) {
273
273
  // 파이프라인 패킷 요약
274
274
  const packets = json.execution_fabric && json.execution_fabric.packets;
275
275
  if (Array.isArray(packets)) {
276
+ routeContext = packets.slice(0, 24).map((p) => {
277
+ const title = String(p.title || p.id || "packet").replace(/\s+/g, " ").slice(0, 160);
278
+ const card = p.card ? ` [agent:${String(p.card).slice(0, 100)}]` : "";
279
+ return `- ${title}${card}`;
280
+ }).join("\n");
276
281
  for (const p of packets.slice(0, 12)) {
277
282
  ui.line(" " + ui.c.emerald("▸ ") + ui.c.text(String(p.title || p.id || "packet")) + (p.card ? ui.c.dim(" " + p.card) : ""));
278
283
  }
@@ -293,13 +298,29 @@ function create(deps) {
293
298
  const raw = (result.stdout || result.stderr || "").trim();
294
299
  if (raw) ui.markdown(raw.slice(0, 4000));
295
300
  }
296
- if (result.code !== 0 && !json) ui.error(`hephaestus exited ${result.code}`);
297
- return { ok: result.code === 0, json };
301
+ if (result.code !== 0 && !json) {
302
+ ui.warn(`Hephaestus route evidence unavailable (${result.code}); Agentlas parent planner will continue from the original goal.`);
303
+ }
304
+
305
+ const harnessResult = await swarmRun(db, goal, {
306
+ ...ctx,
307
+ ui,
308
+ cwd,
309
+ runtimeOverride: ctx.runtimeOverride,
310
+ stormbreaker: true,
311
+ executionHarness,
312
+ routeContext,
313
+ });
314
+ return { ...harnessResult, routeDecision: json };
298
315
  }
299
316
 
300
- async function cmdStorm(db, args, runtimeOverride) {
317
+ async function cmdStorm(db, args, runtimeOverride, executionContext = {}) {
301
318
  const rest = [];
302
- const ctx = { cwd: D.runCwd() };
319
+ const ctx = {
320
+ ...executionContext,
321
+ cwd: executionContext.cwd || (typeof D.projectCwd === "function" ? D.projectCwd() : D.runCwd()),
322
+ runtimeOverride,
323
+ };
303
324
  for (let i = 0; i < args.length; i++) {
304
325
  if (args[i] === "--research" || args[i] === "--research-evidence") ctx.research = true;
305
326
  else if (args[i] === "--background") ctx.background = true;
@@ -333,14 +354,14 @@ function create(deps) {
333
354
  const child =
334
355
  found.kind === "bin"
335
356
  ? spawn(found.exec, isCareerGraph ? args.slice(1) : args, { cwd, stdio: "inherit" })
336
- : spawn("python3", ["-c", PY_BOOTSTRAP, moduleName, ...moduleArgs], {
337
- cwd,
338
- stdio: "inherit",
339
- env: { ...process.env, HEPHAESTUS_RUNTIME_ROOT: found.root },
340
- });
357
+ : spawnCoreModule(moduleName, moduleArgs, { cwd, stdio: "inherit" }, found.root);
358
+ if (!child) {
359
+ process.stderr.write("Hephaestus failed: Python 3.9+ was not found.\n");
360
+ return Promise.resolve(1);
361
+ }
341
362
  return new Promise((resolve) => {
342
363
  child.on("error", (e) => {
343
- process.stderr.write(`Hephaestus 실행 실패: ${e.message}\n`);
364
+ process.stderr.write(`Hephaestus failed: ${e.message}\n`);
344
365
  resolve(1);
345
366
  });
346
367
  child.on("close", (code) => resolve(code == null ? 0 : code));
@@ -371,12 +392,17 @@ function create(deps) {
371
392
  }
372
393
 
373
394
  // ── swarm — 앱 swarm-run.ts 프로토콜의 CLI 포트 ──
374
- function swarmProtocol(goal, tasks, task) {
395
+ function swarmProtocol(goal, tasks, task, liveRuntimeInventory) {
375
396
  const doneList = tasks
376
397
  .filter((t) => t.status === "done")
377
398
  .slice(-8)
378
399
  .map((t) => `- ${t.title}`)
379
400
  .join("\n");
401
+ const assignedList = tasks
402
+ .filter((t) => t.id !== task.id && t.status !== "failed")
403
+ .slice(0, 24)
404
+ .map((t) => `- [${t.status}] ${t.title}${t.brief ? ` — ${t.brief}` : ""}`)
405
+ .join("\n");
380
406
  return [
381
407
  "You are one worker in an EMERGENT AGENT SWARM collaborating on a shared goal.",
382
408
  `SHARED GOAL: ${goal}`,
@@ -386,15 +412,18 @@ function create(deps) {
386
412
  task.brief ? `- Details: ${task.brief}` : "",
387
413
  "",
388
414
  doneList ? `Already completed by peers (recent):\n${doneList}` : "No peer results yet — you may be first.",
415
+ assignedList ? `WORK ALREADY ASSIGNED TO PEERS (never duplicate these packets):\n${assignedList}` : "",
389
416
  "",
390
417
  "RULES:",
391
418
  "1. Do your task concretely with available tools/files in the current working folder.",
419
+ `LIVE_RUNTIME_INVENTORY=${JSON.stringify(liveRuntimeInventory || [])}`,
392
420
  "2. If the goal needs MORE work beyond your task — split into concrete next steps — end your",
393
- " message with a `## Spawn` block, one task per line as `role? | brief`:",
421
+ " message with a `## Spawn` block. Every child MUST be one JSON object with a higher-level AI allocation. Choose runtimeId and exactModelId only from LIVE_RUNTIME_INVENTORY:",
394
422
  " ## Spawn",
395
- " - webmaster | build the landing page structure",
396
- " - | run the tests and report failures",
397
- " (role is optional; omit it for any-worker tasks. Do NOT spawn if the goal is already met.)",
423
+ ' - {"role":"webmaster","brief":"build the landing page structure","allocation":{"schema":"agentlas.workload-allocation.v1","runtimeId":"runtime-1","exactModelId":"model-from-inventory","tier":"balanced","effort":"high","phase":"delegate","reasonCodes":["complex-reasoning"],"rationale":"requires coordinated implementation","requiredCapabilities":["code","tools"]}}',
424
+ ' - {"brief":"run focused tests","allocation":{"schema":"agentlas.workload-allocation.v1","runtimeId":"runtime-2","exactModelId":"model-from-inventory","tier":"economy","effort":"low","phase":"delegate","reasonCodes":["bounded-scope"],"rationale":"bounded verification","requiredCapabilities":["code","tools"]}}',
425
+ " Choose each exact runtime/model and effort from the actual child difficulty; do not copy one allocation to every child.",
426
+ " (role is optional. Do NOT spawn if the goal is already met or another pending/running/done packet already owns that work.)",
398
427
  "3. Do NOT restate the whole goal. Do NOT invent work that isn't needed — over-spawning wastes the user's money.",
399
428
  "4. Everything above the `## Spawn` block is your result and is shared with peers on the blackboard.",
400
429
  ]
@@ -415,6 +444,23 @@ function create(deps) {
415
444
  continue;
416
445
  }
417
446
  const body = line.replace(/^-\s*/, "");
447
+ if (body.startsWith("{")) {
448
+ const item = workloadRouting.extractJsonObject(body);
449
+ if (item && typeof item === "object" && !Array.isArray(item)) {
450
+ const brief = String(item.brief || "").trim();
451
+ const allocation = workloadRouting.normalizeAllocation(item.allocation || item, "delegate");
452
+ if (brief) {
453
+ spawn.push({
454
+ title: String(item.title || brief).trim().slice(0, 80),
455
+ brief: brief.slice(0, 8_000),
456
+ role: item.role ? String(item.role).trim().slice(0, 80) : undefined,
457
+ allocation,
458
+ });
459
+ }
460
+ }
461
+ if (spawn.length >= SWARM_SPAWN_PER_TURN) break;
462
+ continue;
463
+ }
418
464
  const parts = body.split("|");
419
465
  let role;
420
466
  let brief;
@@ -424,7 +470,9 @@ function create(deps) {
424
470
  } else {
425
471
  brief = body.trim();
426
472
  }
427
- if (brief) spawn.push({ title: brief.slice(0, 80), brief, role });
473
+ // Legacy text remains parseable, but it has no AI-authored allocation and
474
+ // therefore runs on the current model with an observable fallback receipt.
475
+ if (brief) spawn.push({ title: brief.slice(0, 80), brief, role, allocation: null });
428
476
  if (spawn.length >= SWARM_SPAWN_PER_TURN) break;
429
477
  }
430
478
  return { result, spawn };
@@ -439,8 +487,23 @@ function create(deps) {
439
487
  return { ok: false };
440
488
  }
441
489
  const runtime = ctx.runtime || D.resolveRuntime(db, ctx.runtimeOverride);
490
+ const stormbreaker = ctx.stormbreaker === true;
491
+ const executionHarness = stormbreaker ? ctx.executionHarness : null;
492
+ if (stormbreaker && (!executionHarness || typeof executionHarness.system_prompt !== "string")) {
493
+ ui.error("Stormbreaker requires the canonical Goal + UltraCode harness from Agentlas Core.");
494
+ return { ok: false, error: "stormbreaker-core-harness-unavailable" };
495
+ }
496
+ const coreHarnessPrompt = executionHarness && executionHarness.system_prompt;
442
497
  const permission = ctx.permission || "write";
443
- const cwd = ctx.cwd || D.runCwd();
498
+ const discoveredRuntimes = ctx.runtimes && ctx.runtimes.length
499
+ ? ctx.runtimes
500
+ : typeof D.listAvailableRuntimes === "function"
501
+ ? D.listAvailableRuntimes(db, runtime)
502
+ : [runtime];
503
+ const runtimes = discoveredRuntimes
504
+ .map((candidate, index) => ({ ...candidate, runtimeId: candidate.runtimeId || `runtime-${index + 1}` }));
505
+ const liveRuntimeInventory = workloadRouting.runtimeInventory(runtimes);
506
+ const cwd = ctx.cwd || (typeof D.projectCwd === "function" ? D.projectCwd() : D.runCwd());
444
507
  const concurrency = Math.max(1, Math.min(8, Number(ctx.concurrency) || 3));
445
508
  const env = await D.buildChildEnvCli(db, {
446
509
  projectPath: ctx.projectPath || null,
@@ -450,22 +513,125 @@ function create(deps) {
450
513
  lang: ui.lang,
451
514
  });
452
515
 
453
- async function runWorker(system, prompt) {
516
+ async function runBaseWorker(system, prompt) {
454
517
  if (runtime.mode === "cli") {
455
- return await D.captureRuntime(runtime.kind, system, prompt, { cwd, env, permission });
518
+ return await D.captureRuntime(runtime.kind, system, prompt, {
519
+ cwd,
520
+ env,
521
+ permission,
522
+ model: ctx.modelPin || runtime.model || null,
523
+ effort: ctx.effortPin === undefined ? null : ctx.effortPin,
524
+ });
525
+ }
526
+ const text = await D.runApi(runtime.backend, ctx.modelPin || runtime.model, system, prompt);
527
+ return typeof text === "string" ? text : (text && text.text) || "";
528
+ }
529
+
530
+ function recordAllocation(task, stage, decision, resolution, parentTaskId = null) {
531
+ const receipt = workloadRouting.createDecisionReceipt({
532
+ taskId: `${stage}-${task.id || "synthesis"}`,
533
+ parentTaskId,
534
+ taskText: task.brief || task.title || goal,
535
+ stage,
536
+ decision,
537
+ resolution,
538
+ });
539
+ try {
540
+ workloadRouting.appendDecisionReceipt(
541
+ receipt,
542
+ ctx.receiptFile || (D.modelRoutingReceiptPath && D.modelRoutingReceiptPath()),
543
+ );
544
+ } catch (error) {
545
+ ui.warn(`model routing receipt failed: ${String((error && error.message) || error).slice(0, 120)}`);
546
+ }
547
+ return receipt;
548
+ }
549
+
550
+ async function runAllocatedWorker(system, prompt, task, stage, parentTaskId = null) {
551
+ const resolution = workloadRouting.resolveAllocationAcrossRuntimes({
552
+ runtimes,
553
+ fallbackRuntime: runtime,
554
+ decision: task.allocation,
555
+ modelPin: ctx.modelPin,
556
+ effortPin: ctx.effortPin,
557
+ availableModels: ctx.availableModels,
558
+ maxTier: ctx.maxTier || process.env.AGENTLAS_MODEL_MAX_TIER,
559
+ });
560
+ recordAllocation(task, stage, task.allocation, resolution, parentTaskId);
561
+ if (!resolution.ok) {
562
+ throw new Error(`model allocation failed closed: ${resolution.fallbackReason || "no compliant live model"}`);
563
+ }
564
+ if (resolution.fallbackReason) {
565
+ ui.info(`model route: ${resolution.source} · ${resolution.runtimeId || "current"} · ${resolution.model || runtime.kind || runtime.backend} · ${resolution.fallbackReason}`);
566
+ }
567
+ const selectedRuntime = resolution.runtime || runtime;
568
+ task.resolvedAllocation = {
569
+ runtimeId: resolution.runtimeId || selectedRuntime.runtimeId || null,
570
+ runtimeKind: selectedRuntime.kind || selectedRuntime.backend || null,
571
+ model: resolution.model || selectedRuntime.model || null,
572
+ effort: resolution.effort ?? null,
573
+ source: resolution.source,
574
+ fallbackReason: resolution.fallbackReason || null,
575
+ };
576
+ if (selectedRuntime.mode === "cli") {
577
+ return await D.captureRuntime(selectedRuntime.kind, system, prompt, {
578
+ cwd,
579
+ env,
580
+ permission,
581
+ model: resolution.model,
582
+ effort: resolution.effort,
583
+ });
456
584
  }
457
- const text = await D.runApi(runtime.backend, runtime.model, system, prompt);
585
+ const text = await D.runApi(selectedRuntime.backend, resolution.model || selectedRuntime.model, system, prompt);
458
586
  return typeof text === "string" ? text : (text && text.text) || "";
459
587
  }
460
588
 
461
589
  const label = runtime.mode === "cli" ? runtime.kind : runtime.backend;
462
590
  ui.line("");
463
- ui.line(ui.c.paw("◤ ") + ui.c.bold(ui.c.text("swarm")) + ui.c.dim(` ${label} · x${concurrency} · max ${SWARM_MAX_TASKS} tasks`));
591
+ ui.line(ui.c.paw("◤ ") + ui.c.bold(ui.c.text(stormbreaker ? "stormbreaker" : "swarm")) + ui.c.dim(` Agentlas harness · ${label} · x${concurrency} · max ${SWARM_MAX_TASKS} tasks`));
464
592
  ui.info(goal.slice(0, 120));
465
593
 
594
+ ui.startSpinner(ui.lang === "ko" ? "상위 AI가 작업별 모델 비용을 배정 중…" : "Higher-level AI is allocating task models…");
595
+ let planned = null;
596
+ try {
597
+ const plannerText = await runBaseWorker(
598
+ [
599
+ coreHarnessPrompt,
600
+ workloadRouting.plannerSystemPrompt({
601
+ language: ui.lang === "ko" ? "Korean" : "English",
602
+ maxTasks: Math.min(SWARM_SPAWN_PER_TURN, SWARM_MAX_TASKS),
603
+ mode: stormbreaker ? "stormbreaker-goal-ultracode" : "swarm",
604
+ liveRuntimeInventory,
605
+ }),
606
+ ].filter(Boolean).join("\n\n"),
607
+ ctx.routeContext
608
+ ? `${goal}\n\nHEPHAESTUS ROUTE EVIDENCE (advisory; the Agentlas parent owns the final plan):\n${ctx.routeContext}`
609
+ : goal,
610
+ );
611
+ planned = workloadRouting.normalizePlan(plannerText, { maxTasks: SWARM_SPAWN_PER_TURN });
612
+ } catch (error) {
613
+ ui.warn(`workload planner failed: ${String((error && error.message) || error).slice(0, 160)}`);
614
+ }
615
+ ui.stopSpinner();
616
+ if (!planned) ui.warn(ui.lang === "ko" ? "모델 배정 JSON이 유효하지 않아 현재 모델로 투명하게 폴백합니다." : "Invalid allocation JSON; transparently falling back to the current model.");
617
+ if (planned) {
618
+ ui.line("");
619
+ ui.info(stormbreaker
620
+ ? (ui.lang === "ko" ? "Stormbreaker Goal/UltraCode 실행 계획:" : "Stormbreaker Goal/UltraCode execution plan:")
621
+ : (ui.lang === "ko" ? "스웜 실행 계획:" : "Swarm execution plan:"));
622
+ for (const task of planned.tasks) {
623
+ const allocation = task.allocation;
624
+ ui.line(` ${ui.c.emerald("▸ ")}${ui.c.text(task.title)}${ui.c.dim(` ${allocation.runtimeId || "current"} · ${allocation.exactModelId || allocation.tier} · ${allocation.effort}`)}`);
625
+ }
626
+ ui.line(` ${ui.c.emerald("◆ ")}${ui.c.text("synthesis")}${ui.c.dim(` ${planned.synthesis.runtimeId || "current"} · ${planned.synthesis.exactModelId || planned.synthesis.tier} · ${planned.synthesis.effort}`)}`);
627
+ }
628
+
466
629
  let seq = 0;
467
- const tasks = [{ id: ++seq, title: goal.slice(0, 80), brief: goal, role: undefined, status: "pending", result: "" }];
468
- const seen = new Set([goal.slice(0, 80).toLowerCase()]);
630
+ const initialTasks = planned
631
+ ? planned.tasks
632
+ : [{ title: goal.slice(0, 80), brief: goal, role: undefined, allocation: null }];
633
+ const tasks = initialTasks.map((task) => ({ id: ++seq, ...task, status: "pending", result: "", parentTaskId: null }));
634
+ const seen = new Set(tasks.map((task) => task.title.toLowerCase()));
469
635
  let active = 0;
470
636
  let failed = 0;
471
637
 
@@ -478,17 +644,23 @@ function create(deps) {
478
644
  task.status = "running";
479
645
  active++;
480
646
  ui.tool(`⚑ ${task.title}` + (task.role ? ` (${task.role})` : ""));
481
- runWorker(swarmProtocol(goal, tasks, task), task.brief || task.title)
647
+ runAllocatedWorker(
648
+ [coreHarnessPrompt, swarmProtocol(goal, tasks, task, liveRuntimeInventory)].filter(Boolean).join("\n\n"),
649
+ task.brief || task.title,
650
+ task,
651
+ "worker",
652
+ task.parentTaskId,
653
+ )
482
654
  .then((text) => {
483
655
  const parsed = parseSwarmOutput(text);
484
656
  task.status = "done";
485
657
  task.result = parsed.result;
486
- ui.toolResult(parsed.result.split("\n").slice(0, 3).join("\n") || "( 결과)", true);
658
+ ui.toolResult(parsed.result.split("\n").slice(0, 3).join("\n") || "(empty result)", true);
487
659
  for (const s of parsed.spawn) {
488
660
  const key = s.title.toLowerCase();
489
661
  if (tasks.length >= SWARM_MAX_TASKS || seen.has(key)) continue;
490
662
  seen.add(key);
491
- tasks.push({ id: ++seq, title: s.title, brief: s.brief, role: s.role, status: "pending", result: "" });
663
+ tasks.push({ id: ++seq, title: s.title, brief: s.brief, role: s.role, allocation: s.allocation, status: "pending", result: "", parentTaskId: `worker-${task.id}` });
492
664
  ui.info(`+ spawn: ${s.title}`);
493
665
  }
494
666
  })
@@ -515,11 +687,22 @@ function create(deps) {
515
687
  }
516
688
 
517
689
  ui.startSpinner(ui.lang === "ko" ? "스웜 결과 종합 중…" : "Synthesizing swarm results…");
518
- const pieces = done.map((t, i) => `### ${i + 1}. ${t.title}\n${t.result}`).join("\n\n");
690
+ const pieces = done.map((t, i) => [
691
+ `### ${i + 1}. ${t.title}`,
692
+ `HOST-VERIFIED ALLOCATION: ${JSON.stringify(t.resolvedAllocation || null)}`,
693
+ t.result,
694
+ ].join("\n")).join("\n\n");
519
695
  let finalText;
520
696
  try {
521
- finalText = await runWorker(
697
+ const synthesisTask = {
698
+ id: "final",
699
+ title: "swarm synthesis",
700
+ brief: goal,
701
+ allocation: planned && planned.synthesis,
702
+ };
703
+ finalText = await runAllocatedWorker(
522
704
  [
705
+ coreHarnessPrompt,
523
706
  "You are the synthesizer of an agent swarm. Below are the results your peers produced for the shared goal.",
524
707
  "Integrate them into ONE coherent final answer for the user. Reconcile overlaps, note anything incomplete.",
525
708
  "Do not just concatenate. Do not include a `## Spawn` block.",
@@ -527,10 +710,12 @@ function create(deps) {
527
710
  `Answer in the user's language (${ui.lang === "ko" ? "Korean" : "English"}).`,
528
711
  ].join("\n"),
529
712
  pieces,
713
+ synthesisTask,
714
+ "synthesis",
530
715
  );
531
716
  } catch (e) {
532
717
  ui.stopSpinner();
533
- ui.error("종합 실패: " + String((e && e.message) || e).slice(0, 200));
718
+ ui.error("Synthesis failed: " + String((e && e.message) || e).slice(0, 200));
534
719
  finalText = pieces;
535
720
  }
536
721
  ui.stopSpinner();
@@ -539,14 +724,14 @@ function create(deps) {
539
724
  return { ok: true, finalText, taskCount: tasks.length, doneCount: done.length };
540
725
  }
541
726
 
542
- async function cmdSwarm(db, args, runtimeOverride) {
727
+ async function cmdSwarm(db, args, runtimeOverride, executionContext = {}) {
543
728
  const rest = [];
544
729
  let concurrency;
545
730
  for (let i = 0; i < args.length; i++) {
546
731
  if (args[i] === "--parallel" || args[i] === "-n") concurrency = Number(args[++i]);
547
732
  else rest.push(args[i]);
548
733
  }
549
- const r = await swarmRun(db, rest.join(" "), { concurrency, runtimeOverride });
734
+ const r = await swarmRun(db, rest.join(" "), { ...executionContext, concurrency, runtimeOverride });
550
735
  if (!r.ok) process.exitCode = 1;
551
736
  }
552
737
 
@@ -716,7 +901,7 @@ function create(deps) {
716
901
  const rows = db.prepare(
717
902
  "SELECT id, name, schedule, target_type, target_id, enabled, next_run_at, last_run_at, run_count, trigger_type FROM automations ORDER BY created_at DESC",
718
903
  ).all();
719
- if (!rows.length) return D.out("자동화가 없습니다. agentlas automation add --help");
904
+ if (!rows.length) return D.out("No automations. Run agentlas automation add --help.");
720
905
  for (const r of rows) {
721
906
  const target = r.target_type + ":" + String(r.target_id).slice(0, 24);
722
907
  D.out(
@@ -726,8 +911,8 @@ function create(deps) {
726
911
  );
727
912
  }
728
913
  D.out("");
729
- D.out("지금 실행: agentlas automation run <id> · 상주 실행기: agentlas automation daemon");
730
- D.out("(데스크탑 앱이 켜져 있으면 스케줄러도 실행합니다 리스로 중복 실행은 방지됩니다.)");
914
+ D.out("Run now: agentlas automation run <id> · daemon: agentlas automation daemon");
915
+ D.out("The Desktop scheduler also runs when the app is open; leases prevent duplicate runs.");
731
916
  return;
732
917
  }
733
918
 
@@ -745,7 +930,7 @@ function create(deps) {
745
930
  else if (a === "--disabled") flags.disabled = true;
746
931
  }
747
932
  if (!flags.cron || !flags.prompt || (!flags.agent && !flags.firm)) {
748
- D.out('usage: agentlas automation add --name "이름" --agent <slug>|--firm <slug> --cron "0 9 * * *" --prompt "지시" [--tz Asia/Seoul] [--disabled]');
933
+ D.out('usage: agentlas automation add --name "name" --agent <slug>|--firm <slug> --cron "0 9 * * *" --prompt "instructions" [--tz Asia/Seoul] [--disabled]');
749
934
  process.exitCode = 1;
750
935
  return;
751
936
  }
@@ -754,19 +939,19 @@ function create(deps) {
754
939
  let targetLabel;
755
940
  if (flags.agent) {
756
941
  const a = D.resolveAgent(db, flags.agent);
757
- if (!a) return D.fail(`에이전트를 찾을 없습니다: ${flags.agent}`);
942
+ if (!a) return D.fail(`Agent not found: ${flags.agent}`);
758
943
  targetType = "agent";
759
944
  targetId = a.id;
760
945
  targetLabel = a.name;
761
946
  } else {
762
947
  const f = D.resolveFirm(db, flags.firm);
763
- if (!f) return D.fail(`회사를 찾을 없습니다: ${flags.firm}`);
948
+ if (!f) return D.fail(`Company not found: ${flags.firm}`);
764
949
  targetType = "firm";
765
950
  targetId = f.id;
766
951
  targetLabel = f.name;
767
952
  }
768
953
  const next = nextCronRun(flags.cron, new Date(), flags.tz || null);
769
- if (!next) return D.fail(`cron 표현식을 해석할 없습니다: "${flags.cron}" (5필드: 요일)`);
954
+ if (!next) return D.fail(`Could not parse cron expression: "${flags.cron}" (5 fields: minute hour day month weekday)`);
770
955
  const id = crypto.randomUUID();
771
956
  db.prepare(
772
957
  `INSERT INTO automations (id, name, schedule, target_type, target_id, prompt_template, enabled, created_by,
@@ -774,7 +959,7 @@ function create(deps) {
774
959
  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,0)`,
775
960
  ).run(
776
961
  id,
777
- flags.name || `${targetLabel} 자동화`,
962
+ flags.name || `${targetLabel} automation`,
778
963
  flags.cron,
779
964
  targetType,
780
965
  targetId,
@@ -788,8 +973,8 @@ function create(deps) {
788
973
  "auto",
789
974
  "hub-allowed",
790
975
  );
791
- D.out(`등록됨: ${id.slice(0, 8)} ${flags.name || targetLabel} next=${next.toISOString().slice(0, 16)}`);
792
- D.out(`지금 실행: agentlas automation run ${id.slice(0, 8)} · 예약 실행: agentlas automation daemon (또는 데스크탑 앱)`);
976
+ D.out(`Created: ${id.slice(0, 8)} ${flags.name || targetLabel} next=${next.toISOString().slice(0, 16)}`);
977
+ D.out(`Run now: agentlas automation run ${id.slice(0, 8)} · scheduled: agentlas automation daemon (or Desktop)`);
793
978
  return;
794
979
  }
795
980
 
@@ -797,14 +982,14 @@ function create(deps) {
797
982
  const idPrefix = args[1];
798
983
  if (!idPrefix) return D.fail(`usage: agentlas automation ${sub} <id>`);
799
984
  const row = db.prepare("SELECT id, name, schedule, schedule_json, timezone FROM automations WHERE id LIKE ?").get(idPrefix + "%");
800
- if (!row) return D.fail(`자동화를 찾을 없습니다: ${idPrefix}`);
985
+ if (!row) return D.fail(`Automation not found: ${idPrefix}`);
801
986
  if (sub === "on") {
802
987
  const next = nextAutomationRun(row) || null;
803
988
  db.prepare("UPDATE automations SET enabled=1, next_run_at=? WHERE id=?").run(next ? next.toISOString() : null, row.id);
804
989
  } else {
805
990
  db.prepare("UPDATE automations SET enabled=0 WHERE id=?").run(row.id);
806
991
  }
807
- D.out(`${sub === "on" ? "켜짐" : "꺼짐"}: ${row.id.slice(0, 8)} ${row.name}`);
992
+ D.out(`${sub === "on" ? "Enabled" : "Disabled"}: ${row.id.slice(0, 8)} ${row.name}`);
808
993
  return;
809
994
  }
810
995
 
@@ -812,9 +997,9 @@ function create(deps) {
812
997
  const idPrefix = args[1];
813
998
  if (!idPrefix) return D.fail("usage: agentlas automation remove <id>");
814
999
  const row = db.prepare("SELECT id, name FROM automations WHERE id LIKE ?").get(idPrefix + "%");
815
- if (!row) return D.fail(`자동화를 찾을 없습니다: ${idPrefix}`);
1000
+ if (!row) return D.fail(`Automation not found: ${idPrefix}`);
816
1001
  db.prepare("DELETE FROM automations WHERE id=?").run(row.id);
817
- D.out(`삭제됨: ${row.id.slice(0, 8)} ${row.name}`);
1002
+ D.out(`Deleted: ${row.id.slice(0, 8)} ${row.name}`);
818
1003
  return;
819
1004
  }
820
1005
 
@@ -824,10 +1009,10 @@ function create(deps) {
824
1009
  LEFT JOIN automations a ON a.id = h.automation_id
825
1010
  ORDER BY h.ran_at DESC LIMIT 15`,
826
1011
  ).all();
827
- if (!rows.length) return D.out("실행 이력이 없습니다.");
1012
+ if (!rows.length) return D.out("No run history.");
828
1013
  for (const r of rows) {
829
1014
  D.out(
830
- `${(r.ran_at || "").slice(0, 16).padEnd(17)} ${(r.status || "?").padEnd(9)} ${(r.name || "(삭제됨)").slice(0, 30).padEnd(31)} ${r.error ? String(r.error).slice(0, 40) : ""}`,
1015
+ `${(r.ran_at || "").slice(0, 16).padEnd(17)} ${(r.status || "?").padEnd(9)} ${(r.name || "(deleted)").slice(0, 30).padEnd(31)} ${r.error ? String(r.error).slice(0, 40) : ""}`,
831
1016
  );
832
1017
  }
833
1018
  return;
@@ -837,7 +1022,7 @@ function create(deps) {
837
1022
  const idPrefix = args[1];
838
1023
  if (!idPrefix) return D.fail("usage: agentlas automation run <id>");
839
1024
  const row = db.prepare("SELECT * FROM automations WHERE id LIKE ?").get(idPrefix + "%");
840
- if (!row) return D.fail(`자동화를 찾을 없습니다: ${idPrefix}`);
1025
+ if (!row) return D.fail(`Automation not found: ${idPrefix}`);
841
1026
  const ui = newUi();
842
1027
  // run-now는 스케줄을 건드리지 않는다 (앱의 advanceSchedule=false와 동일).
843
1028
  const r = await runAutomationOnce(db, row, { ui, advanceSchedule: false, runtimeOverride });
@@ -888,7 +1073,7 @@ function create(deps) {
888
1073
  const ui = ctx.ui || newUi();
889
1074
  // run-now도 리스를 잡는다 — 앱 스케줄러가 같은 행을 동시에 돌리는 것을 방지.
890
1075
  if (!claimAutomation(db, row.id)) {
891
- ui.warn(`다른 실행기가 자동화를 잡고 있습니다 (lease TTL 15): ${row.name}`);
1076
+ ui.warn(`Another runner holds this automation (lease TTL 15 minutes): ${row.name}`);
892
1077
  return { ok: false, skipped: true };
893
1078
  }
894
1079
  ui.line("");
@@ -900,11 +1085,11 @@ function create(deps) {
900
1085
  let agentId = null;
901
1086
  if (row.target_type === "firm") {
902
1087
  const firm = db.prepare("SELECT * FROM firms WHERE id = ?").get(row.target_id);
903
- if (!firm) throw new Error(`회사를 찾을 없습니다: ${row.target_id}`);
1088
+ if (!firm) throw new Error(`Company not found: ${row.target_id}`);
904
1089
  system = D.firmSystemPrompt(db, firm);
905
1090
  } else {
906
1091
  const agent = db.prepare("SELECT * FROM installed_agents WHERE id = ?").get(row.target_id);
907
- if (!agent) throw new Error(`에이전트를 찾을 없습니다: ${row.target_id}`);
1092
+ if (!agent) throw new Error(`Agent not found: ${row.target_id}`);
908
1093
  system = agent.system_prompt || `You are ${agent.name}.`;
909
1094
  agentId = agent.id;
910
1095
  }
@@ -972,7 +1157,7 @@ function create(deps) {
972
1157
  process.on("SIGTERM", () => { stopping = true; });
973
1158
 
974
1159
  ui.line("");
975
- ui.ok(`automation daemon — ${intervalSec}s 폴링 · owner ${AUTOMATION_LEASE_OWNER}`);
1160
+ ui.ok(`automation daemon — polling every ${intervalSec}s · owner ${AUTOMATION_LEASE_OWNER}`);
976
1161
  ui.info(ui.lang === "ko" ? "Ctrl-C로 종료. (데스크탑 앱 스케줄러와 리스를 공유해 중복 실행되지 않습니다.)" : "Ctrl-C to stop.");
977
1162
 
978
1163
  while (!stopping) {
@@ -983,7 +1168,7 @@ function create(deps) {
983
1168
  "SELECT * FROM automations WHERE enabled = 1 AND trigger_type = 'schedule' AND next_run_at IS NOT NULL AND next_run_at <= ? ORDER BY next_run_at ASC LIMIT 5",
984
1169
  ).all(nowIso);
985
1170
  } catch (e) {
986
- ui.error("due 조회 실패: " + String((e && e.message) || e));
1171
+ ui.error("Failed to query due automations: " + String((e && e.message) || e));
987
1172
  }
988
1173
  for (const row of due) {
989
1174
  if (stopping) break;
@@ -1007,12 +1192,12 @@ function create(deps) {
1007
1192
  try {
1008
1193
  rows = db.prepare("SELECT id, name, name_en, transport, enabled FROM mcp_servers ORDER BY installed_at ASC").all();
1009
1194
  } catch { /* 테이블 없음 */ }
1010
- if (!rows.length) return D.out("설치된 MCP 서버가 없습니다. (설치/설정은 데스크탑 또는 에이전트 패키지가 관리)");
1195
+ if (!rows.length) return D.out("No MCP servers are installed. Configure them in Desktop or an agent package.");
1011
1196
  for (const r of rows) {
1012
1197
  D.out(`${r.enabled ? "●" : "○"} ${String(r.name || r.name_en || r.id).padEnd(28).slice(0, 28)} ${String(r.transport || "stdio").padEnd(8)} ${String(r.id).slice(0, 12)}`);
1013
1198
  }
1014
1199
  D.out("");
1015
- D.out("full 턴에서만 활성(●) stdio 서버가 런타임에 배선됩니다. REPL에서는 /mcp.");
1200
+ D.out("Only full-access turns wire active (●) stdio servers into the runtime. In the REPL, use /mcp.");
1016
1201
  }
1017
1202
 
1018
1203
  function cmdChats(db, args) {
@@ -1026,12 +1211,12 @@ function create(deps) {
1026
1211
  ORDER BY c.updated_at DESC LIMIT ?`,
1027
1212
  ).all(limit);
1028
1213
  } catch { /* 스키마 차이 */ }
1029
- if (!rows.length) return D.out("채팅이 없습니다.");
1214
+ if (!rows.length) return D.out("No chats.");
1030
1215
  for (const r of rows) {
1031
- D.out(`${String(r.updated_at || "").slice(0, 16).padEnd(17)} ${String(r.agent_name || r.agent_name_en || "-").slice(0, 18).padEnd(19)} ${String(r.title || "(제목 없음)").slice(0, 60)}`);
1216
+ D.out(`${String(r.updated_at || "").slice(0, 16).padEnd(17)} ${String(r.agent_name_en || r.agent_name || "-").slice(0, 18).padEnd(19)} ${String(r.title || "(untitled)").slice(0, 60)}`);
1032
1217
  }
1033
1218
  D.out("");
1034
- D.out("데스크탑 앱과 같은 대화 목록입니다 터미널 세션 이어하기는 REPL의 /resume.");
1219
+ D.out("These chats are shared with Desktop. Resume terminal sessions with /resume in the REPL.");
1035
1220
  }
1036
1221
 
1037
1222
  // ── usage — 로컬 집계 ──
@@ -1048,14 +1233,14 @@ function create(deps) {
1048
1233
  const msg7 = q("SELECT COUNT(*) AS n FROM chat_messages WHERE created_at > ?", week);
1049
1234
  const auto = q("SELECT COUNT(*) AS n FROM automations WHERE enabled=1");
1050
1235
  const runs7 = q("SELECT COUNT(*) AS n, SUM(CASE WHEN status='error' OR error IS NOT NULL THEN 1 ELSE 0 END) AS err FROM run_history WHERE ran_at > ?", week);
1051
- D.out(`활성 런타임 ${ar.kind || "(없음)"}`);
1052
- D.out(`설치 에이전트 ${agents.n ?? "?"}`);
1053
- D.out(`활성 채팅 ${chats.n ?? "?"}`);
1054
- D.out(`메시지 24h ${msg24.n ?? 0} · 7d ${msg7.n ?? 0}`);
1055
- D.out(`자동화(켜짐) ${auto.n ?? 0}`);
1056
- D.out(`자동화 실행(7d) ${runs7.n ?? 0}${runs7.err ? ` (실패 ${runs7.err})` : ""}`);
1236
+ D.out(`Active runtime ${ar.kind || "(none)"}`);
1237
+ D.out(`Installed agents ${agents.n ?? "?"}`);
1238
+ D.out(`Active chats ${chats.n ?? "?"}`);
1239
+ D.out(`Messages 24h ${msg24.n ?? 0} · 7d ${msg7.n ?? 0}`);
1240
+ D.out(`Automations ${auto.n ?? 0}`);
1241
+ D.out(`Runs (7d) ${runs7.n ?? 0}${runs7.err ? ` (${runs7.err} failed)` : ""}`);
1057
1242
  D.out("");
1058
- D.out("세션 단위 토큰/비용은 대화 /cost, 프로바이더 쿼터 대시보드는 데스크탑 앱에서.");
1243
+ D.out("Session tokens/cost: /cost in chat. Provider quota dashboards are in Desktop.");
1059
1244
  }
1060
1245
 
1061
1246
  // ── telegram — 바인딩 현황 (읽기 전용) ──
@@ -1067,17 +1252,17 @@ function create(deps) {
1067
1252
  rows = [];
1068
1253
  }
1069
1254
  if (!rows.length) {
1070
- D.out("텔레그램 바인딩이 없습니다 페어링은 데스크탑 Connect에서 합니다.");
1255
+ D.out("No Telegram bindings. Pair devices from Desktop Connect.");
1071
1256
  return;
1072
1257
  }
1073
1258
  for (const r of rows) {
1074
- const bot = r.bot_username ? "@" + r.bot_username : "( 미지정)";
1075
- const chat = r.telegram_chat_title || r.telegram_chat_id || "(채팅 미연결)";
1259
+ const bot = r.bot_username ? "@" + r.bot_username : "(bot not set)";
1260
+ const chat = r.telegram_chat_title || r.telegram_chat_id || "(chat not connected)";
1076
1261
  const status = r.status || (r.telegram_chat_id ? "paired" : "pending");
1077
1262
  D.out(`${String(r.id).slice(0, 8)} ${r.target_kind}:${String(r.target_id).slice(0, 20).padEnd(21)} ${String(bot).padEnd(24)} ${String(chat).slice(0, 28).padEnd(29)} ${status}`);
1078
1263
  }
1079
1264
  D.out("");
1080
- D.out("페어링/봇 발급은 데스크탑 Connect에서, 여기서는 현황만 봅니다.");
1265
+ D.out("Pairing and bot issuance happen in Desktop Connect; this command only shows status.");
1081
1266
  }
1082
1267
 
1083
1268
  // ── login / logout / whoami — Agentlas Cloud 세션 (데스크탑과 동일한 loopback 브라우저 플로우) ──
@@ -1098,15 +1283,15 @@ function create(deps) {
1098
1283
 
1099
1284
  async function fetchSessionMeta(cookie) {
1100
1285
  const resp = await fetch(`${webBaseUrl()}/api/auth/session`, { headers: { cookie }, signal: AbortSignal.timeout(8000) });
1101
- if (!resp.ok) throw new Error(`세션 확인 응답 ${resp.status}`);
1286
+ if (!resp.ok) throw new Error(`Session check returned ${resp.status}`);
1102
1287
  return resp.json();
1103
1288
  }
1104
1289
 
1105
1290
  function loginCallbackHtml(ok) {
1106
- const title = ok ? "Agentlas 로그인 완료" : "Agentlas 로그인 실패";
1291
+ const title = ok ? "Agentlas login complete" : "Agentlas login failed";
1107
1292
  const body = ok
1108
- ? "터미널로 돌아가세요. 창은 닫아도 됩니다."
1109
- : "터미널로 돌아가 agentlas login 다시 실행하세요.";
1293
+ ? "Return to the terminal. You can close this window."
1294
+ : "Return to the terminal and run agentlas login again.";
1110
1295
  return `<!doctype html><html><head><meta charset="utf-8"><title>Agentlas</title></head><body style="font-family:-apple-system,system-ui,sans-serif;padding:40px"><h3>${title}</h3><p>${body}</p></body></html>`;
1111
1296
  }
1112
1297
 
@@ -1117,7 +1302,7 @@ function create(deps) {
1117
1302
  const state = createLoginState(options.randomBytes || crypto.randomBytes);
1118
1303
  const guard = createLoginCallbackGuard(state);
1119
1304
  const onLoginUrl = options.onLoginUrl || ((url) => {
1120
- D.out("브라우저에서 Agentlas 로그인하세요 (자동으로 열립니다):");
1305
+ D.out("Sign in to Agentlas in the browser (opening automatically):");
1121
1306
  D.out(" " + url);
1122
1307
  openInBrowser(url);
1123
1308
  });
@@ -1156,7 +1341,7 @@ function create(deps) {
1156
1341
  const address = server.address();
1157
1342
  const port = address && typeof address === "object" ? address.port : 0;
1158
1343
  if (!port) {
1159
- finish(new Error("로그인 loopback 포트를 열지 못했습니다."));
1344
+ finish(new Error("Could not open the login loopback port."));
1160
1345
  return;
1161
1346
  }
1162
1347
  const callback = new URL(`http://127.0.0.1:${port}${LOGIN_CALLBACK_PATH}`);
@@ -1165,13 +1350,13 @@ function create(deps) {
1165
1350
  try {
1166
1351
  loginUrl = new URL("/account", `${options.baseUrl || webBaseUrl()}/`);
1167
1352
  } catch {
1168
- finish(new Error("Agentlas 로그인 URL 올바르지 않습니다."));
1353
+ finish(new Error("The Agentlas login URL is invalid."));
1169
1354
  return;
1170
1355
  }
1171
1356
  loginUrl.searchParams.set("desktop", "1");
1172
1357
  loginUrl.searchParams.set("callback", callback.toString());
1173
1358
  timer = setTimeout(
1174
- () => finish(new Error(`로그인 대기 시간(${Math.ceil(timeoutMs / 1000)}초)이 지났습니다. 다시 시도: agentlas login`)),
1359
+ () => finish(new Error(`Login timed out after ${Math.ceil(timeoutMs / 1000)} seconds. Try: agentlas login`)),
1175
1360
  timeoutMs,
1176
1361
  );
1177
1362
  if (timer.unref) timer.unref();
@@ -1190,7 +1375,7 @@ function create(deps) {
1190
1375
  async function cmdWhoami() {
1191
1376
  const cookie = await D.cloudSessionCookieCli();
1192
1377
  if (!cookie) {
1193
- D.out("로그아웃 상태입니다. agentlas login 으로 로그인하세요.");
1378
+ D.out("You are signed out. Sign in with agentlas login.");
1194
1379
  process.exitCode = 1;
1195
1380
  return;
1196
1381
  }
@@ -1199,13 +1384,13 @@ function create(deps) {
1199
1384
  if (j && j.authenticated) {
1200
1385
  const email = (j.user && j.user.email) || "?";
1201
1386
  const ws = j.workspace || {};
1202
- D.out(`로그인됨: ${email} · 워크스페이스: ${ws.name || "?"} (${ws.plan || "free"})`);
1387
+ D.out(`Signed in: ${email} · workspace: ${ws.name || "?"} (${ws.plan || "free"})`);
1203
1388
  } else {
1204
- D.out("세션이 만료되었거나 유효하지 않습니다. agentlas login 으로 다시 로그인하세요.");
1389
+ D.out("The session is expired or invalid. Sign in again with agentlas login.");
1205
1390
  process.exitCode = 1;
1206
1391
  }
1207
1392
  } catch (e) {
1208
- D.fail("세션 확인 실패: " + String((e && e.message) || e));
1393
+ D.fail("Session check failed: " + String((e && e.message) || e));
1209
1394
  }
1210
1395
  }
1211
1396
 
@@ -1219,7 +1404,7 @@ function create(deps) {
1219
1404
  try {
1220
1405
  const j = await fetchSessionMeta(existing);
1221
1406
  if (j && j.authenticated) {
1222
- D.out(`이미 로그인돼 있습니다 (${(j.user && j.user.email) || "?"}). 재로그인: agentlas login --force`);
1407
+ D.out(`Already signed in (${(j.user && j.user.email) || "?"}). Re-authenticate with agentlas login --force`);
1223
1408
  return;
1224
1409
  }
1225
1410
  } catch { /* 확인 실패 — 새로 로그인 진행 */ }
@@ -1237,18 +1422,18 @@ function create(deps) {
1237
1422
  fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
1238
1423
  fs.writeFileSync(p, JSON.stringify({ version: 1, value, updatedAt: new Date().toISOString() }, null, 2) + "\n", { mode: 0o600 });
1239
1424
  try { fs.chmodSync(p, 0o600); } catch { /* win32 */ }
1240
- D.out(`세션 저장됨: ${p}`);
1425
+ D.out(`Session saved: ${p}`);
1241
1426
  await cmdWhoami();
1242
1427
  }
1243
1428
 
1244
1429
  function cmdLogout() {
1245
1430
  const p = D.cliSessionPath();
1246
1431
  if (fs.existsSync(p)) {
1247
- try { fs.rmSync(p); D.out("로그아웃 완료 (CLI 세션 삭제)."); } catch (e) { return D.fail("세션 파일 삭제 실패: " + e.message); }
1432
+ try { fs.rmSync(p); D.out("Signed out (CLI session deleted)."); } catch (e) { return D.fail("Could not delete the session file: " + e.message); }
1248
1433
  } else {
1249
- D.out("저장된 CLI 세션이 없습니다.");
1434
+ D.out("No saved CLI session.");
1250
1435
  }
1251
- if (process.env.AGENTLAS_SESSION) D.out("주의: AGENTLAS_SESSION 환경변수가 여전히 설정돼 있어 로그인 상태로 보일 있습니다.");
1436
+ if (process.env.AGENTLAS_SESSION) D.out("Warning: AGENTLAS_SESSION is still set, so the CLI may appear signed in.");
1252
1437
  }
1253
1438
 
1254
1439
  // ── cloud search — 마켓플레이스 검색 ──
@@ -1260,8 +1445,8 @@ function create(deps) {
1260
1445
  else rest.push(args[i]);
1261
1446
  }
1262
1447
  const query = rest.join(" ").trim();
1263
- if (!query) return D.fail('usage: agentlas cloud search "<찾는 일>" [--limit 10]');
1264
- if (typeof fetch !== "function") return D.fail(" 런타임에 fetch가 없습니다.");
1448
+ if (!query) return D.fail('usage: agentlas cloud search "<task>" [--limit 10]');
1449
+ if (typeof fetch !== "function") return D.fail("fetch is not available in this runtime.");
1265
1450
  const base = process.env.AGENTLAS_MCP_BASE_URL || "https://agentlas.cloud/api/mcp/v1";
1266
1451
  const headers = { "content-type": "application/json" };
1267
1452
  try {
@@ -1280,9 +1465,9 @@ function create(deps) {
1280
1465
  }),
1281
1466
  });
1282
1467
  } catch (e) {
1283
- return D.fail(`마켓플레이스 연결 실패: ${(e && e.message) || e}`);
1468
+ return D.fail(`Marketplace connection failed: ${(e && e.message) || e}`);
1284
1469
  }
1285
- if (!resp.ok) return D.fail(`마켓플레이스 응답 ${resp.status}`);
1470
+ if (!resp.ok) return D.fail(`Marketplace returned ${resp.status}`);
1286
1471
  const json = await resp.json();
1287
1472
  if (json.error) return D.fail(json.error.message || "marketplace error");
1288
1473
  const result = json.result || {};
@@ -1290,7 +1475,7 @@ function create(deps) {
1290
1475
  // 엔진 내부 에이전트(researcher-<n>, research-intelligence-desk, hephaestus-*)는 제품이 아니므로 숨긴다.
1291
1476
  const items = Array.isArray(rawItems) ? rawItems.filter((it) => !isInternalAgentSlug(it && (it.slug || it.id))) : rawItems;
1292
1477
  if (!Array.isArray(items) || !items.length) {
1293
- D.out(`검색 결과 없음: "${query}"`);
1478
+ D.out(`No results for "${query}"`);
1294
1479
  return;
1295
1480
  }
1296
1481
  for (const it of items.slice(0, limit)) {
@@ -1301,7 +1486,7 @@ function create(deps) {
1301
1486
  D.out(`${String(slug).padEnd(34).slice(0, 34)} ${String(name).slice(0, 26).padEnd(27)} ${String(kind).padEnd(14)} ${String(tagline).slice(0, 60)}`);
1302
1487
  }
1303
1488
  D.out("");
1304
- D.out("설치: agentlas install <slug>");
1489
+ D.out("Install: agentlas install <slug>");
1305
1490
  }
1306
1491
 
1307
1492
  return {