agentlas 1.0.65 → 1.0.67

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 (163) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +12 -4
  3. package/bin/agentlas.cjs +354 -16
  4. package/engine/acp/server.cjs +34 -3
  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 +30 -5
  30. package/engine/agentlas-workload-routing.cjs +83 -10
  31. package/engine/agentlas.cjs +21 -4
  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/browser/cdp.cjs +190 -17
  43. package/engine/browser/vault.cjs +1 -0
  44. package/engine/cli-output.cjs +96 -30
  45. package/engine/cloud/auth.cjs +131 -13
  46. package/engine/cloud/hub-client.cjs +41 -16
  47. package/engine/cloud-assets/cas.cjs +18 -9
  48. package/engine/cloud-assets/commands.cjs +108 -30
  49. package/engine/cloud-assets/package.cjs +66 -3
  50. package/engine/cloud-assets/restore.cjs +12 -9
  51. package/engine/cloud-assets/state.cjs +609 -116
  52. package/engine/commands/acp.cjs +13 -2
  53. package/engine/commands/automation.cjs +75 -24
  54. package/engine/commands/billing.cjs +6 -1
  55. package/engine/commands/browser.cjs +61 -29
  56. package/engine/commands/build.cjs +72 -14
  57. package/engine/commands/call.cjs +1 -1
  58. package/engine/commands/cd.cjs +1 -1
  59. package/engine/commands/cloud.cjs +2 -1
  60. package/engine/commands/connect.cjs +38 -13
  61. package/engine/commands/creds.cjs +74 -29
  62. package/engine/commands/doctor.cjs +17 -1
  63. package/engine/commands/document.cjs +38 -10
  64. package/engine/commands/env.cjs +6 -1
  65. package/engine/commands/firm.cjs +42 -12
  66. package/engine/commands/graph.cjs +36 -32
  67. package/engine/commands/help.cjs +11 -1
  68. package/engine/commands/hep.cjs +1 -1
  69. package/engine/commands/import.cjs +5 -4
  70. package/engine/commands/index.cjs +1 -1
  71. package/engine/commands/install.cjs +6 -4
  72. package/engine/commands/list.cjs +56 -16
  73. package/engine/commands/login.cjs +5 -0
  74. package/engine/commands/logout.cjs +6 -1
  75. package/engine/commands/mcp.cjs +150 -37
  76. package/engine/commands/memory.cjs +3 -2
  77. package/engine/commands/multimodal.cjs +12 -0
  78. package/engine/commands/native.cjs +5 -1
  79. package/engine/commands/one.cjs +78 -33
  80. package/engine/commands/plugin.cjs +13 -2
  81. package/engine/commands/project.cjs +15 -12
  82. package/engine/commands/research.cjs +18 -7
  83. package/engine/commands/roles.cjs +104 -28
  84. package/engine/commands/route.cjs +2 -2
  85. package/engine/commands/run.cjs +42 -9
  86. package/engine/commands/search.cjs +23 -2
  87. package/engine/commands/setup.cjs +6 -1
  88. package/engine/commands/storm.cjs +44 -6
  89. package/engine/commands/swarm.cjs +47 -9
  90. package/engine/commands/telegram.cjs +6 -1
  91. package/engine/commands/uninstall.cjs +12 -6
  92. package/engine/commands/update.cjs +5 -0
  93. package/engine/commands/upload.cjs +2 -1
  94. package/engine/commands/usage.cjs +6 -1
  95. package/engine/commands/version.cjs +6 -1
  96. package/engine/commands/whoami.cjs +6 -1
  97. package/engine/commands/workforce.cjs +60 -16
  98. package/engine/core/capability-grants.cjs +7 -2
  99. package/engine/core/daemon-client.cjs +36 -7
  100. package/engine/core/db.cjs +10 -2
  101. package/engine/core/desktop-core-fetch.cjs +477 -77
  102. package/engine/core/schema-ensure.cjs +23 -11
  103. package/engine/experience/build.cjs +5 -1
  104. package/engine/experience/intents.cjs +35 -82
  105. package/engine/experience/runtime.cjs +7 -1
  106. package/engine/experience/variant.cjs +58 -4
  107. package/engine/firms/orchestrate.cjs +9 -6
  108. package/engine/graph/ask-model.cjs +22 -11
  109. package/engine/graph/interview.cjs +74 -23
  110. package/engine/graph/package.cjs +63 -8
  111. package/engine/hephaestus/local-core.cjs +18 -5
  112. package/engine/hephaestus/runtime.cjs +48 -43
  113. package/engine/hub/install.cjs +484 -63
  114. package/engine/hub/plugins.cjs +391 -29
  115. package/engine/mcp/consent.cjs +25 -17
  116. package/engine/mcp/contract.cjs +229 -24
  117. package/engine/mcp/inventory.cjs +46 -10
  118. package/engine/mcp/plan.cjs +10 -1
  119. package/engine/mcp/probe.cjs +24 -9
  120. package/engine/memory-cli/curate.cjs +74 -16
  121. package/engine/oberon/common.cjs +37 -12
  122. package/engine/oberon/manifest.cjs +25 -2
  123. package/engine/oberon/outputs.cjs +49 -19
  124. package/engine/oberon/render.cjs +665 -40
  125. package/engine/project/career-graph.cjs +15 -2
  126. package/engine/project/controller.cjs +133 -15
  127. package/engine/project/credentials.cjs +382 -34
  128. package/engine/project/env-file.cjs +93 -8
  129. package/engine/project/memory-context.cjs +11 -57
  130. package/engine/project/ontology.cjs +66 -25
  131. package/engine/project/paths.cjs +42 -8
  132. package/engine/project/seed.cjs +88 -51
  133. package/engine/project/state.cjs +41 -16
  134. package/engine/project/team.cjs +26 -1
  135. package/engine/runtime-refusal.cjs +4 -1
  136. package/engine/runtimes/acp-driver.cjs +56 -9
  137. package/engine/runtimes/auth-evidence.cjs +21 -2
  138. package/engine/runtimes/detect.cjs +28 -8
  139. package/engine/runtimes/kinds.cjs +12 -4
  140. package/engine/runtimes/overrides.cjs +4 -3
  141. package/engine/runtimes/resolve.cjs +8 -7
  142. package/engine/runtimes/roles.cjs +7 -5
  143. package/engine/sessions/apply-fences.cjs +396 -56
  144. package/engine/sessions/fences.cjs +111 -27
  145. package/engine/sessions/memory-turn.cjs +62 -3
  146. package/engine/sessions/orchestrator.cjs +11 -1
  147. package/engine/sessions/prompt.cjs +66 -9
  148. package/engine/sessions/session.cjs +70 -10
  149. package/engine/sessions/sink.cjs +78 -12
  150. package/engine/sessions/store.cjs +29 -7
  151. package/engine/storm/storm.cjs +26 -3
  152. package/engine/storm/swarm.cjs +31 -3
  153. package/engine/telegram/connect.cjs +185 -22
  154. package/engine/ui/commands-catalog.cjs +1 -1
  155. package/engine/ui/palette.cjs +12 -2
  156. package/engine/ui/repl.cjs +57 -9
  157. package/engine/ui/screens.cjs +2 -4
  158. package/engine/vendor/desktop-core.manifest.json +5 -5
  159. package/engine/workforce/capture.cjs +9 -27
  160. package/engine/workforce/deps.cjs +18 -6
  161. package/engine/workforce/local-core-transport.cjs +68 -5
  162. package/package.json +1 -1
  163. package/engine/commands/career-graph.cjs +0 -51
