agentlas 1.0.64 → 1.0.66

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 (164) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +8 -6
  3. package/bin/agentlas.cjs +341 -16
  4. package/engine/acp/server.cjs +27 -1
  5. package/engine/agentlas-api-agent.cjs +151 -120
  6. package/engine/agentlas-banner.cjs +6 -2
  7. package/engine/agentlas-capabilities.cjs +17 -10
  8. package/engine/agentlas-cloud-runtime.cjs +4 -5
  9. package/engine/agentlas-config.cjs +19 -54
  10. package/engine/agentlas-core-harness.cjs +59 -5
  11. package/engine/agentlas-desktop-loadout.cjs +17 -3
  12. package/engine/agentlas-evolution.cjs +75 -18
  13. package/engine/agentlas-experience-exchange.cjs +145 -14
  14. package/engine/agentlas-experience-intake.cjs +2 -2
  15. package/engine/agentlas-i18n.cjs +0 -2
  16. package/engine/agentlas-input.cjs +273 -20
  17. package/engine/agentlas-judgment.cjs +0 -0
  18. package/engine/agentlas-mcp-env.cjs +84 -23
  19. package/engine/agentlas-mcp-wrapper.cjs +36 -4
  20. package/engine/agentlas-memory-governance.cjs +2 -1
  21. package/engine/agentlas-memory-import.cjs +49 -12
  22. package/engine/agentlas-native-host.cjs +279 -44
  23. package/engine/agentlas-onboard.cjs +23 -1
  24. package/engine/agentlas-permissions.cjs +5 -1
  25. package/engine/agentlas-sqlite-policy.cjs +40 -2
  26. package/engine/agentlas-tasks.cjs +7 -0
  27. package/engine/agentlas-tools.cjs +37 -10
  28. package/engine/agentlas-ui.cjs +26 -4
  29. package/engine/agentlas-workforce.cjs +29 -5
  30. package/engine/agentlas-workload-routing.cjs +83 -10
  31. package/engine/agentlas.cjs +10 -3
  32. package/engine/agents/builder.cjs +35 -10
  33. package/engine/agents/files.cjs +81 -10
  34. package/engine/agents/import-local.cjs +10 -2
  35. package/engine/agents/registry.cjs +10 -3
  36. package/engine/agents/router.cjs +7 -7
  37. package/engine/agents/routes.cjs +31 -9
  38. package/engine/automation/daemon.cjs +154 -40
  39. package/engine/automation/launchd.cjs +395 -16
  40. package/engine/automation/schedule.cjs +90 -3
  41. package/engine/automation/store.cjs +163 -59
  42. package/engine/bootstrap-schema.sql +4 -4
  43. package/engine/browser/cdp.cjs +315 -32
  44. package/engine/browser/vault.cjs +1 -0
  45. package/engine/cli-output.cjs +80 -28
  46. package/engine/cloud/auth.cjs +131 -13
  47. package/engine/cloud/hub-client.cjs +41 -16
  48. package/engine/cloud-assets/cas.cjs +18 -9
  49. package/engine/cloud-assets/commands.cjs +108 -30
  50. package/engine/cloud-assets/package.cjs +66 -3
  51. package/engine/cloud-assets/restore.cjs +12 -9
  52. package/engine/cloud-assets/state.cjs +308 -113
  53. package/engine/commands/acp.cjs +13 -2
  54. package/engine/commands/automation.cjs +75 -24
  55. package/engine/commands/billing.cjs +6 -1
  56. package/engine/commands/browser.cjs +67 -34
  57. package/engine/commands/build.cjs +42 -12
  58. package/engine/commands/call.cjs +1 -1
  59. package/engine/commands/cd.cjs +1 -1
  60. package/engine/commands/cloud.cjs +2 -1
  61. package/engine/commands/connect.cjs +38 -13
  62. package/engine/commands/creds.cjs +74 -29
  63. package/engine/commands/doctor.cjs +17 -1
  64. package/engine/commands/document.cjs +40 -12
  65. package/engine/commands/env.cjs +6 -1
  66. package/engine/commands/firm.cjs +42 -12
  67. package/engine/commands/graph.cjs +36 -32
  68. package/engine/commands/help.cjs +11 -1
  69. package/engine/commands/hep.cjs +1 -1
  70. package/engine/commands/import.cjs +5 -4
  71. package/engine/commands/index.cjs +1 -1
  72. package/engine/commands/install.cjs +6 -4
  73. package/engine/commands/list.cjs +56 -16
  74. package/engine/commands/login.cjs +5 -0
  75. package/engine/commands/logout.cjs +6 -1
  76. package/engine/commands/mcp.cjs +8 -2
  77. package/engine/commands/memory.cjs +3 -2
  78. package/engine/commands/multimodal.cjs +12 -0
  79. package/engine/commands/native.cjs +5 -1
  80. package/engine/commands/one.cjs +78 -33
  81. package/engine/commands/plugin.cjs +13 -2
  82. package/engine/commands/project.cjs +15 -12
  83. package/engine/commands/research.cjs +18 -7
  84. package/engine/commands/roles.cjs +104 -28
  85. package/engine/commands/route.cjs +2 -2
  86. package/engine/commands/run.cjs +42 -9
  87. package/engine/commands/search.cjs +23 -2
  88. package/engine/commands/setup.cjs +6 -1
  89. package/engine/commands/storm.cjs +44 -6
  90. package/engine/commands/swarm.cjs +47 -9
  91. package/engine/commands/telegram.cjs +6 -1
  92. package/engine/commands/uninstall.cjs +12 -6
  93. package/engine/commands/update.cjs +5 -0
  94. package/engine/commands/upload.cjs +2 -1
  95. package/engine/commands/usage.cjs +6 -1
  96. package/engine/commands/version.cjs +6 -1
  97. package/engine/commands/whoami.cjs +6 -1
  98. package/engine/commands/workforce.cjs +60 -16
  99. package/engine/core/capability-grants.cjs +7 -2
  100. package/engine/core/daemon-client.cjs +36 -7
  101. package/engine/core/db.cjs +10 -2
  102. package/engine/core/desktop-core-fetch.cjs +477 -77
  103. package/engine/core/schema-ensure.cjs +23 -11
  104. package/engine/experience/build.cjs +5 -1
  105. package/engine/experience/intents.cjs +29 -82
  106. package/engine/experience/runtime.cjs +7 -1
  107. package/engine/experience/variant.cjs +58 -4
  108. package/engine/firms/orchestrate.cjs +9 -6
  109. package/engine/graph/ask-model.cjs +22 -11
  110. package/engine/graph/interview.cjs +74 -23
  111. package/engine/graph/package.cjs +63 -8
  112. package/engine/hephaestus/local-core.cjs +18 -5
  113. package/engine/hephaestus/runtime.cjs +48 -43
  114. package/engine/hub/install.cjs +230 -61
  115. package/engine/hub/plugins.cjs +391 -29
  116. package/engine/mcp/consent.cjs +25 -17
  117. package/engine/mcp/contract.cjs +229 -24
  118. package/engine/mcp/inventory.cjs +15 -5
  119. package/engine/mcp/plan.cjs +5 -1
  120. package/engine/mcp/probe.cjs +24 -9
  121. package/engine/memory-cli/curate.cjs +74 -16
  122. package/engine/oberon/common.cjs +37 -12
  123. package/engine/oberon/manifest.cjs +25 -2
  124. package/engine/oberon/outputs.cjs +49 -19
  125. package/engine/oberon/render.cjs +665 -40
  126. package/engine/project/career-graph.cjs +15 -2
  127. package/engine/project/controller.cjs +133 -15
  128. package/engine/project/credentials.cjs +212 -34
  129. package/engine/project/env-file.cjs +93 -8
  130. package/engine/project/memory-context.cjs +11 -57
  131. package/engine/project/ontology.cjs +66 -25
  132. package/engine/project/paths.cjs +42 -8
  133. package/engine/project/seed.cjs +88 -51
  134. package/engine/project/state.cjs +41 -16
  135. package/engine/project/team.cjs +26 -1
  136. package/engine/runtime-refusal.cjs +4 -1
  137. package/engine/runtimes/acp-driver.cjs +56 -9
  138. package/engine/runtimes/auth-evidence.cjs +21 -2
  139. package/engine/runtimes/detect.cjs +28 -8
  140. package/engine/runtimes/kinds.cjs +12 -4
  141. package/engine/runtimes/overrides.cjs +4 -3
  142. package/engine/runtimes/resolve.cjs +8 -7
  143. package/engine/runtimes/roles.cjs +7 -5
  144. package/engine/sessions/apply-fences.cjs +396 -56
  145. package/engine/sessions/fences.cjs +111 -27
  146. package/engine/sessions/memory-turn.cjs +62 -3
  147. package/engine/sessions/orchestrator.cjs +11 -1
  148. package/engine/sessions/prompt.cjs +66 -9
  149. package/engine/sessions/session.cjs +70 -10
  150. package/engine/sessions/sink.cjs +78 -12
  151. package/engine/sessions/store.cjs +29 -7
  152. package/engine/storm/storm.cjs +26 -3
  153. package/engine/storm/swarm.cjs +31 -3
  154. package/engine/telegram/connect.cjs +187 -24
  155. package/engine/ui/commands-catalog.cjs +1 -1
  156. package/engine/ui/palette.cjs +12 -2
  157. package/engine/ui/repl.cjs +57 -9
  158. package/engine/ui/screens.cjs +2 -4
  159. package/engine/vendor/desktop-core.manifest.json +5 -5
  160. package/engine/workforce/capture.cjs +9 -27
  161. package/engine/workforce/deps.cjs +18 -6
  162. package/engine/workforce/local-core-transport.cjs +68 -5
  163. package/package.json +1 -1
  164. package/engine/commands/career-graph.cjs +0 -51
