agentlas 1.0.11 → 1.0.14

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 (57) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +4 -2
  3. package/bin/agentlas.cjs +17 -3
  4. package/engine/agentlas-config.cjs +25 -18
  5. package/engine/agentlas-core-harness.cjs +14 -1
  6. package/engine/agentlas-i18n.cjs +2 -0
  7. package/engine/agentlas-input.cjs +62 -8
  8. package/engine/agentlas-memory-governance.cjs +10 -0
  9. package/engine/agentlas-onboard.cjs +22 -5
  10. package/engine/agentlas-sqlite-policy.cjs +18 -7
  11. package/engine/agentlas-workforce.cjs +989 -114
  12. package/engine/agentlas-workload-routing.cjs +61 -7
  13. package/engine/agentlas.cjs +8 -0
  14. package/engine/automation/daemon.cjs +76 -30
  15. package/engine/automation/schedule.cjs +16 -0
  16. package/engine/automation/store.cjs +92 -12
  17. package/engine/bootstrap-schema.sql +788 -29
  18. package/engine/commands/automation.cjs +8 -0
  19. package/engine/commands/chats.cjs +6 -1
  20. package/engine/commands/doctor.cjs +23 -1
  21. package/engine/commands/firm.cjs +66 -4
  22. package/engine/commands/help.cjs +31 -7
  23. package/engine/commands/hep-cloud.cjs +31 -0
  24. package/engine/commands/hep-hub.cjs +30 -0
  25. package/engine/commands/hep-local.cjs +32 -0
  26. package/engine/commands/hep-network.cjs +43 -0
  27. package/engine/commands/index.cjs +38 -7
  28. package/engine/commands/list.cjs +25 -1
  29. package/engine/commands/open.cjs +5 -1
  30. package/engine/commands/run.cjs +77 -13
  31. package/engine/commands/setup.cjs +12 -11
  32. package/engine/commands/storm.cjs +5 -9
  33. package/engine/commands/swarm.cjs +4 -4
  34. package/engine/commands/uninstall.cjs +36 -2
  35. package/engine/commands/version.cjs +29 -0
  36. package/engine/commands/workforce.cjs +10 -10
  37. package/engine/core/schema-ensure.cjs +75 -0
  38. package/engine/experience/variant.cjs +46 -2
  39. package/engine/firms/orchestrate.cjs +10 -3
  40. package/engine/hephaestus/runtime.cjs +7 -0
  41. package/engine/memory-cli/curate.cjs +3 -7
  42. package/engine/project/memory-context.cjs +3 -7
  43. package/engine/project/state.cjs +7 -6
  44. package/engine/runtimes/overrides.cjs +91 -22
  45. package/engine/runtimes/resolve.cjs +38 -8
  46. package/engine/runtimes/roles.cjs +162 -0
  47. package/engine/sessions/orchestrator.cjs +43 -4
  48. package/engine/sessions/prompt.cjs +4 -7
  49. package/engine/sessions/session.cjs +88 -20
  50. package/engine/storm/swarm.cjs +40 -12
  51. package/engine/ui/palette.cjs +52 -0
  52. package/engine/ui/renderer.cjs +37 -0
  53. package/engine/ui/repl.cjs +78 -10
  54. package/engine/workforce/capture.cjs +199 -14
  55. package/engine/workforce/concurrency.cjs +41 -0
  56. package/engine/workforce/deps.cjs +145 -29
  57. package/package.json +1 -1