@@ -37,16 +37,27 @@ const projectBootstrapStates = new Map();
37
37
 
38
38
  function terminalProjectCandidateCli(projectPath) {
39
39
  try {
40
- const root = path.resolve(projectPath || process.cwd());
40
+ const requested = path.resolve(projectPath || process.cwd());
41
+ const stat = fs.statSync(requested);
42
+ if (!stat.isDirectory()) return null;
43
+ // Resolve the directory before applying broad-root exclusions. Otherwise
44
+ // `/tmp/project -> ~/.agentlas` bypasses the lexical checks and lets an
45
+ // explicit init write private project state into Agentlas' own userData.
46
+ const root = fs.realpathSync.native(requested);
47
+ const realOrResolved = (candidate) => {
48
+ try { return fs.realpathSync.native(candidate); } catch { return path.resolve(candidate); }
49
+ };
50
+ const home = realOrResolved(os.homedir());
51
+ const data = realOrResolved(userDataDir());
52
+ const fallback = realOrResolved(runCwd());
41
53
  const unsafe = new Set([
42
- path.resolve(os.homedir()),
54
+ home,
43
55
  path.parse(root).root,
44
- path.resolve(userDataDir()),
45
- path.resolve(runCwd()),
56
+ data,
57
+ fallback,
46
58
  ]);
47
59
  if (unsafe.has(root)) return null;
48
- const stat = fs.statSync(root);
49
- if (!stat.isDirectory()) return null;
60
+ if (root.startsWith(`${data}${path.sep}`) || root.startsWith(`${fallback}${path.sep}`)) return null;
50
61
  return root;
51
62
  } catch {
52
63
  return null;
@@ -67,6 +78,10 @@ function assertNoSymlinkInAgentlasStateCli(stateDir) {
67
78
  if (stat.isSymbolicLink()) throw new Error(".agentlas local state must not contain symbolic links");
68
79
  if (stat.isDirectory()) {
69
80
  for (const entry of fs.readdirSync(current)) pending.push(path.join(current, entry));
81
+ } else if (stat.isFile()) {
82
+ if (stat.nlink !== 1) throw new Error(".agentlas local state must not contain hard-linked files");
83
+ } else {
84
+ throw new Error(".agentlas local state must contain only directories and regular files");
70
85
  }
71
86
  }
72
87
  if (pending.length) throw new Error(".agentlas local state exceeds the safe bootstrap inspection limit");
@@ -78,7 +93,9 @@ function readRegularUtf8FileNoFollowCli(filePath, maxBytes = AGENTLAS_GITIGNORE_
78
93
  if (error && error.code === "ENOENT") return { exists: false, content: "", mode: 0o644, stat: null };
79
94
  throw error;
80
95
  }
81
- if (before.isSymbolicLink() || !before.isFile()) throw new Error(".gitignore must be a regular non-symbolic-link file");
96
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1) {
97
+ throw new Error(".gitignore must be a single-link regular non-symbolic-link file");
98
+ }
82
99
  if (before.size > maxBytes) throw new Error(`.gitignore exceeds the ${maxBytes}-byte safe bootstrap limit`);
83
100
 
84
101
  const noFollow = fs.constants.O_NOFOLLOW || 0;
@@ -91,7 +108,7 @@ function readRegularUtf8FileNoFollowCli(filePath, maxBytes = AGENTLAS_GITIGNORE_
91
108
  }
92
109
  try {
93
110
  const opened = fs.fstatSync(fd);
94
- if (!opened.isFile()) throw new Error(".gitignore changed type during bootstrap");
111
+ if (!opened.isFile() || opened.nlink !== 1) throw new Error(".gitignore changed type during bootstrap");
95
112
  if (opened.size > maxBytes) throw new Error(`.gitignore exceeds the ${maxBytes}-byte safe bootstrap limit`);
96
113
  if (
97
114
  Number.isFinite(before.dev) && Number.isFinite(before.ino) &&
@@ -110,7 +127,9 @@ function readRegularUtf8FileNoFollowCli(filePath, maxBytes = AGENTLAS_GITIGNORE_
110
127
  }
111
128
  if (total > maxBytes) throw new Error(`.gitignore exceeds the ${maxBytes}-byte safe bootstrap limit`);
112
129
  const after = fs.fstatSync(fd);
113
- if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) throw new Error(".gitignore changed while it was being read");
130
+ if (after.nlink !== 1 || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) {
131
+ throw new Error(".gitignore changed while it was being read");
132
+ }
114
133
  let content;
115
134
  try { content = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, total)); } catch {
116
135
  throw new Error(".gitignore must contain valid UTF-8 text");
@@ -132,7 +151,7 @@ function assertFileSnapshotUnchangedCli(filePath, snapshot) {
132
151
  }
133
152
  }