@@ -32,42 +32,93 @@ const AUTOMATION_HEADING = "## Automation";
32
32
  const ASK_OPEN = "<<agentlas-ask>>";
33
33
  const ASK_CLOSE = "<</agentlas-ask>>";
34
34
 
35
+ // Fence payloads are model-authored input. Keep parsing and downstream
36
+ // side-effect loops bounded; oversized entries are refused, not truncated.
37
+ const MAX_FENCE_JSON_BYTES = 256 * 1024;
38
+ const MAX_DELEGATIONS = 64;
39
+ const MAX_AUTOMATIONS = 64;
40
+ const MAX_ASKS = 32;
41
+ const MAX_STEPS = 32;
42
+ const MAX_DELEGATE_ENTRY_BYTES = 16 * 1024;
43
+ const MAX_AUTOMATION_ENTRY_BYTES = 32 * 1024;
44
+ const MAX_STEPS_BYTES = 64 * 1024;
45
+ const MAX_TARGET_BYTES = 256;
46
+ const MAX_BRIEF_BYTES = 8 * 1024;
47
+ const MAX_NAME_BYTES = 256;
48
+ const MAX_PROMPT_BYTES = 16 * 1024;
49
+ const MAX_AGENT_BYTES = 256;
50
+ const MAX_SCHEDULE_BYTES = 512;
51
+ const MAX_ASK_BODY_BYTES = 64 * 1024;
52
+
53
+ function utf8Bytes(value) {
54
+ return Buffer.byteLength(String(value || ""), "utf8");
55
+ }
56
+
57
+ function boundedString(value, maxBytes) {
58
+ if (typeof value !== "string") return "";
59
+ const trimmed = value.trim();
60
+ return utf8Bytes(trimmed) <= maxBytes ? trimmed : null;
61
+ }
62
+
63
+ function jsonBytes(value) {
64
+ try { return utf8Bytes(JSON.stringify(value)); } catch { return Infinity; }
65
+ }
66
+
35
67
  /* ── ## Delegate (delegate.ts parseDelegations 포팅) ─────────────────────── */