@@ -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,
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ /*
3
+ * workforce/concurrency — 워크포스 워커 동시 실행 수(스웜 크기)의 사양 기반 추천값.
4
+ *
5
+ * 워커 1명 = captureRuntime을 통해 스폰되는 실제 CLI 자식 프로세스(engine/workforce/
6
+ * capture.cjs)다. 이전에는 사용자가 --parallel/-n을 안 주면 컴퓨터 사양과 무관하게
7
+ * 고정 3(상한 8)을 썼다 — 코어 2개짜리 저사양 기기에서 3개 CLI 자식이 한꺼번에 뜨면
8
+ * 과다 산정, 32코어 워크스테이션에서도 항상 3으로 저평가되는 양방향 문제였다.
9
+ *
10
+ * 데스크탑(electron/store/concurrency.ts)과 동일한 원리(코어 2개는 OS/자식 자신에게
11
+ * 남기고, 에이전트당 RAM ~2GB + 여유 4GB로 추정)를 쓰되, 상한(HARD_MAX)은 터미널
12
+ * 자체의 기존 안전선(8)을 그대로 유지한다 — 이건 provider가 바뀌어도 변하지 않는
13
+ * Agentlas 자체 정책 상수이지, 하드코딩 금지 대상인 "외부에서 바뀌는 값"이 아니다.
14
+ */
15
+ const os = require("node:os");
16
+
17
+ const HARD_MAX = 8;
18
+
19
+ function getSystemSpecs() {
20
+ let cores = 4;
21
+ let totalMemGB = 8;
22
+ try {
23
+ cores = Math.max(1, os.cpus().length);
24
+ } catch {
25
+ // fall back
26
+ }
27
+ try {
28
+ totalMemGB = os.totalmem() / 1024 ** 3;
29
+ } catch {
30
+ // fall back
31
+ }
32
+ return { cores, totalMemGB };
33
+ }
34
+
35
+ function recommendedConcurrency(specs = getSystemSpecs()) {
36
+ const coreBound = Math.max(1, specs.cores - 2);
37
+ const memBound = Math.max(1, Math.floor((specs.totalMemGB - 4) / 2));
38
+ return Math.max(1, Math.min(coreBound, memBound, HARD_MAX));
39
+ }
40
+
41
+ module.exports = { HARD_MAX, getSystemSpecs, recommendedConcurrency };
@@ -32,49 +32,116 @@ const path = require("node:path");
32
32
  const { userDataDir } = require("../core/paths.cjs");
33
33
  const hubClient = require("../cloud/hub-client.cjs");
34
34
  const detect = require("../runtimes/detect.cjs");
35
+ const { resolvedModelRole } = require("../runtimes/roles.cjs");
35
36
  const capture = require("./capture.cjs");
36
37
 
37
38
  // ── 런타임 해석 (v1 resolveRuntime의 워크포스 어댑터) ─────────────────────