134
153
  const current = fs.lstatSync(filePath);
135
- if (current.isSymbolicLink() || !current.isFile()) throw new Error(".gitignore changed type during bootstrap");
154
+ if (current.isSymbolicLink() || !current.isFile() || current.nlink !== 1) throw new Error(".gitignore changed type during bootstrap");
136
155
  const original = snapshot.stat;
137
156
  if (
138
157
  !original || current.dev !== original.dev || current.ino !== original.ino ||
@@ -227,14 +246,20 @@ function hardenAgentlasProjectStateCli(projectPath) {
227
246
  visited += 1;
228
247
  try {
229
248
  const stat = fs.lstatSync(current);
230
- if (stat.isSymbolicLink()) continue;
249
+ if (stat.isSymbolicLink()) throw new Error(".agentlas local state must not contain symbolic links");
231
250
  if (stat.isDirectory()) {
232
251
  try { fs.chmodSync(current, 0o700); } catch { /* Windows/best effort */ }
233
252
  for (const entry of fs.readdirSync(current)) pending.push(path.join(current, entry));
234
253
  } else if (stat.isFile()) {
254
+ if (stat.nlink !== 1) throw new Error(".agentlas local state must not contain hard-linked files");
235
255
  try { fs.chmodSync(current, 0o600); } catch { /* Windows/best effort */ }
256
+ } else {
257
+ throw new Error(".agentlas local state must contain only directories and regular files");
236
258
  }
237
- } catch { /* disappearing files and ACL-only hosts are best effort */ }
259
+ } catch (error) {
260
+ if (error && error.code === "ENOENT") continue;
261
+ throw error;
262
+ }
238
263
  }