36
68
 
37
69
  function parseDelegateBlock(text) {
38
70
  const idx = text.lastIndexOf(DELEGATE_HEADING);
39
- if (idx < 0) return { delegations: [], synthesisAllocation: null, cleanedText: text.trim() };
71
+ if (idx < 0) return { delegations: [], synthesisAllocation: null, cleanedText: text.trim(), errors: [] };
40
72
 
41
73
  const after = text.slice(idx + DELEGATE_HEADING.length);
42
74
  const fence = after.match(/```(?:json)?\s*([\s\S]*?)```/);
43
75
  let delegations = [];
44
76
  let synthesisAllocation = null;
77
+ const errors = [];
45
78
  if (fence) {
46
- try {
47
- const data = JSON.parse(fence[1].trim());
79
+ const jsonText = fence[1].trim();
80
+ if (utf8Bytes(jsonText) > MAX_FENCE_JSON_BYTES) {
81
+ errors.push("Delegate JSON exceeds " + MAX_FENCE_JSON_BYTES + " bytes");
82
+ } else try {
83
+ const data = JSON.parse(jsonText);
48
84
  const rawDelegations = Array.isArray(data)
49
85
  ? data
50
86
  : data && typeof data === "object" && Array.isArray(data.delegations)
51
87
  ? data.delegations
52
88
  : [];
53
- if (!Array.isArray(data) && data && typeof data === "object" && data.synthesis) {
54
- synthesisAllocation = workloadRouting.normalizeAllocation(data.synthesis, "synthesize");
89
+ if (rawDelegations.length > MAX_DELEGATIONS) {
90
+ errors.push("Delegate block exceeds maximum of " + MAX_DELEGATIONS + " entries");
91
+ } else {
92
+ if (!Array.isArray(data) && data && typeof data === "object" && data.synthesis) {
93
+ synthesisAllocation = workloadRouting.normalizeAllocation(data.synthesis, "synthesize");
94
+ }
95
+ delegations = rawDelegations
96
+ .map((d, index) => {
97
+ if (!d || typeof d !== "object" || Array.isArray(d)) return null;
98
+ if (jsonBytes(d) > MAX_DELEGATE_ENTRY_BYTES) {
99
+ errors.push("Delegate entry " + (index + 1) + " exceeds " + MAX_DELEGATE_ENTRY_BYTES + " bytes");
100
+ return null;
101
+ }
102
+ const target = boundedString(d.target, MAX_TARGET_BYTES);
103
+ const brief = boundedString(d.brief, MAX_BRIEF_BYTES);
104
+ if (target === null || brief === null) {
105
+ errors.push("Delegate entry " + (index + 1) + " contains an oversized string");
106
+ return null;
107
+ }
108
+ // Desktop 계약: target 만 필수. allocation 은 정규화 실패 시 null 로 남고
109
+ // (레거시 텍스트 위임과 동일) 실행 측이 현재 모델 폴백 영수증을 남긴다.
110
+ return target
111
+ ? { target, brief, allocation: workloadRouting.normalizeAllocation(d.allocation, "delegate") }
112
+ : null;
113
+ })
114
+ .filter(Boolean);
55
115
  }
56
- delegations = rawDelegations
57
- .map((d) => {
58
- if (!d || typeof d !== "object") return null;
59
- const target = typeof d.target === "string" ? d.target.trim() : "";
60
- const brief = typeof d.brief === "string" ? d.brief.trim() : "";
61
- // Desktop 계약: target 만 필수. allocation 은 정규화 실패 시 null 로 남고
62
- // (레거시 텍스트 위임과 동일) 실행 측이 현재 모델 폴백 영수증을 남긴다.
63
- return target
64
- ? { target, brief, allocation: workloadRouting.normalizeAllocation(d.allocation, "delegate") }
65
- : null;
66
- })
67
- .filter(Boolean);
68
116
  } catch {
69
117
  delegations = [];
118
+ errors.push("Delegate JSON parse failed");
70
119
  }
120
+ } else {
121
+ errors.push("Delegate heading present but no JSON fence found");
71
122
  }
72
123
 
73
124
  let cut = text.length;
@@ -77,7 +128,7 @@ function parseDelegateBlock(text) {
77
128
  cut = idx; // 펜스 없으면 dangling heading 도 제거 (Desktop 과 동일)
78
129
  }
79
130
  const cleanedText = (text.slice(0, idx) + text.slice(cut)).trim();
80
- return { delegations, synthesisAllocation, cleanedText };
131
+ return { delegations, synthesisAllocation, cleanedText, errors };
81
132
  }
82
133
 
83
134
  /* ── ## Automation (automation-emitter.ts parseAutomations 포팅, 정직 거부판) ── */
@@ -182,28 +233,58 @@ function parseAutomationBlock(text) {
182
233
  let automations = [];
183
234
 
184
235
  if (fence) {
185
- try {
186
- const data = JSON.parse(fence[1].trim());
236
+ const jsonText = fence[1].trim();
237
+ if (utf8Bytes(jsonText) > MAX_FENCE_JSON_BYTES) {
238
+ errors.push("Automation JSON exceeds " + MAX_FENCE_JSON_BYTES + " bytes");
239
+ } else try {
240
+ const data = JSON.parse(jsonText);
187
241
  if (Array.isArray(data)) {
188
- automations = data
189
- .map((d) => {
242
+ if (data.length > MAX_AUTOMATIONS) {
243
+ errors.push("Automation block exceeds maximum of " + MAX_AUTOMATIONS + " entries");
244
+ } else {
245
+ automations = data
246
+ .map((d, index) => {
190
247
  if (!d || typeof d !== "object") {
191
248
  errors.push("Automation entry was not an object");
192
249
  return null;
193
250
  }
194
- const name = typeof d.name === "string" ? d.name.trim() : "";
195
- const prompt = typeof d.prompt === "string" ? d.prompt.trim() : "";
196
- const agent = typeof d.agent === "string" ? d.agent.trim() : "";
197
- const hubAgent = typeof d.hubAgent === "string" ? d.hubAgent.trim() : "";
251
+ if (jsonBytes(d) > MAX_AUTOMATION_ENTRY_BYTES) {
252
+ errors.push("Automation entry " + (index + 1) + " exceeds " + MAX_AUTOMATION_ENTRY_BYTES + " bytes");
253
+ return null;
254
+ }
255
+ const name = boundedString(d.name, MAX_NAME_BYTES);
256
+ const prompt = boundedString(d.prompt, MAX_PROMPT_BYTES);
257
+ const agent = boundedString(d.agent, MAX_AGENT_BYTES);
258
+ const hubAgent = boundedString(d.hubAgent, MAX_AGENT_BYTES);
259
+ if (name === null || prompt === null || agent === null || hubAgent === null) {
260
+ errors.push("Automation entry " + (index + 1) + " contains an oversized string");
261
+ return null;
262
+ }
198
263
  if (!name || !prompt) {
199
264
  errors.push(`Automation "${name || "(unnamed)"}" missing name/prompt`);
200
265
  return null;
201
266
  }
267
+ if (typeof d.schedule === "string" && utf8Bytes(d.schedule.trim()) > MAX_SCHEDULE_BYTES) {
268
+ errors.push("Automation " + name + " schedule exceeds " + MAX_SCHEDULE_BYTES + " bytes");
269
+ return null;
270
+ }
271
+ if (d.schedule && typeof d.schedule === "object" && jsonBytes(d.schedule) > MAX_SCHEDULE_BYTES) {
272
+ errors.push("Automation " + name + " schedule exceeds " + MAX_SCHEDULE_BYTES + " bytes");
273
+ return null;
274
+ }
202
275
  const resolved = resolveScheduleHonest(d.schedule, errors);
203
276
  if (!resolved) return null; // 정직 거부 — 스케줄 추측 금지
204
277
  // steps[] 는 wire 패리티로 보존만 한다. Desktop 은 stepsToGraph 로
205
278
  // 그래프를 합성하지만 터미널 데몬은 prompt_template 단일 실행이다 —
206
279
  // 그래프 합성을 위장하지 않는다(비합성은 apply 쪽 영수증에 표기).
280
+ if (Array.isArray(d.steps) && d.steps.length > MAX_STEPS) {
281
+ errors.push("Automation " + name + " exceeds maximum of " + MAX_STEPS + " steps");
282
+ return null;
283
+ }
284
+ if (Array.isArray(d.steps) && jsonBytes(d.steps) > MAX_STEPS_BYTES) {
285
+ errors.push("Automation " + name + " steps exceed " + MAX_STEPS_BYTES + " bytes");
286
+ return null;
287
+ }
207
288
  const steps = Array.isArray(d.steps) && d.steps.length
208
289
  ? d.steps.filter((s) => !!s && typeof s === "object")
209
290
  : undefined;
@@ -218,6 +299,7 @@ function parseAutomationBlock(text) {
218
299
  };
219
300
  })
220
301
  .filter(Boolean);
302
+ }
221
303
  } else {
222
304
  errors.push("Automation block was not a JSON array");
223
305
  }
@@ -241,6 +323,7 @@ function parseAutomationBlock(text) {
241
323
  /* ── <<agentlas-ask>> (renderer/lib/ask-question.ts extractQuestions 포팅) ── */
242
324
 
243
325
  function tryParseAsk(body) {
326
+ if (utf8Bytes(body) > MAX_ASK_BODY_BYTES) return null;
244
327
  // body 가 ```json 펜스로 감싸진 경우도 허용 (Desktop confirm/index.ts:77-80 동일)
245
328
  const stripped = body
246
329
  .replace(/^```(?:json)?\s*/i, "")
@@ -297,7 +380,7 @@ function extractAsks(text) {
297
380
  break;
298
381
  }
299
382
  const parsed = tryParseAsk(afterOpen.slice(0, close).trim());
300
- if (parsed) asks.push(parsed);
383
+ if (parsed && asks.length < MAX_ASKS) asks.push(parsed);
301
384
  // 파싱 실패해도 닫힌 펜스는 본문에서 제거 — malformed JSON raw 를 사용자에게 노출하지 않는다.
302
385
  remaining = afterOpen.slice(close + ASK_CLOSE.length);
303
386
  }
@@ -328,6 +411,7 @@ function parseReplyFences(text) {
328
411
  // 2. Delegate
329
412
  const del = parseDelegateBlock(working);
330
413
  working = del.cleanedText;
414
+ errors.push(...(del.errors || []));
331
415
 
332
416
  // 3. Automation
333
417
  const auto = parseAutomationBlock(working);
@@ -64,6 +64,52 @@ function curatorRuntimeEnv() {
64
64
  return env;
65
65
  }
66
66
 
67
+ const OLLAMA_CURATOR_TIMEOUT_MS = 120_000;
68
+ const OLLAMA_JUDGMENT_TIMEOUT_MS = 60_000;
69
+
70
+ /**
71
+ * API-backed memory decisions run after the main Session turn, so Session's
72
+ * provider abort slot is no longer active. Keep this boundary self-contained:
73
+ * the race releases the turn even if a custom/fake fetch ignores AbortSignal,
74
+ * while the signal lets a real fetch stop immediately on timeout.
75
+ */
76
+ async function runBoundedOllama(session, system, prompt, operation, timeoutMs) {
77
+ const limit = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
78
+ ? Number(timeoutMs)
79
+ : OLLAMA_JUDGMENT_TIMEOUT_MS;
80
+ const controller = new AbortController();
81
+ let timer = null;
82
+ let timedOut = false;
83
+ let timeoutError = null;
84
+ const call = Promise.resolve().then(() => capture.runApi(
85
+ "ollama",
86
+ session.runtime.model,
87
+ system,
88
+ prompt,
89
+ { signal: controller.signal },
90
+ ));
91
+ const deadline = new Promise((_, reject) => {
92
+ timer = setTimeout(() => {
93
+ timedOut = true;
94
+ timeoutError = new Error(`Ollama ${operation} timed out after ${limit}ms.`);
95
+ timeoutError.code = "AGENTLAS_MEMORY_JUDGMENT_TIMEOUT";
96
+ try { controller.abort(timeoutError); } catch { /* already aborted */ }
97
+ reject(timeoutError);
98
+ }, limit);
99
+ });
100
+ try {
101
+ return await Promise.race([call, deadline]);
102
+ } catch (error) {
103
+ if (timedOut) throw timeoutError;
104
+ throw error;
105
+ } finally {
106
+ if (timer) clearTimeout(timer);
107
+ if (!controller.signal.aborted) {
108
+ try { controller.abort(new Error(`Ollama ${operation} finished`)); } catch { /* already aborted */ }
109
+ }
110
+ }
111
+ }
112
+
67
113
  function ensureGeminiNoToolsPolicy() {
68
114
  const dir = curatorRuntimeDir();
69
115
  const file = path.join(dir, "gemini-no-tools-policy.toml");
@@ -101,7 +147,7 @@ async function invokeCurator(session, payload, systemPrompt) {
101
147
  throw new Error("Memory Curator payload failed the pre-invocation privacy gate");
102
148
  }
103
149
  if (session.runtime.kind === "ollama") {
104
- return capture.runApi("ollama", session.runtime.model, systemPrompt, serialized);
150
+ return runBoundedOllama(session, systemPrompt, serialized, "memory curator", OLLAMA_CURATOR_TIMEOUT_MS);
105
151
  }
106
152
  return capture.captureRuntime(session.runtime.kind, systemPrompt, serialized, {
107
153
  cwd: curatorRuntimeDir(),
@@ -146,7 +192,13 @@ async function resolveSessionTaskSignatures(session, prompt) {
146
192
  ].join("\n");
147
193
  let raw;
148
194
  if (session.runtime.kind === "ollama") {
149
- raw = await capture.runApi("ollama", session.runtime.model, system, String(prompt || ""));
195
+ raw = await runBoundedOllama(
196
+ session,
197
+ system,
198
+ String(prompt || ""),
199
+ "task judgment",
200
+ OLLAMA_JUDGMENT_TIMEOUT_MS,
201
+ );
150
202
  } else {
151
203
  raw = await capture.captureRuntime(session.runtime.kind, system, String(prompt || ""), {
152
204
  cwd: curatorRuntimeDir(),
@@ -183,7 +235,13 @@ async function judgeGlobalMemoryAuthorization(session, promptText) {
183
235
  ].join("\n");
184
236
  let raw;
185
237
  if (session.runtime.kind === "ollama") {
186
- raw = await capture.runApi("ollama", session.runtime.model, system, String(promptText || ""));
238
+ raw = await runBoundedOllama(
239
+ session,
240
+ system,
241
+ String(promptText || ""),
242
+ "global-memory judgment",
243
+ OLLAMA_JUDGMENT_TIMEOUT_MS,
244
+ );
187
245
  } else {
188
246
  raw = await capture.captureRuntime(session.runtime.kind, system, String(promptText || ""), {
189
247
  cwd: curatorRuntimeDir(),
@@ -242,4 +300,5 @@ module.exports = {
242
300
  beginSessionMemoryTurn,
243
301
  completeSessionMemoryTurn,
244
302
  resolveSessionTaskSignatures,
303
+ runBoundedOllama,
245
304
  };
@@ -84,7 +84,17 @@ class Orchestrator extends EventEmitter {
84
84
 
85
85
  runningCount() {
86
86
  let n = 0;
87
- for (const s of this.sessions.values()) if (s.isBusy()) n += 1;
87
+ for (const s of this.sessions.values()) {
88
+ if (s.isBusy()) {
89
+ n += 1;
90
+ continue;
91
+ }
92
+ // kill() fences the session immediately, but native/API cancellation is
93
+ // asynchronous. Keep the slot occupied until Session has released its
94
+ // child or abort controller; otherwise a replacement turn can start
95
+ // while the killed provider is still consuming a process/slot.
96
+ if (s.status === "killed" && (s._child || s._apiAbort)) n += 1;
97
+ }
88
98
  return n;
89
99
  }
90
100
 
@@ -31,6 +31,63 @@ const TERMINAL_MEMORY_CORE = [
31
31
  ].join("\n");
32
32
  const MEMORY_DETAIL_RE = /\b(?:remember|memory|save this|record this|memory event)\b|기억|메모리|저장해|기록해|남겨/i;
33
33
  const CREDENTIAL_INDEX_RE = /\b(?:deploy|release|billing|auth|oauth|credential|api key|secret key|cloud)\b|배포|릴리스|출시|결제|인증|자격 증명|API\s*키|시크릿|클라우드/i;
34
+ const PROJECT_SOUL_MAX_CHARS = 1_800;
35
+ const ONE_STATE_MAX_CHARS = 64 * 1024;
36
+
37
+ function sameFileIdentity(left, right) {
38
+ return left && right && String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino);
39
+ }
40
+
41
+ /**
42
+ * Read a small local prompt file through one descriptor. The lstat/fstat
43
+ * identity check detects a path swap between the check and open; O_NOFOLLOW
44
+ * rejects symlink aliases, and the second fstat detects a concurrent rewrite.
45
+ * The byte cap is deliberately larger than the character cap for UTF-8 while
46
+ * still bounding the amount retained before decoding and slicing.
47
+ */
48
+ function readBoundedText(file, maxChars) {
49
+ const charLimit = Math.max(0, Number(maxChars) || 0);
50
+ const byteLimit = Math.max(1, charLimit * 4 + 4);
51
+ const expected = fs.lstatSync(file);
52
+ if (!expected.isFile() || expected.isSymbolicLink() || expected.nlink !== 1) {
53
+ throw new Error(`prompt file is not a single-link regular file: ${file}`);
54
+ }
55
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
56
+ let fd = null;
57
+ try {
58
+ fd = fs.openSync(file, flags);
59
+ const opened = fs.fstatSync(fd);
60
+ if (!opened.isFile() || opened.nlink !== 1 || !sameFileIdentity(expected, opened)) {
61
+ throw new Error(`prompt file identity changed before read: ${file}`);
62
+ }
63
+ const size = Number.isSafeInteger(Number(opened.size)) ? Number(opened.size) : byteLimit;
64
+ const readLimit = Math.min(size, byteLimit);
65
+ const data = Buffer.allocUnsafe(readLimit);
66
+ let offset = 0;
67
+ while (offset < readLimit) {
68
+ const count = fs.readSync(fd, data, offset, readLimit - offset, null);
69
+ if (!count) break;
70
+ offset += count;
71
+ }
72
+ const after = fs.fstatSync(fd);
73
+ const changed = !sameFileIdentity(opened, after)
74
+ || after.nlink !== 1
75
+ || Number(after.size) !== Number(opened.size)
76
+ || (after.mtimeNs != null && opened.mtimeNs != null
77
+ ? String(after.mtimeNs) !== String(opened.mtimeNs)
78
+ : after.mtimeMs !== opened.mtimeMs);
79
+ if (changed) throw new Error(`prompt file changed while reading: ${file}`);
80
+ const text = data.subarray(0, offset).toString("utf8");
81
+ return {
82
+ text: text.slice(0, charLimit),
83
+ truncated: offset < Number(opened.size) || text.length > charLimit,
84
+ };
85
+ } finally {
86
+ if (fd != null) {
87
+ try { fs.closeSync(fd); } catch { /* already closed */ }
88
+ }
89
+ }
90
+ }
34
91
 
35
92
  function approximatePromptTokens(text) {
36
93
  return Math.ceil(Buffer.byteLength(String(text || ""), "utf8") / 3);
@@ -130,11 +187,10 @@ function cliMemoryContext(db, projectPath, agentId = null, task = "") {
130
187
  if (projectPath) {
131
188
  try {
132
189
  const soulPath = path.join(projectPath, arch.memoryDir || ".agentlas", arch.soulFile || "project-soul-memory.md");
133
- if (fs.existsSync(soulPath)) {
134
- let s = fs.readFileSync(soulPath, "utf8");
135
- if (s.length > 1800) s = s.slice(0, 1800) + "\n…(truncated)";
136
- if (s.trim()) sections.push(`### Project memory (${projectPath})\n${s.trim()}`);
137
- }
190
+ const bounded = readBoundedText(soulPath, PROJECT_SOUL_MAX_CHARS);
191
+ let s = bounded.text;
192
+ if (bounded.truncated) s += "\n…(truncated)";
193
+ if (s.trim()) sections.push(`### Project memory (${projectPath})\n${s.trim()}`);
138
194
  } catch { /* ignore */ }
139
195
  const contextSlice = cliProjectContextSlice(projectPath, task);
140
196
  if (contextSlice) sections.push(contextSlice);
@@ -188,13 +244,13 @@ function loadOneDirective() {
188
244
  try {
189
245
  const root = process.env.AGENTLAS_ONE_DIR
190
246
  || require("node:path").join(require("node:os").homedir(), ".agentlas", "one");
191
- const fs = require("node:fs");
192
247
  const path = require("node:path");
193
- const state = JSON.parse(fs.readFileSync(path.join(root, "state.json"), "utf8"));
248
+ const state = JSON.parse(readBoundedText(path.join(root, "state.json"), ONE_STATE_MAX_CHARS).text);
194
249
  if (!state || state.on !== true) return "";
195
- const text = fs.readFileSync(path.join(root, "directive.md"), "utf8").trim();
250
+ const bounded = readBoundedText(path.join(root, "directive.md"), ONE_DIRECTIVE_MAX_CHARS);
251
+ const text = bounded.text.trim();
196
252
  // 상한을 둔다 — 지시문이 남의 토큰 예산을 잠식하면 안 된다(메모리 예산과 같은 규칙).
197
- return text.length > ONE_DIRECTIVE_MAX_CHARS ? text.slice(0, ONE_DIRECTIVE_MAX_CHARS) : text;
253
+ return text;
198
254
  } catch {
199
255
  return "";
200
256
  }
@@ -251,4 +307,5 @@ module.exports = {
251
307
  cliProjectContextSlice,
252
308
  augmentSystem,
253
309
  loadGlobalConnectionSkill,
310
+ readBoundedText,
254
311
  };
@@ -15,12 +15,44 @@ const nativeHost = require("../agentlas-native-host.cjs");
15
15
  const permissions = require("../agentlas-permissions.cjs");
16
16
  const { RUNTIME_BIN, whichSync } = require("../runtimes/detect.cjs");
17
17
  const { CLI_EXECUTABLE_KINDS } = require("../runtimes/resolve.cjs");
18
+ const ACP_RUNTIME_KINDS = new Set(require("../runtimes/kinds.cjs").ACP_CLI_KINDS);
18
19
  const { roleMembers } = require("../runtimes/roles.cjs");
19
20
  const { EventSink } = require("./sink.cjs");
20
21
  const store = require("./store.cjs");
21
22
  const memoryTurn = require("./memory-turn.cjs");
22
23
 
23
24
  const RING_LIMIT = 2000;
25
+ const RING_BYTE_LIMIT = 8 * 1024 * 1024;
26
+
27
+ function eventBytes(event) {
28
+ try { return Buffer.byteLength(JSON.stringify(event), "utf8"); }
29
+ catch { return 1024; }
30
+ }
31
+
32
+ /**
33
+ * Provider resume ids belong to the model that created them. Effort and other
34
+ * per-turn controls can safely change on the same thread, but a model switch
35
+ * must start a fresh provider session and let Agentlas history preserve the
36
+ * visible conversation. Otherwise the CLI receives `--model new` together
37
+ * with `--resume old-session` and the dashboard selection is not authoritative.
38
+ */
39
+ function runtimeSessionFingerprint(runtime, agent, permission) {
40
+ const digest = crypto.createHash("sha256")
41
+ .update("agentlas-terminal-runtime-session.v2\0")
42
+ .update(String((runtime && runtime.kind) || ""))
43
+ .update("\0model\0")
44
+ .update(String((runtime && runtime.model) || ""))
45
+ .update("\0agent\0")
46
+ .update(String((agent && agent.id) || ""))
47
+ .update("\0prompt\0")
48
+ .update(String((agent && agent.systemPrompt) || ""));
49
+ // ACP chooses its session mode at session/new. Unlike native Codex/Claude
50
+ // flags, a later turn cannot safely retrofit that mode onto an old id.
51
+ if (ACP_RUNTIME_KINDS.has(runtime && runtime.kind)) {
52
+ digest.update("\0permission\0").update(permissions.normalize(permission));
53
+ }
54
+ return digest.digest("hex");
55
+ }
24
56
 
25
57
  class Session extends EventEmitter {
26
58
  /**
@@ -48,6 +80,7 @@ class Session extends EventEmitter {
48
80
  this.queue = [];
49
81
  this._child = null;
50
82
  this._events = [];
83
+ this._eventBytes = 0;
51
84
  this._turnPromise = null;
52
85
  // 계약 테스트용 spawn 주입(runNativeTurn의 req.spawn). 프로덕션 경로에선 null.
53
86
  this._spawnImpl = opts.spawnImpl || null;
@@ -73,9 +106,7 @@ class Session extends EventEmitter {
73
106
  } catch { /* kind 열이 없는 구형 DB — user 취급(레거시 NULL=user 계약) */ }
74
107
  }
75
108
 
76
- this.fingerprint = crypto.createHash("sha256")
77
- .update(`${this.runtime.kind}\n${this.agent.id}\n${this.agent.systemPrompt || ""}`)
78
- .digest("hex");
109
+ this.fingerprint = runtimeSessionFingerprint(this.runtime, this.agent, this.permission);
79
110
  this.runtimeSession = store.loadRuntimeSession(this.db, this.chatId, this.runtime.kind, this.fingerprint, this.agent.id);
80
111
 
81
112
  this._sink = new EventSink({
@@ -91,7 +122,11 @@ class Session extends EventEmitter {
91
122
 
92
123
  _record(ev) {
93
124
  this._events.push(ev);
94
- if (this._events.length > RING_LIMIT) this._events.splice(0, this._events.length - RING_LIMIT);
125
+ this._eventBytes += eventBytes(ev);
126
+ while (this._events.length > RING_LIMIT || this._eventBytes > RING_BYTE_LIMIT) {
127
+ const removed = this._events.shift();
128
+ this._eventBytes = Math.max(0, this._eventBytes - eventBytes(removed));
129
+ }
95
130
  if (ev.type === "stream-delta") {
96
131
  const tail = (this.lastLine + ev.text).split(/\r?\n/).filter((l) => l.trim());
97
132
  this.lastLine = tail.length ? tail[tail.length - 1].slice(0, 200) : this.lastLine;
@@ -154,6 +189,10 @@ class Session extends EventEmitter {
154
189
  }
155
190
 
156
191
  _nextRecoveryRuntime() {
192
+ // Per-automation exact pins are authoritative. A provider/model failure is
193
+ // surfaced to the run receipt; it must never jump into a Dashboard role
194
+ // pool and make the unattended run use a different provider.
195
+ if (this.runtime.pinAuthoritative) return null;
157
196
  const role = this.runtime.role === "worker" ? "worker" : "orchestrator";
158
197
  const members = roleMembers(this.db, role);
159
198
  const current = members.findIndex((member) =>
@@ -178,6 +217,10 @@ class Session extends EventEmitter {
178
217
  }
179
218
 
180
219
  async _runTurn(prompt) {
220
+ const runtimeSessionAtTurnStart = {
221
+ kind: this.runtime.kind,
222
+ id: this.runtimeSession && this.runtimeSession.id ? String(this.runtimeSession.id) : "",
223
+ };
181
224
  this.status = "running";
182
225
  this.startedAt = Date.now();
183
226
  this.lastError = null;
@@ -304,6 +347,7 @@ class Session extends EventEmitter {
304
347
  if (turnAbort) req.signal = turnAbort.signal;
305
348
  if (this._spawnImpl) req.spawn = this._spawnImpl;
306
349
  if (this._timeoutConfig) req.timeoutConfig = this._timeoutConfig;
350
+ let recoveryAbort = null;
307
351
  try {
308
352
  res = await nativeHost.runNativeTurn(req);
309
353
  /*
@@ -312,16 +356,21 @@ class Session extends EventEmitter {
312
356
  * 그래서 `!res.text`가 영원히 거짓이 되어, 예비 런타임이 등록돼 있는데도
313
357
  * 복구가 한 번도 발화하지 않았다. 실패는 error가 말하고, text는 표시용이다.
314
358
  */
315
- if (res && res.error) {
359
+ // A user kill is terminal. The killed child normally unwinds with an
360
+ // error marker; treating that marker as provider failure would launch
361
+ // the next priority model after the user explicitly pressed stop.
362
+ if (res && res.error && this.status !== "killed") {
316
363
  const nextRuntime = this._nextRecoveryRuntime();
317
364
  if (nextRuntime) {
318
365
  const privateEvidence = [...this._privateRecoveryEvidence, String(res.error)]
319
366
  .filter(Boolean).join("\n").slice(0, 12000);
320
367
  this.runtime = nextRuntime;
321
368
  this.runtimeSession = {};
322
- this.fingerprint = crypto.createHash("sha256")
323
- .update(`${nextRuntime.kind}\n${this.agent.id}\n${this.agent.systemPrompt || ""}`)
324
- .digest("hex");
369
+ this.fingerprint = runtimeSessionFingerprint(nextRuntime, this.agent, this.permission);
370
+ if (ACP_RUNTIME_KINDS.has(nextRuntime.kind)) {
371
+ recoveryAbort = new AbortController();
372
+ this._apiAbort = recoveryAbort;
373
+ }
325
374
  res = await nativeHost.runNativeTurn({
326
375
  ...req,
327
376
  kind: nextRuntime.kind,
@@ -329,6 +378,8 @@ class Session extends EventEmitter {
329
378
  model: nextRuntime.model,
330
379
  effort: nextRuntime.effort,
331
380
  session: {},
381
+ sessionFingerprintSeed: this.fingerprint,
382
+ ...(recoveryAbort ? { signal: recoveryAbort.signal } : {}),
332
383
  prompt: [
333
384
  prompt,
334
385
  "",
@@ -342,6 +393,7 @@ class Session extends EventEmitter {
342
393
  } catch (e) {
343
394
  res = { text: "", session: req.session, error: (e && e.message) || String(e) };
344
395
  } finally {
396
+ if (recoveryAbort && this._apiAbort === recoveryAbort) this._apiAbort = null;
345
397
  if (turnAbort && this._apiAbort === turnAbort) this._apiAbort = null;
346
398
  }
347
399
  }
@@ -396,7 +448,15 @@ class Session extends EventEmitter {
396
448
  if (persistText) store.appendMessage(this.db, this.chatId, "assistant", persistText);
397
449
  if (res && res.session && res.session.id) {
398
450
  this.runtimeSession = { id: res.session.id };
399
- store.saveRuntimeSession(this.db, this.chatId, this.runtime.kind, this.runtimeSession, this.fingerprint, this.agent.id);
451
+ store.saveRuntimeSession(
452
+ this.db,
453
+ this.chatId,
454
+ this.runtime.kind,
455
+ this.runtimeSession,
456
+ this.fingerprint,
457
+ this.agent.id,
458
+ runtimeSessionAtTurnStart.kind === this.runtime.kind ? runtimeSessionAtTurnStart.id : "",
459
+ );
400
460
  }
401
461
  if (res && res.usage) this.usage = res.usage;
402
462
 
@@ -495,4 +555,4 @@ class Session extends EventEmitter {
495
555
  }
496
556
  }
497
557
 
498
- module.exports = { Session };
558
+ module.exports = { Session, runtimeSessionFingerprint };