38
- // 워크포스 모듈이 기대하는 형태: {mode:"cli", kind, model?} | {mode:"api", backend, model}.
39
- // v2 runtimes/resolve.cjs는 {kind, bin, source}만 주고 BYOK/Ollama를 모르므로,
40
- // v1의 사다리(명시 override > prefs 저장값 > 공유 DB active_runtime(byok/ollama 포함)
41
- // > PATH 탐지)를 여기서 복원한다. 아무것도 없으면 no_runtime 정직 정지 — 폴백 금지.
42
- function resolveWorkforceRuntime(db, override) {
43
- if (!override) {
39
+ // 워크포스 모듈이 기대하는 형태:
40
+ // {mode:"cli", kind, model?, effort?} | {mode:"api", backend, model, effort?}
41
+ // 반환 정본은 orchestrator 런타임이며 roleRuntimes에 역할의 실제 실행 런타임을
42
+ // 동봉한다. stage별 선택은 agentlas-workforce.cjs가 수행한다.
43
+ function runtimeFromSelection(selection) {
44
+ if (!selection || !selection.kind) return null;
45
+ const common = {
46
+ model: selection.model || null,
47
+ effort: selection.effort || null,
48
+ capabilities: ["code", "tools", ...(selection.longContext ? ["long-context"] : [])],
49
+ efforts: [],
50
+ source: selection.sourceLayer || selection.source || null,
51
+ role: selection.role || null,
52
+ };
53
+ if (capture.RUNTIME_BIN[selection.kind]) {
54
+ return { mode: "cli", kind: selection.kind, ...common };
55
+ }
56
+ if (selection.kind === "byok" && selection.backend) {
57
+ return { mode: "api", backend: selection.backend, ...common };
58
+ }
59
+ if (selection.kind === "ollama") {
60
+ return { mode: "api", backend: "ollama", ...common };
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function legacyWorkforceRuntime(db, override) {
66
+ let selectedOverride = override;
67
+ if (!selectedOverride) {
44
68
  try {
45
69
  const saved = require("../agentlas-config.cjs").loadPrefs(userDataDir()).runtime;
46
- if (saved && saved !== "auto" && capture.RUNTIME_BIN[saved] && capture.which(capture.RUNTIME_BIN[saved])) override = saved;
70
+ if (
71
+ saved &&
72
+ saved !== "auto" &&
73
+ capture.RUNTIME_BIN[saved] &&
74
+ capture.which(capture.RUNTIME_BIN[saved])
75
+ ) {
76
+ selectedOverride = saved;
77
+ }
47
78
  } catch { /* prefs 없음 — 사다리 계속 */ }
48
79
  }
49
80
  const ar = db ? detect.activeRuntimeRow(db) : null;
50
- const activeCli = ar && capture.RUNTIME_BIN[ar.kind]
51
- ? {
52
- mode: "cli",
81
+ const active = ar
82
+ ? runtimeFromSelection({
83
+ role: "orchestrator",
53
84
  kind: ar.kind,
54
- model: ar.model || null,
55
- capabilities: ["code", "tools", ...(ar.long_context ? ["long-context"] : [])],
56
- efforts: [],
57
- }
85
+ backend: ar.backend,
86
+ source: ar.source,
87
+ model: ar.model,
88
+ effort: null,
89
+ longContext: Boolean(ar.long_context),
90
+ sourceLayer: "active-runtime",
91
+ })
58
92
  : null;
59
- if (override) {
60
- if (!capture.RUNTIME_BIN[override]) {
61
- const error = new Error(`unknown workforce runtime: ${override} (capture drivers: ${Object.keys(capture.RUNTIME_BIN).join(", ")})`);
93
+ if (selectedOverride) {
94
+ if (!capture.RUNTIME_BIN[selectedOverride]) {
95
+ const error = new Error(
96
+ `unknown workforce runtime: ${selectedOverride} (capture drivers: ${Object.keys(capture.RUNTIME_BIN).join(", ")})`,
97
+ );
62
98
  error.code = "no_runtime";
63
99
  throw error;
64
100
  }
65
- return activeCli && activeCli.kind === override ? activeCli : { mode: "cli", kind: override };
101
+ return active && active.mode === "cli" && active.kind === selectedOverride
102
+ ? active
103
+ : { mode: "cli", kind: selectedOverride, model: null, effort: null };
66
104
  }
67
- if (activeCli) return activeCli;
68
- if (ar && ar.kind === "byok" && ar.backend) return { mode: "api", backend: ar.backend, model: ar.model };
69
- if (ar && ar.kind === "ollama") return { mode: "api", backend: "ollama", model: ar.model };
105
+ if (active) return active;
70
106
  for (const kind of Object.keys(capture.RUNTIME_BIN)) {
71
- if (capture.which(capture.RUNTIME_BIN[kind])) return { mode: "cli", kind };
107
+ if (capture.which(capture.RUNTIME_BIN[kind])) {
108
+ return { mode: "cli", kind, model: null, effort: null, source: "detected" };
109
+ }
72
110
  }
73
- const error = new Error("no_runtime: no agent CLI or connected API runtime found (claude / codex / gemini / BYOK / Ollama).");
111
+ const error = new Error(
112
+ "no_runtime: no agent CLI or connected API runtime found (claude / codex / gemini / BYOK / Ollama).",
113
+ );
74
114
  error.code = "no_runtime";
75
115
  throw error;
76
116
  }
77
117
 
118
+ // 사다리: 명시 override > model_roles[role] > 레거시 prefs/active/PATH.
119
+ // worker가 비어 있거나 inherit=1이면 roles.cjs가 orchestrator로만 승격한다.
120
+ function resolveWorkforceRuntime(db, override) {
121
+ if (override) {
122
+ const exact = legacyWorkforceRuntime(db, override);
123
+ return {
124
+ ...exact,
125
+ role: "orchestrator",
126
+ roleRuntimes: {
127
+ orchestrator: { ...exact, role: "orchestrator" },
128
+ worker: { ...exact, role: "worker" },
129
+ },
130
+ };
131
+ }
132
+ const fallback = legacyWorkforceRuntime(db, null);
133
+ const orchestrator = runtimeFromSelection(resolvedModelRole(db, "orchestrator")) || fallback;
134
+ const worker = runtimeFromSelection(resolvedModelRole(db, "worker")) || orchestrator;
135
+ return {
136
+ ...orchestrator,
137
+ role: "orchestrator",
138
+ roleRuntimes: {
139
+ orchestrator: { ...orchestrator, role: "orchestrator" },
140
+ worker: { ...worker, role: "worker" },
141
+ },
142
+ };
143
+ }
144
+
78
145
  // ── 영수증 (v1 모놀리스의 JSONL 계약 포팅: userData 하위, 0700/0600) ──────
79
146
  function receiptFile() {
80
147
  return path.join(userDataDir(), "workforce-execution-receipts.jsonl");
@@ -112,13 +179,49 @@ function persistBenchmarkArtifact(artifact, executionIdHint) {
112
179
  // 보존하되 runtimeIds=[] / status="observed-not-executable"로 광고한다 —
113
180
  // 필요 capability에 대해 워크포스 모듈이 플래너 전에 fail-closed 하게 된다.
114
181
  // 권한을 제조하는 것보다 정직 정지가 계약이다.
115
- async function listWorkforceTools({ db, roster, cwd, env, timeoutMs, signal }) {
182
+ // 워커가 실제로 부여받을 있는 유일한 네이티브 도구 집합 — 읽기 전용.
183
+ // claude-code는 plan 모드(쓰기·실행 거부) + --allowedTools 로 이 경계를 강제할 수 있어
184
+ // "정확 per-tool 부착"이 증명된다. 쓰기·셸·네트워크·MCP는 여전히 증명 불가라 잠긴 채다.
185
+ const READ_ONLY_NATIVE_TOOLS = ["Read", "Grep", "Glob"];
186
+ const READ_ONLY_BUILTIN_TOOL_ID = "builtin:file-read";
187
+ const READ_ONLY_CAPABILITY_IDS = ["tool:file-read"];
188
+ const READ_ONLY_RUNTIME_IDS = ["runtime:claude-code"];
189
+
190
+ function readOnlyBuiltinToolRows(roster, runtimeId) {
191
+ if (!READ_ONLY_RUNTIME_IDS.includes(String(runtimeId || ""))) return [];
192
+ const rows = [];
193
+ for (const pinned of roster || []) {
194
+ // 허브가 이 릴리스에 파일 읽기를 허용했을 때만. deny면 존중하고 부여하지 않는다.
195
+ if (pinned?.permissionPolicy?.fileRead?.mode !== "manifest-allowlist") continue;
196
+ rows.push({
197
+ slotId: pinned.slotId,
198
+ agentReleaseId: pinned.agentReleaseId,
199
+ permissionPolicyDigest: pinned.permissionPolicyDigest,
200
+ provider: "builtin",
201
+ toolId: READ_ONLY_BUILTIN_TOOL_ID,
202
+ // 내장 도구는 MCP 서버가 없다. validateToolInventory는 serverId가 정확히 null이
203
+ // 아니면 거절한다(2026-07-27 라이브: "builtin" 문자열을 넣어 prepare 직후 전량 폐기).
204
+ serverId: null,
205
+ description: "Read-only project file access (Read/Grep/Glob, no write, no shell)",
206
+ inputSchemaDigest: null,
207
+ runtimeIds: [...READ_ONLY_RUNTIME_IDS],
208
+ selectiveEnforcement: "exact-tool-allowlist",
209
+ capabilityIds: [...READ_ONLY_CAPABILITY_IDS],
210
+ status: "ready",
211
+ });
212
+ }
213
+ return rows;
214
+ }
215
+
216
+ async function listWorkforceTools({ db, roster, cwd, env, timeoutMs, signal, runtimeId }) {
116
217
  const mcp = require("../mcp/index.cjs");
218
+ // 읽기 전용 내장 도구는 MCP 서버 유무와 무관하게 항상 제공한다.
219
+ const builtinRows = readOnlyBuiltinToolRows(roster, runtimeId);
117
220
  const servers = mcp.readConsentedSystemMcpServers(db, {
118
221
  userDataDir: userDataDir(),
119
222
  createRuntimeHome: false,
120
223
  }).slice(0, 8);
121
- if (!servers.length) return [];
224
+ if (!servers.length) return builtinRows;
122
225
  const deadline = Date.now() + Math.max(50, Math.min(12_000, Number(timeoutMs) || 12_000));
123
226
  const outcomes = new Array(servers.length);
124
227
  let cursor = 0;
@@ -140,7 +243,7 @@ async function listWorkforceTools({ db, roster, cwd, env, timeoutMs, signal }) {
140
243
  await Promise.all(Array.from({ length: Math.min(3, servers.length) }, () => worker()));
141
244
 
142
245
  const safeId = /^[A-Za-z0-9][A-Za-z0-9_.$:/@+~-]{0,127}$/;
143
- const rows = [];
246
+ const rows = [...builtinRows];
144
247
  for (let index = 0; index < servers.length; index += 1) {
145
248
  const server = servers[index];
146
249
  const listed = outcomes[index];
@@ -444,9 +547,19 @@ function buildWorkforceDeps(ctx = {}) {
444
547
  appendAuditReceipt,
445
548
  persistBenchmarkArtifact,
446
549
  listWorkforceTools,
447
- // Terminal 원샷 러너는 네이티브 per-tool 권한 부여 경계를 아직 증명하지 못했다.
448
- // 증명 없이 true를 돌려주면 권한 제조가 된다 — v1과 동일하게 항상 false.
449
- supportsWorkforceToolAuthority: async () => false,
550
+ // 호스트가 자기 도구를 빌려주는 결정은 허브 후보 자격과 무관하다.
551
+ // requiredToolCapabilities는 "이 허브 에이전트가 도구를 선언했는가"라는
552
+ // 후보 필터라서, 선언한 에이전트가 사실상 0이라 그걸 쓰면 후보가 0건이 된다
553
+ // (2026-07-27 실측: 그래서 리더가 절대 선언하지 않았고 부여가 영영 발동 안 됨).
554
+ // 읽기 권한 대여는 허브가 그 릴리스에 파일 읽기를 허용했는지만 보면 된다.
555
+ hostReadOnlyGrants: (roster, runtimeId) => readOnlyBuiltinToolRows(roster, runtimeId),
556
+ // 읽기 전용 내장 도구는 claude-code plan 모드 + --allowedTools/--disallowedTools로
557
+ // 정확 경계가 증명된다 → 부여 허용. 그 밖(쓰기·셸·네트워크·MCP)은 여전히 증명
558
+ // 불가라 거부한다. 증명 없이 true를 돌려주면 권한 제조가 된다.
559
+ supportsWorkforceToolAuthority: async ({ grantedToolIds }) =>
560
+ Array.isArray(grantedToolIds)
561
+ && grantedToolIds.length > 0
562
+ && grantedToolIds.every((id) => id === READ_ONLY_BUILTIN_TOOL_ID),
450
563
  bindWorkforceGoal,
451
564
  loadWorkforceGoalRuntime,
452
565
  recordWorkforceGoalTurn,
@@ -472,6 +585,9 @@ module.exports = {
472
585
  appendAuditReceipt,
473
586
  persistBenchmarkArtifact,
474
587
  listWorkforceTools,
588
+ readOnlyBuiltinToolRows,
589
+ READ_ONLY_NATIVE_TOOLS,
590
+ READ_ONLY_BUILTIN_TOOL_ID,
475
591
  bindWorkforceGoal,
476
592
  loadWorkforceGoalRuntime,
477
593
  completeWorkforceGoal,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlas",
3
- "version": "1.0.11",
3
+ "version": "1.0.14",
4
4
  "description": "Agentlas agent terminal — chat with your installed AI agents and teams from the terminal, Claude Code style. Standalone: no desktop app required.",
5
5
  "bin": {
6
6
  "agentlas": "bin/agentlas.cjs"