239
264
  }
240
265
 
@@ -314,10 +339,10 @@ function recordCliFolderVisit(db, projectPath, options = {}) {
314
339
  const activate = options.activate === true;
315
340
  try {
316
341
  if (!activate) {
317
- const row = tableExists(db, "folder_activity")
318
- ? db.prepare("SELECT activated_at FROM folder_activity WHERE path=?").get(root)
319
- : null;
320
- return { activated: Boolean(row && row.activated_at) || fs.existsSync(path.join(root, ".agentlas")) };
342
+ // A historical DB row or an arbitrary `.agentlas` entry is not proof
343
+ // that today's private-state boundary is intact. Revalidate the marker,
344
+ // soul file, entry types, and link identities on every passive use.
345
+ return { activated: Boolean(initializedAgentlasProjectPathCli(root)) };
321
346
  }
322
347
 
323
348
  ensureCoreProjectCli(root, { reason: options.reason || "terminal-first-contact", coreRoot: options.coreRoot });
@@ -18,24 +18,49 @@
18
18
  * 갱신하고, 없으면 만든다(데스크탑이 만든 프로젝트도 그대로 이어받는다).
19
19
  */
20
20
  const crypto = require("node:crypto");
21
+ const os = require("node:os");
21
22
  const path = require("node:path");
22
23
  const fs = require("node:fs");
23
24
  const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
24
25
  const { findAgent } = require("../agents/registry.cjs");
26
+ const { userDataDir } = require("../core/paths.cjs");
25
27
 
26
28
  function canonical(p) {
27
29
  const resolved = path.resolve(String(p || ""));
28
- try { return fs.realpathSync.native(resolved); } catch { return resolved; }
30
+ let root;
31
+ try { root = fs.realpathSync.native(resolved); }
32
+ catch { throw Object.assign(new Error(`project folder does not exist: ${resolved}`), { code: "project_folder_invalid", honestStop: true }); }
33
+ const stat = fs.lstatSync(root);
34
+ let home = path.resolve(os.homedir());
35
+ let data = path.resolve(userDataDir());
36
+ try { home = fs.realpathSync.native(home); } catch { /* compare unresolved */ }
37
+ try { data = fs.realpathSync.native(data); } catch { /* compare unresolved */ }
38
+ if (
39
+ !stat.isDirectory() || stat.isSymbolicLink() ||
40
+ path.parse(root).root === root || root === home ||
41
+ root === data || root.startsWith(`${data}${path.sep}`)
42
+ ) {
43
+ throw Object.assign(new Error(`refusing unsafe project folder: ${resolved}`), { code: "project_folder_invalid", honestStop: true });
44
+ }
45
+ return root;
29
46
  }
30
47
 
31
48
  /** slug/이름 목록 → 검증된 순서 풀. 못 찾은 이름은 정직하게 실패. */
32
49
  function resolveTeam(db, tokens) {
50
+ if (tokens.length > 64) {
51
+ throw Object.assign(new Error("a project team may contain at most 64 agents"), { code: "team_too_large", honestStop: true });
52
+ }
33
53
  const pool = [];
54
+ const seen = new Set();
34
55
  for (const token of tokens) {
35
56
  const agent = findAgent(db, token);
36
57
  if (!agent) {
37
58
  throw Object.assign(new Error(`agent not found: ${token}`), { code: "team_agent_not_found", honestStop: true, token });
38
59
  }
60
+ if (seen.has(agent.id)) {
61
+ throw Object.assign(new Error(`duplicate project team agent: ${token}`), { code: "team_agent_duplicate", honestStop: true, token });
62
+ }
63
+ seen.add(agent.id);
39
64
  // 설치 에이전트는 로컬이다(installed_agents에 있음 = 이 머신에서 실행 가능).
40
65
  pool.push({
41
66
  agentId: agent.id,
@@ -27,7 +27,10 @@ const MAX_NOTICE_LENGTH = 400;
27
27
  const NOTICE_PATTERNS = [
28
28
  /\byou'?ve hit\b/i, // "You've hit your weekly/usage limit" (claude·codex 실측)
29
29
  /\busage limit\b/i,
30
- /\brate.?limit(ed)?\b/i,
30
+ /\brate.?limit (?:exceeded|reached)\b/i,
31
+ /^(?:error:\s*)?rate.?limit (?:has been )?(?:exceeded|reached)\b/i,
32
+ /\b(?:your|the) rate.?limit (?:has been|is) (?:exceeded|reached)\b/i,
33
+ /\byou (?:are (?:currently )?|have been )rate.?limited\b/i,
31
34
  /\bquota (exceeded|reached)\b/i,
32
35
  /\bresets? (at|on)\b/i, // "resets Aug 8 at 6pm"
33
36
  /\btry again (at|later)\b/i,
@@ -14,6 +14,48 @@ const permissions = require("../agentlas-permissions.cjs");
14
14
 
15
15
  // 정본(runtimes/kinds.cjs)의 ACP 3종에서 파생 — resolve.cjs의 ACP_CLI_KINDS와 같은 원소.
16
16
  const ACP_KINDS = new Set(require("./kinds.cjs").ACP_CLI_KINDS);
17
+ const ACP_HISTORY_MAX_ENTRIES = 40;
18
+ const ACP_HISTORY_MAX_BYTES = 512 * 1024;
19
+ const ACP_HISTORY_MAX_ENTRY_BYTES = 64 * 1024;
20
+
21
+ function boundedHistoryText(value, maxBytes) {
22
+ const text = String(value || "");
23
+ const encoded = Buffer.from(text, "utf8");
24
+ if (encoded.length <= maxBytes) return text;
25
+ let bounded = encoded.subarray(0, maxBytes).toString("utf8");
26
+ // A byte cut in the middle of a multibyte code point can decode to U+FFFD,
27
+ // whose three bytes would otherwise make the decoded value exceed maxBytes.
28
+ while (Buffer.byteLength(bounded, "utf8") > maxBytes) bounded = bounded.slice(0, -1);
29
+ return bounded;
30
+ }
31
+
32
+ /*
33
+ * Provider history is supplied by the local chat store, where old rows may be
34
+ * larger than the ACP request budget. Keep the newest messages, retain their
35
+ * original order, and cap both message count and UTF-8 text bytes before the
36
+ * request is serialized by the provider runner.
37
+ */
38
+ function boundedAcpHistory(history) {
39
+ if (!Array.isArray(history)) return [];
40
+ const bounded = [];
41
+ let totalBytes = 0;
42
+ for (let index = history.length - 1;
43
+ index >= 0 && bounded.length < ACP_HISTORY_MAX_ENTRIES && totalBytes < ACP_HISTORY_MAX_BYTES;
44
+ index -= 1) {
45
+ const entry = history[index];
46
+ const remainingBytes = ACP_HISTORY_MAX_BYTES - totalBytes;
47
+ const text = boundedHistoryText(
48
+ entry && (entry.text ?? entry.content),
49
+ Math.min(ACP_HISTORY_MAX_ENTRY_BYTES, remainingBytes),
50
+ );
51
+ bounded.unshift({
52
+ role: entry && entry.role === "assistant" ? "assistant" : "user",
53
+ text,
54
+ });
55
+ totalBytes += Buffer.byteLength(text, "utf8");
56
+ }
57
+ return bounded;
58
+ }
17
59
 
18
60
  /** 이 머신에서 ACP 드라이버를 쓸 수 있는가. { ok, reason?, module? } */
19
61
  function acpDriverAvailability() {
@@ -48,13 +90,14 @@ async function runAcpTurn(req) {
48
90
  return { text: "", session: req.session || {}, error: `runtime '${kind}' is not an ACP agent in this core`, errorKind: "unsupported", errorSource: "marker" };
49
91
  }
50
92
  const runner = mod.createAcpRunner(spec);
93
+ const permission = permissions.normalize(req.permission);
51
94
  let mcpConfigPath;
52
95
  try {
53
96
  // ACP runner consumes the same Claude-compatible MCP config shape as Desktop.
54
97
  // Reuse native-host's content-addressed, credential-isolating materializer so
55
98
  // Cursor/Grok/Kimi receive exactly the already-consented Terminal allowlist.
56
99
  const nativeHost = require("../agentlas-native-host.cjs");
57
- const allowedMcpServers = permissions.normalize(req.permission) === "full"
100
+ const allowedMcpServers = permission === "full"
58
101
  ? (req.mcpServers || [])
59
102
  : [];
60
103
  mcpConfigPath = nativeHost.cliMcpConfigPath(allowedMcpServers, {
@@ -92,16 +135,11 @@ async function runAcpTurn(req) {
92
135
  try {
93
136
  const result = await runner({
94
137
  systemPrompt: req.systemPrompt || "",
95
- history: Array.isArray(req.history)
96
- ? req.history.map((entry) => ({
97
- role: entry && entry.role === "assistant" ? "assistant" : "user",
98
- text: String((entry && (entry.text ?? entry.content)) || ""),
99
- }))
100
- : [],
138
+ history: boundedAcpHistory(req.history),
101
139
  userPrompt: req.prompt || "",
102
140
  backendLabel: spec.label,
103
141
  locale,
104
- permission: req.permission,
142
+ permission,
105
143
  runtimeSource: bin,
106
144
  cwd: req.cwd,
107
145
  env: req.env || process.env,
@@ -132,4 +170,13 @@ async function runAcpTurn(req) {
132
170
  }
133
171
  }
134
172
 
135
- module.exports = { ACP_KINDS, acpDriverAvailability, acpSpecFor, runAcpTurn };
173
+ module.exports = {
174
+ ACP_KINDS,
175
+ ACP_HISTORY_MAX_ENTRIES,
176
+ ACP_HISTORY_MAX_BYTES,
177
+ ACP_HISTORY_MAX_ENTRY_BYTES,
178
+ boundedAcpHistory,
179
+ acpDriverAvailability,
180
+ acpSpecFor,
181
+ runAcpTurn,
182
+ };
@@ -23,13 +23,32 @@ const path = require("node:path");
23
23
  const { spawnSync } = require("node:child_process");
24
24
 
25
25
  const home = () => os.homedir();
26
+ const MAX_AUTH_EVIDENCE_BYTES = 256 * 1024;
27
+ const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
26
28
 
27
29
  function fileEvidence(rel, label) {
28
30
  const p = path.join(home(), ...rel);
31
+ let fd = null;
29
32
  try {
30
- if (fs.existsSync(p) && fs.statSync(p).size > 0) return { status: "evidence", detail: label || p };
33
+ const before = fs.lstatSync(p);
34
+ if (
35
+ before.isSymbolicLink() || !before.isFile() || before.nlink !== 1 ||
36
+ before.size <= 0 || before.size > MAX_AUTH_EVIDENCE_BYTES
37
+ ) return null;
38
+ fd = fs.openSync(p, fs.constants.O_RDONLY | NOFOLLOW);
39
+ const after = fs.fstatSync(fd);
40
+ if (
41
+ after.isSymbolicLink() || !after.isFile() || after.nlink !== 1 ||
42
+ after.size <= 0 || after.size > MAX_AUTH_EVIDENCE_BYTES ||
43
+ after.dev !== before.dev || after.ino !== before.ino
44
+ ) return null;
45
+ return { status: "evidence", detail: label || p };
31
46
  } catch { /* unreadable == no evidence */ }
32
- return null;
47
+ finally {
48
+ if (fd !== null) {
49
+ try { fs.closeSync(fd); } catch { /* already closed */ }
50
+ }
51
+ }
33
52
  }
34
53
 
35
54
  function envEvidence(names) {
@@ -6,21 +6,41 @@
6
6
  * 여기서는 "실행 파일이 있는가"만 결정론적으로 본다. 모델 응답 여부는 판정하지 않는다
7
7
  * (없으면 no_runtime 정직 정지 — 폴백 금지는 상위 계층의 계약).
8
8
  */
9
- const { spawnSync } = require("node:child_process");
9
+ const fs = require("node:fs");
10
+ const path = require("node:path");
10
11
  // kind 목록/실행 파일 이름의 정본은 runtimes/kinds.cjs 하나다 — 여기서 다시 적지 않는다.
11
12
  const { RUNTIME_BIN, CLI_KINDS } = require("./kinds.cjs");
12
13
 
13
14
  const CLI_RUNTIMES = CLI_KINDS;
14
15
 
15
16
  function whichSync(bin) {
16
- const cmd = process.platform === "win32" ? "where" : "which";
17
- try {
18
- const res = spawnSync(cmd, [bin], { encoding: "utf8", timeout: 4000 });
19
- if (res.status === 0) {
20
- const line = String(res.stdout || "").split(/\r?\n/).find((l) => l.trim());
21
- return line ? line.trim() : null;
17
+ const name = typeof bin === "string" ? bin.trim() : "";
18
+ if (!name || name.includes("\0") || path.basename(name) !== name) return null;
19
+ const pathValue = String(process.env.PATH || "");
20
+ if (!pathValue) return null;
21
+ const windows = process.platform === "win32";
22
+ const configuredExts = windows
23
+ ? String(process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
24
+ : [""];
25
+ const hasWindowsExt = windows && configuredExts.some((ext) => name.toLowerCase().endsWith(ext.toLowerCase()));
26
+ const extensions = hasWindowsExt ? [""] : configuredExts;
27
+ for (const rawDir of pathValue.split(path.delimiter)) {
28
+ const unquoted = rawDir.length >= 2 && rawDir.startsWith('"') && rawDir.endsWith('"')
29
+ ? rawDir.slice(1, -1)
30
+ : rawDir;
31
+ const directory = path.resolve(unquoted || ".");
32
+ for (const ext of extensions) {
33
+ const candidate = path.join(directory, name + ext);
34
+ try {
35
+ const stat = fs.statSync(candidate);
36
+ if (!stat.isFile()) continue;
37
+ fs.accessSync(candidate, windows ? fs.constants.F_OK : fs.constants.X_OK);
38
+ return candidate;
39
+ } catch {
40
+ // Continue to the next PATH entry just like a shell command lookup.
41
+ }
22
42
  }
23
- } catch { /* not found */ }
43
+ }
24
44
  return null;
25
45
  }
26
46
 
@@ -94,15 +94,23 @@ const API_BACKEND_SPECS = ["anthropic", "openai", "google", "ollama", "upstage"]
94
94
  const RUNTIME_SPECS = [...NATIVE_CLI_KINDS, ...API_BACKEND_SPECS];
95
95
 
96
96
  /**
97
- * 저장 계약(automation 등)이 허용하는 kind 전체 — CLI + 로컬/BYOK 실행 kind.
98
- * 데스크탑 shared/runtime-kinds.ts 와 동형(터미널 표기: antigravity→agy, acp 미지원).
97
+ * 저장 계약(automation 등)이 허용하는 kind 전체.
98
+ *
99
+ * 공유 SQLite의 소유자는 Desktop이므로 Desktop shared/runtime-kinds.ts의 저장
100
+ * 어휘를 모두 받아야 한다. 이 Terminal이 아직 실행하지 못하는 kind도 구조적으로
101
+ * 정상인 핀은 "손상"이 아니라 pinned-runtime-unavailable로 정직하게 멈춘다.
102
+ * agy/gemini는 이전 Terminal·Desktop 행의 전진 호환 별칭이다.
99
103
  */
100
- const CONTRACT_RUNTIME_KINDS = [...CLI_KINDS, "byok", "ollama", "lmstudio", "mlx"];
104
+ const CONTRACT_RUNTIME_KINDS = [
105
+ "claude-code", "codex", "antigravity", "kimi", "grok", "cursor",
106
+ "byok", "ollama", "lmstudio", "mlx", "acp", "agentlas",
107
+ "agy", "gemini",
108
+ ];
101
109
 
102
110
  /** 저장 계약이 허용하는 LLM 백엔드 — 데스크탑 shared/runtime-backends.ts 와 동일 15종. */
103
111
  const CONTRACT_RUNTIME_BACKENDS = [
104
112
  "anthropic", "openai", "google", "ollama", "lmstudio", "mlx", "upstage", "custom", "glm",
105
- "kimi", "deepseek", "minimax", "xai", "openrouter", "cursor",
113
+ "kimi", "deepseek", "minimax", "xai", "openrouter", "cursor", "agentlas",
106
114
  ];
107
115
 
108
116
  module.exports = {
@@ -7,9 +7,10 @@
7
7
  * 스키마 소유권은 데스크탑에 있으므로 여기서는 방어적으로만 읽는다(tableExists/
8
8
  * columnExists) — 구버전 DB에서 열이 없으면 "오버라이드 없음"으로 조용히 전진.
9
9
  *
10
- * 해석 사다리(오너 계약): 명시(--runtime) > 에이전트별 오버라이드 > prefs >
11
- * active_runtime > detected. resolve.cjs 건드리지 않는다 모듈이
12
- * 오버라이드 층만 앞에 얹고 나머지는 resolve.cjs 위임한다.
10
+ * 해석 사다리(오너 계약): 명시(--runtime) > 에이전트별 오버라이드 > 역할별
11
+ * Dashboard 후보 풀 > prefs > active_runtime > detected. 역할 풀은 사용자가
12
+ * Orchestrator/Worker별로 정한 실행 우선순위이므로 전역 legacy prefs보다 앞선다.
13
+ * resolve.cjs 는 건드리지 않는다 — 이 모듈이 앞의 두 층을 얹는다.
13
14
  */
14
15
  const { tableExists, columnExists } = require("../core/db.cjs");
15
16
  const { RUNTIME_BIN, whichSync } = require("./detect.cjs");
@@ -63,7 +63,7 @@ function sharedRuntimeKind(row) {
63
63
  // never discard `agy` and silently fall through to Gemini or another CLI.
64
64
  const selectedBin = path.posix.basename(path.win32.basename(String(row.source || ""))).toLowerCase();
65
65
  if (row.kind === "gemini" && /^agy(?:\.exe|\.cmd)?$/i.test(selectedBin)) return "agy";
66
- return row.kind;
66
+ return KINDS.canonicalRuntimeKind(row.kind);
67
67
  }
68
68
 
69
69
  class NoRuntimeError extends Error {
@@ -79,21 +79,22 @@ class NoRuntimeError extends Error {
79
79
  */
80
80
  function resolveRuntime({ db, prefs, explicit }) {
81
81
  if (explicit) {
82
- const api = apiRuntime(explicit, null, "explicit");
82
+ const explicitKind = KINDS.canonicalRuntimeKind(explicit);
83
+ const api = apiRuntime(explicitKind, null, "explicit");
83
84
  if (api) return api;
84
- const bin = RUNTIME_BIN[explicit];
85
+ const bin = RUNTIME_BIN[explicitKind];
85
86
  if (!bin) throw new NoRuntimeError(`unknown runtime: ${explicit}`);
86
- if (!CLI_EXECUTABLE_KINDS.has(explicit)) {
87
- const acpHint = ACP_CLI_KINDS.has(explicit)
87
+ if (!CLI_EXECUTABLE_KINDS.has(explicitKind)) {
88
+ const acpHint = ACP_CLI_KINDS.has(explicitKind)
88
89
  ? ` — its ACP driver needs the desktop core with electron/runtime/acp.js (npm run vendor:core / agentlas doctor)`
89
90
  : "";
90
91
  throw new NoRuntimeError(`runtime '${explicit}' has no v2 streaming driver yet (available: ${[...EXECUTABLE_KINDS].join(", ")})${acpHint}`);
91
92
  }
92
93
  const p = whichSync(bin);
93
94
  if (!p) throw new NoRuntimeError(`runtime '${explicit}' requested but '${bin}' is not on PATH`);
94
- return { kind: explicit, bin: p, source: "explicit" };
95
+ return { kind: explicitKind, bin: p, source: "explicit" };
95
96
  }
96
- const pref = prefs && prefs.runtime;
97
+ const pref = KINDS.canonicalRuntimeKind(prefs && prefs.runtime);
97
98
  if (pref && API_EXECUTABLE_KINDS.has(pref)) {
98
99
  return apiRuntime(pref, null, "prefs");
99
100
  }
@@ -5,6 +5,7 @@
5
5
  * Persistence is owned by Desktop migration v79. Terminal reads defensively:
6
6
  * orchestrator row -> legacy active_runtime -> null
7
7
  * worker direct row -> orchestrator row/legacy active_runtime
8
+ * multimodal direct row -> null (never inherits a conversational runtime)
8
9
  *
9
10
  * The worker may inherit upward for quality; the orchestrator never falls
10
11
  * downward to the worker row.
@@ -13,7 +14,7 @@ const { canonicalRuntimeKind } = require("./kinds.cjs");
13
14
  const { tableExists, columnExists } = require("../core/db.cjs");
14
15
 
15
16
  const MODEL_ROLE_TABLE = "model_roles";
16
- const VALID_ROLES = new Set(["orchestrator", "worker"]);
17
+ const VALID_ROLES = new Set(["orchestrator", "worker", "multimodal"]);
17
18
 
18
19
  function cleanText(value) {
19
20
  const trimmed = typeof value === "string" ? value.trim() : "";
@@ -136,17 +137,18 @@ function resolvedModelRole(db, role = "orchestrator") {
136
137
  if (role === "orchestrator") {
137
138
  return normalizedRow(roleRow(db, "orchestrator"), role) || legacyOrchestrator(db);
138
139
  }
139
- const worker = normalizedRow(roleRow(db, "worker"), "worker");
140
- if (worker && !worker.inherit) return worker;
140
+ const direct = normalizedRow(roleRow(db, role), role);
141
+ if (role === "multimodal") return direct;
142
+ if (direct && !direct.inherit) return direct;
141
143
  const orchestrator = resolvedModelRole(db, "orchestrator");
142
144
  if (!orchestrator) return null;
143
145
  return {
144
146
  ...orchestrator,
145
147
  role: "worker",
146
148
  inherit: true,
147
- updatedAt: worker?.updatedAt || orchestrator.updatedAt,
149
+ updatedAt: direct?.updatedAt || orchestrator.updatedAt,
148
150
  sourceLayer:
149
- worker?.sourceLayer === "model-role"
151
+ direct?.sourceLayer === "model-role"
150
152
  ? "model-role-inherit"
151
153
  : "active-runtime-inherit",
152
154
  };