agentlas 1.0.61 → 1.0.63

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.
@@ -13,6 +13,7 @@
13
13
  * 하위 명령:
14
14
  * connect 상태 표
15
15
  * connect telegram <agent|firm> 이 대상에 봇을 연결(토큰 stdin) + 방 페어링
16
+ * connect pair <id> 타임아웃된 방 페어링을 다시 기다림
16
17
  * connect test <id> 연결된 방에 확인 메시지
17
18
  * connect remove <id> 연결 제거(토큰 파일도 삭제)
18
19
  */
@@ -23,8 +24,8 @@ const readline = require("node:readline");
23
24
 
24
25
  function usage(ko) {
25
26
  return ko
26
- ? "사용법: agentlas connect [ status | telegram <agent|firm> [--auto] | test <id> | remove <id> ]"
27
- : "Usage: agentlas connect [ status | telegram <agent|firm> [--auto] | test <id> | remove <id> ]";
27
+ ? "사용법: agentlas connect [ status | telegram <agent|firm> [--auto] | pair <id> | test <id> | remove <id> ]"
28
+ : "Usage: agentlas connect [ status | telegram <agent|firm> [--auto] | pair <id> | test <id> | remove <id> ]";
28
29
  }
29
30
 
30
31
  function resolveTarget(db, token) {
@@ -111,8 +112,8 @@ async function connectTelegram(ctx, targetToken, { auto = false } = {}) {
111
112
  const botAt = started.botUsername ? "@" + started.botUsername : "(bot)";
112
113
  ctx.out(`${ctx.ui.green("✓")} ${ko ? "봇 확인됨" : "bot verified"}: ${botAt} → ${target.name}`);
113
114
  ctx.out(ko
114
- ? `이제 텔레그램에서 ${botAt} 에게 아무 메시지나 보내세요 (예: /start). 방을 기다립니다…`
115
- : `Now message ${botAt} on Telegram (e.g. /start). Waiting for the chat…`);
115
+ ? `이제 텔레그램에서 ${botAt} 에게 정확히 \`/start ${started.id}\` 보내세요. 방을 기다립니다…`
116
+ : `Now send exactly \`/start ${started.id}\` to ${botAt} on Telegram. Waiting for the chat…`);
116
117
 
117
118
  let paired;
118
119
  try {
@@ -127,8 +128,8 @@ async function connectTelegram(ctx, targetToken, { auto = false } = {}) {
127
128
 
128
129
  if (!paired) {
129
130
  ctx.out(ctx.ui.dim(ko
130
- ? `방을 못 받았습니다(2분 초과). ${botAt} 에게 메시지를 보낸 뒤 다시: agentlas connect test ${started.id}`
131
- : `No chat received (2 min). Message ${botAt}, then retry: agentlas connect test ${started.id}`));
131
+ ? `방을 못 받았습니다(2분 초과). ${botAt} 에게 \`/start ${started.id}\` 를 보낸 뒤 다시: agentlas connect pair ${started.id}`
132
+ : `No chat received (2 min). Send \`/start ${started.id}\` to ${botAt}, then retry: agentlas connect pair ${started.id}`));
132
133
  return 0;
133
134
  }
134
135
  ctx.out(`${ctx.ui.green("✓")} ${ko ? "방 연결됨" : "chat paired"}: ${paired.telegram_chat_title || paired.telegram_chat_id}`);
@@ -152,6 +153,33 @@ async function run(ctx, args) {
152
153
  if (!targetToken) { ctx.err(usage(ko)); return 1; }
153
154
  return connectTelegram(ctx, targetToken, { auto });
154
155
  }
156
+ if (sub === "pair") {
157
+ if (!args[1]) { ctx.err(ko ? "사용법: agentlas connect pair <id>" : "Usage: agentlas connect pair <id>"); return 1; }
158
+ const row = tg.getBinding(ctx.db(), args[1]);
159
+ if (!row) { ctx.err(ko ? "연결을 찾을 수 없습니다." : "connection not found."); return 1; }
160
+ if (row.telegram_chat_id) {
161
+ ctx.out(`${ctx.ui.green("✓")} ${ko ? "이미 방이 연결돼 있습니다" : "chat already paired"}: ${row.telegram_chat_title || row.telegram_chat_id}`);
162
+ return 0;
163
+ }
164
+ ctx.out(ko
165
+ ? `텔레그램에서 @${row.bot_username || "bot"} 에게 정확히 \`/start ${row.id}\` 를 보내세요.`
166
+ : `Send exactly \`/start ${row.id}\` to @${row.bot_username || "bot"} on Telegram.`);
167
+ try {
168
+ if (typeof ctx.ui.startSpinner === "function") ctx.ui.startSpinner(ko ? "방 연결 대기 중…" : "Waiting for the chat…");
169
+ const paired = await tg.pairByPolling(ctx.db(), row.id, { timeoutMs: 120_000 });
170
+ if (typeof ctx.ui.stopSpinner === "function") ctx.ui.stopSpinner();
171
+ if (!paired) {
172
+ ctx.err(ko ? "방을 못 받았습니다(2분 초과). 같은 명령으로 다시 기다릴 수 있습니다." : "No chat received (2 min). Run the same command to wait again.");
173
+ return 1;
174
+ }
175
+ ctx.out(`${ctx.ui.green("✓")} ${ko ? "방 연결됨" : "chat paired"}: ${paired.telegram_chat_title || paired.telegram_chat_id}`);
176
+ return 0;
177
+ } catch (e) {
178
+ if (typeof ctx.ui.stopSpinner === "function") ctx.ui.stopSpinner();
179
+ ctx.err(`${ctx.ui.red("✖")} ${String((e && e.message) || e)}`);
180
+ return 1;
181
+ }
182
+ }
155
183
  if (sub === "test") {
156
184
  if (!args[1]) { ctx.err(ko ? "사용법: agentlas connect test <id>" : "Usage: agentlas connect test <id>"); return 1; }
157
185
  try {
@@ -48,7 +48,7 @@ async function run(ctx, args, deps) {
48
48
  case "ls":
49
49
  return list(io);
50
50
  case "open":
51
- return open(io, rest);
51
+ return open(io, rest, deps || {});
52
52
  case "help":
53
53
  case "--help":
54
54
  case "-h":
@@ -10,14 +10,35 @@
10
10
  const { compareSemVer } = require("../semver.cjs");
11
11
  const { readVersion } = require("../agentlas-banner.cjs");
12
12
 
13
- async function checkNpmLatest({ fetch: fetchImpl } = {}) {
13
+ async function checkNpmLatest({ fetch: fetchImpl, timeoutMs = 8_000 } = {}) {
14
14
  const f = fetchImpl || globalThis.fetch;
15
15
  const currentVersion = readVersion();
16
16
  let latestVersion = null;
17
+ const controller = new AbortController();
18
+ const boundedTimeoutMs = Math.min(60_000, Math.max(10, Number(timeoutMs) || 8_000));
19
+ let timeout;
17
20
  try {
18
- const resp = await f("https://registry.npmjs.org/agentlas/latest", { headers: { accept: "application/json" } });
19
- if (resp.ok) latestVersion = String((await resp.json()).version || "");
20
- } catch { /* offline 호출부에서 안내 */ }
21
+ // Bound both the connection and body parse. Some injected/custom fetch
22
+ // implementations ignore AbortSignal, so the explicit race is required to
23
+ // keep `agentlas update` from hanging the entire CLI indefinitely.
24
+ const request = Promise.resolve().then(async () => {
25
+ const resp = await f("https://registry.npmjs.org/agentlas/latest", {
26
+ headers: { accept: "application/json" },
27
+ signal: controller.signal,
28
+ });
29
+ return resp.ok ? String((await resp.json()).version || "") : null;
30
+ });
31
+ const expired = new Promise((_, reject) => {
32
+ timeout = setTimeout(() => {
33
+ const error = new Error(`npm update check timed out after ${boundedTimeoutMs}ms`);
34
+ error.code = "AGENTLAS_UPDATE_TIMEOUT";
35
+ try { controller.abort(error); } catch { controller.abort(); }
36
+ reject(error);
37
+ }, boundedTimeoutMs);
38
+ });
39
+ latestVersion = await Promise.race([request, expired]);
40
+ } catch { /* offline/timeout 등 — 호출부에서 안내 */ }
41
+ finally { if (timeout) clearTimeout(timeout); }
21
42
  const comparison = latestVersion ? compareSemVer(currentVersion, latestVersion) : null;
22
43
  return {
23
44
  currentVersion,
@@ -27,9 +48,9 @@ async function checkNpmLatest({ fetch: fetchImpl } = {}) {
27
48
  };
28
49
  }
29
50
 
30
- async function run(ctx, args) {
31
- const json = args.includes("--json");
32
- const status = await checkNpmLatest();
51
+ async function run(ctx, args, deps = {}) {
52
+ const json = ctx.output?.format === "json" || args.includes("--json");
53
+ const status = await checkNpmLatest(deps);
33
54
  if (json) {
34
55
  ctx.out(JSON.stringify(status, null, 2));
35
56
  return 0;
@@ -27,7 +27,17 @@ function readManifest() {
27
27
  try { return JSON.parse(fs.readFileSync(manifestPath(), "utf8")); } catch { return null; }
28
28
  }
29
29
 
30
- function cacheDir(version) { return path.join(userDataDir(), "desktop-core-cache", String(version)); }
30
+ function cacheRoot() { return path.join(userDataDir(), "desktop-core-cache"); }
31
+ function normalizedCacheVersion(version) {
32
+ const value = String(version ?? "").trim();
33
+ if (!value || value === "." || value === "..") return null;
34
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) ? value : null;
35
+ }
36
+ function cacheDir(version) {
37
+ const normalized = normalizedCacheVersion(version);
38
+ if (!normalized) throw new TypeError("Desktop core manifest has an unsafe cache version");
39
+ return path.join(cacheRoot(), normalized);
40
+ }
31
41
  function cacheDistDir(version) { return path.join(cacheDir(version), "dist"); }
32
42
 
33
43
  function sha256File(file) {
@@ -38,13 +48,48 @@ function sha256File(file) {
38
48
 
39
49
  /** 이미 캐시에 온전히 풀려 있으면 그 dist 경로, 아니면 null. */
40
50
  function cachedCoreRoot(manifest = readManifest()) {
41
- if (!manifest) return null;
42
- const dist = cacheDistDir(manifest.version);
43
- const marker = path.join(cacheDir(manifest.version), ".complete");
44
- if (fs.existsSync(marker) && fs.existsSync(path.join(dist, "electron", "workflow", "run-graph.js"))) return dist;
51
+ const version = normalizedCacheVersion(manifest?.version);
52
+ if (!version || !manifest?.sha256) return null;
53
+ const dist = cacheDistDir(version);
54
+ const marker = path.join(cacheDir(version), ".complete");
55
+ if (!fs.existsSync(path.join(dist, "electron", "workflow", "run-graph.js"))) return null;
56
+ try {
57
+ const completed = JSON.parse(fs.readFileSync(marker, "utf8"));
58
+ if (
59
+ completed?.schemaVersion === 2
60
+ && String(completed.version) === version
61
+ && completed.sha256 === manifest.sha256
62
+ ) return dist;
63
+ } catch {
64
+ // Timestamp-only and malformed markers predate the content-bound cache
65
+ // contract. They must be refreshed instead of trusted as executable code.
66
+ }
45
67
  return null;
46
68
  }
47
69
 
70
+ /**
71
+ * Remove only cache entries that are recognizably old engine downloads.
72
+ * Unknown files under the dedicated root are preserved: cleanup must never
73
+ * widen from a versioned engine cache into arbitrary user data.
74
+ */
75
+ function pruneStaleCaches(keepVersion) {
76
+ const root = cacheRoot();
77
+ let removed = 0;
78
+ let entries = [];
79
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return removed; }
80
+ for (const entry of entries) {
81
+ if (!entry.isDirectory() || entry.name === String(keepVersion) || entry.name.includes(".partial-")) continue;
82
+ const dir = path.join(root, entry.name);
83
+ const recognizable = fs.existsSync(path.join(dir, ".complete"))
84
+ || fs.existsSync(path.join(dir, "desktop-core.tar.gz"))
85
+ || fs.existsSync(path.join(dir, "dist", "electron", "workflow", "run-graph.js"));
86
+ if (!recognizable) continue;
87
+ fs.rmSync(dir, { recursive: true, force: true });
88
+ removed += 1;
89
+ }
90
+ return removed;
91
+ }
92
+
48
93
  /**
49
94
  * 매니페스트가 가리키는 데스크탑 코어를 내려받아 캐시에 푼다.
50
95
  * onNotice(text): 사용자에게 보여줄 안내(무엇을·왜 받는지) — 조용히 받지 않는다.
@@ -52,47 +97,75 @@ function cachedCoreRoot(manifest = readManifest()) {
52
97
  */
53
98
  async function fetchDesktopCore({ onNotice } = {}) {
54
99
  const manifest = readManifest();
55
- if (!manifest || !manifest.url || !manifest.sha256) return null;
100
+ const version = normalizedCacheVersion(manifest?.version);
101
+ if (!manifest || !version || !manifest.url || !manifest.sha256) return null;
56
102
 
57
- const existing = cachedCoreRoot(manifest);
103
+ const existing = cachedCoreRoot({ ...manifest, version });
58
104
  if (existing) return existing;
59
105
 
60
106
  const say = (t) => { if (typeof onNotice === "function") onNotice(t); };
61
107
  say(`Downloading the graph-execution engine (${manifest.sizeBytes ? Math.round(manifest.sizeBytes / 1024 / 1024) + " MB" : "one-time"}) from ${manifest.url} …`);
62
108
 
63
- const dir = cacheDir(manifest.version);
64
- fs.rmSync(dir, { recursive: true, force: true });
65
- fs.mkdirSync(dir, { recursive: true });
66
- const tarPath = path.join(dir, "desktop-core.tar.gz");
109
+ const dir = cacheDir(version);
110
+ const partialDir = path.join(
111
+ cacheRoot(),
112
+ `${version}.partial-${process.pid}-${crypto.randomBytes(6).toString("hex")}`,
113
+ );
114
+ fs.mkdirSync(partialDir, { recursive: true });
115
+ const tarPath = path.join(partialDir, "desktop-core.tar.gz");
67
116
 
68
- const res = await fetch(manifest.url);
69
- if (!res.ok) { say(`Download failed: HTTP ${res.status}`); return null; }
117
+ let res;
118
+ try {
119
+ res = await fetch(manifest.url);
120
+ } catch (error) {
121
+ say(`Download failed: ${error?.message || error}`);
122
+ fs.rmSync(partialDir, { recursive: true, force: true });
123
+ return null;
124
+ }
125
+ if (!res.ok) {
126
+ say(`Download failed: HTTP ${res.status}`);
127
+ fs.rmSync(partialDir, { recursive: true, force: true });
128
+ return null;
129
+ }
70
130
  const buf = Buffer.from(await res.arrayBuffer());
71
131
  fs.writeFileSync(tarPath, buf);
72
132
 
73
133
  const digest = sha256File(tarPath);
74
134
  if (digest !== manifest.sha256) {
75
135
  say(`Checksum mismatch (expected ${manifest.sha256.slice(0, 12)}…, got ${digest.slice(0, 12)}…) — refusing to use it.`);
76
- fs.rmSync(dir, { recursive: true, force: true });
136
+ fs.rmSync(partialDir, { recursive: true, force: true });
137
+ return null;
138
+ }
139
+ if (Number.isFinite(Number(manifest.sizeBytes)) && Number(manifest.sizeBytes) !== buf.length) {
140
+ say(`Size mismatch (expected ${manifest.sizeBytes} bytes, got ${buf.length}) — refusing to use it.`);
141
+ fs.rmSync(partialDir, { recursive: true, force: true });
77
142
  return null;
78
143
  }
79
144
 
80
- const extract = spawnSync("tar", ["-xzf", tarPath, "-C", dir], { encoding: "utf8" });
145
+ const extract = spawnSync("tar", ["-xzf", tarPath, "-C", partialDir], { encoding: "utf8" });
81
146
  if (extract.status !== 0) {
82
147
  say(`Extraction failed: ${extract.stderr || extract.error || "unknown error"}`);
83
- fs.rmSync(dir, { recursive: true, force: true });
148
+ fs.rmSync(partialDir, { recursive: true, force: true });
84
149
  return null;
85
150
  }
86
151
  fs.rmSync(tarPath);
87
152
 
88
- if (!fs.existsSync(path.join(cacheDistDir(manifest.version), "electron", "workflow", "run-graph.js"))) {
153
+ if (!fs.existsSync(path.join(partialDir, "dist", "electron", "workflow", "run-graph.js"))) {
89
154
  say("Downloaded archive did not contain the expected engine files.");
90
- fs.rmSync(dir, { recursive: true, force: true });
155
+ fs.rmSync(partialDir, { recursive: true, force: true });
91
156
  return null;
92
157
  }
93
- fs.writeFileSync(path.join(dir, ".complete"), new Date().toISOString());
158
+ fs.writeFileSync(path.join(partialDir, ".complete"), JSON.stringify({
159
+ schemaVersion: 2,
160
+ version,
161
+ sha256: manifest.sha256,
162
+ completedAt: new Date().toISOString(),
163
+ }) + "\n");
164
+ fs.rmSync(dir, { recursive: true, force: true });
165
+ fs.renameSync(partialDir, dir);
166
+ pruneStaleCaches(version);
94
167
  say("Engine ready.");
95
- return cacheDistDir(manifest.version);
168
+ return cacheDistDir(version);
96
169
  }
97
170
 
98
- module.exports = { readManifest, cachedCoreRoot, fetchDesktopCore, cacheDistDir };
171
+ module.exports = { readManifest, cachedCoreRoot, fetchDesktopCore, cacheDistDir, pruneStaleCaches };
@@ -30,7 +30,10 @@ function localCoreBin() {
30
30
  if (process.platform === "win32") return null;
31
31
  for (const candidate of candidates) {
32
32
  try {
33
- if (candidate && fs.existsSync(candidate)) return candidate;
33
+ if (!candidate) continue;
34
+ const stat = fs.statSync(candidate);
35
+ fs.accessSync(candidate, fs.constants.X_OK);
36
+ if (stat.isFile()) return candidate;
34
37
  } catch { /* keep looking */ }
35
38
  }
36
39
  return null;
@@ -69,12 +72,25 @@ function createLocalCoreClient({ cwd, timeoutMs = 60_000 } = {}) {
69
72
  const pending = new Map();
70
73
  let exited = false;
71
74
 
72
- child.on("exit", () => {
75
+ const failPending = (code, message) => {
76
+ if (exited) return;
73
77
  exited = true;
74
78
  for (const [, entry] of pending) {
75
- entry.reject(coreError("local_core_exited", "the local core process exited before responding"));
79
+ entry.reject(coreError(code, message));
76
80
  }
77
81
  pending.clear();
82
+ };
83
+ child.once("error", (error) => {
84
+ failPending("local_core_spawn_failed", `the local core process could not start: ${(error && error.message) || error}`);
85
+ });
86
+ child.on("exit", () => {
87
+ failPending("local_core_exited", "the local core process exited before responding");
88
+ });
89
+ // Always drain stderr. Leaving the pipe unread lets a verbose Core fill the
90
+ // kernel buffer and deadlock an otherwise healthy tools/call.
91
+ child.stderr.on("data", () => {});
92
+ child.stdin.on("error", (error) => {
93
+ failPending("local_core_transport_error", `the local core input stream failed: ${(error && error.message) || error}`);
78
94
  });
79
95
  child.stdout.on("data", (chunk) => {
80
96
  buffer += chunk;
@@ -104,16 +120,35 @@ function createLocalCoreClient({ cwd, timeoutMs = 60_000 } = {}) {
104
120
  resolve: (message) => { clearTimeout(timer); resolve(message); },
105
121
  reject: (error) => { clearTimeout(timer); reject(error); },
106
122
  });
107
- child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
123
+ try {
124
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`, (error) => {
125
+ if (!error) return;
126
+ const entry = pending.get(id);
127
+ if (!entry) return;
128
+ pending.delete(id);
129
+ entry.reject(coreError("local_core_transport_error", `${method} could not be written to the local core: ${error.message}`));
130
+ });
131
+ } catch (error) {
132
+ const entry = pending.get(id);
133
+ pending.delete(id);
134
+ if (entry) entry.reject(coreError("local_core_transport_error", `${method} could not be written to the local core: ${error.message}`));
135
+ }
108
136
  });
109
137
 
110
138
  async function ensureInitialized() {
111
139
  if (!initialized) {
112
- initialized = rpc("initialize", {
113
- protocolVersion: "2024-11-05",
114
- capabilities: {},
115
- clientInfo: { name: "agentlas-terminal", version: "2" },
116
- });
140
+ initialized = (async () => {
141
+ const response = await rpc("initialize", {
142
+ protocolVersion: "2024-11-05",
143
+ capabilities: {},
144
+ clientInfo: { name: "agentlas-terminal", version: "2" },
145
+ });
146
+ if (response.error) {
147
+ throw coreError("local_core_initialize_failed", `initialize: ${response.error.message || JSON.stringify(response.error)}`);
148
+ }
149
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`);
150
+ return response;
151
+ })();
117
152
  }
118
153
  return initialized;
119
154
  }
@@ -141,9 +141,12 @@ function curateCliReply(db, text, ctx) {
141
141
  if (scope === "discard" || scope === "session") { logCli(ctx.projectPath, { action: scope, kind, content, at: now }); continue; }
142
142
  if (scope === "project" && !ctx.projectPath) scope = "team_memory";
143
143
  const ppath = scope === "project" ? ctx.projectPath : null;
144
+ // Same ownership rule as agentlas-memory-governance: team is shared
145
+ // (NULL owner), while an agent_repo memory belongs to the exact agent.
146
+ const scopedAgentId = scope === "agent_repo" ? (ctx.agentId || null) : null;
144
147
  const requestContext = normalizeRequestContext(ev, ctx, ppath);
145
148
  try {
146
- const dup = db.prepare("SELECT id,scope,kind,content,confidence,sensitivity,context_json FROM memory_entries WHERE scope=? AND kind=? AND lower(trim(content))=? AND superseded_at IS NULL AND (project_path IS ? OR project_path=?) LIMIT 1").get(scope, kind, content.toLowerCase(), ppath, ppath);
149
+ const dup = db.prepare("SELECT id,scope,kind,content,confidence,sensitivity,context_json FROM memory_entries WHERE scope=? AND kind=? AND lower(trim(content))=? AND superseded_at IS NULL AND (project_path IS ? OR project_path=?) AND (agent_id IS ? OR agent_id=?) LIMIT 1").get(scope, kind, content.toLowerCase(), ppath, ppath, scopedAgentId, scopedAgentId);
147
150
  if (dup) {
148
151
  rememberCurated({ ...dup, requestContext });
149
152
  continue;
@@ -151,7 +154,7 @@ function curateCliReply(db, text, ctx) {
151
154
  const memoryId = randomUUID();
152
155
  const confidence = ev.confidence || "medium";
153
156
  const sensitivity = ev.sensitivity || "internal";
154
- db.prepare("INSERT INTO memory_entries (id,scope,kind,content,project_id,project_path,agent_id,chat_id,confidence,sensitivity,evidence_json,context_json,superseded_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NULL,?)").run(memoryId, scope, kind, content, ctx.projectId || null, ppath, ctx.agentId || null, null, confidence, sensitivity, JSON.stringify(Array.isArray(ev.evidence_refs) ? ev.evidence_refs : []), JSON.stringify(requestContext), now);
157
+ db.prepare("INSERT INTO memory_entries (id,scope,kind,content,project_id,project_path,agent_id,chat_id,confidence,sensitivity,evidence_json,context_json,superseded_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NULL,?)").run(memoryId, scope, kind, content, ctx.projectId || null, ppath, scopedAgentId, null, confidence, sensitivity, JSON.stringify(Array.isArray(ev.evidence_refs) ? ev.evidence_refs : []), JSON.stringify(requestContext), now);
155
158
  rememberCurated({ id: memoryId, scope, kind, content, confidence, sensitivity, requestContext });
156
159
  logCli(ctx.projectPath, { action: "written", scope, kind, content, request_context: requestContext, at: now });
157
160
  } catch { /* ignore */ }
@@ -6,7 +6,7 @@
6
6
  */
7
7
  const fs = require("node:fs");
8
8
  const path = require("node:path");
9
- const { spawn } = require("node:child_process");
9
+ const { spawnSync } = require("node:child_process");
10
10
  const { userDataDir } = require("../core/paths.cjs");
11
11
  const { fail } = require("./common.cjs");
12
12
 
@@ -58,11 +58,18 @@ function list(io) {
58
58
  }
59
59
 
60
60
  // `oberon open [path]` — 산출물 폴더를 OS 파일 매니저로 연다.
61
- function open(io, args) {
61
+ function open(io, args, options = {}) {
62
62
  const target = args[0] ? path.resolve(args[0]) : oberonHome();
63
63
  if (!fs.existsSync(target)) fail(`Path not found: ${target}`);
64
64
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
65
- spawn(opener, [target], { detached: true, stdio: "ignore" }).unref();
65
+ // `spawn(...).unref()` reported success before the OS opener had even spawned;
66
+ // a missing xdg-open/explorer then emitted an unhandled error and crashed the CLI
67
+ // after printing "Opening". The opener command is short-lived, so wait for its
68
+ // launch result and only claim success on exit 0.
69
+ const launch = options.spawnSyncImpl || spawnSync;
70
+ const result = launch(opener, [target], { stdio: "ignore", windowsHide: true });
71
+ if (result && result.error) fail(`Could not open ${target}: ${result.error.message}`);
72
+ if (!result || result.status !== 0) fail(`Could not open ${target} (opener exit ${result && result.status != null ? result.status : "unknown"})`);
66
73
  io.out(`Opening folder: ${target}`);
67
74
  return 0;
68
75
  }
@@ -209,8 +209,10 @@ async function runCareerGraphCli(args, opts) {
209
209
  }
210
210
  const paths = ensureCareerGraphCli(projectPath, opts.lang);
211
211
  if (sub === "open") {
212
- if (!opts.noOpen) openLocalPathCli(paths.inboxPath, opts.notify);
213
- return [`${ko ? "커리어 그래프 수신함을 열었습니다" : "Opened Career Graph inbox"}: ${paths.inboxPath}`];
212
+ const opened = opts.noOpen ? true : openLocalPathCli(paths.inboxPath, opts.notify);
213
+ return [`${opened
214
+ ? (ko ? "커리어 그래프 수신함을 열었습니다" : "Opened Career Graph inbox")
215
+ : (ko ? "커리어 그래프 수신함을 자동으로 열지 못했습니다. 직접 여세요" : "Could not open Career Graph inbox automatically; open it manually")}: ${paths.inboxPath}`];
214
216
  }
215
217
  if (sub === "add") {
216
218
  const flags = parseFlagsCli(normalizedArgs.slice(1));
@@ -185,19 +185,21 @@ function cliMemoryContext(db, projectPath, agentId = null, task = "") {
185
185
  WHERE superseded_at IS NULL AND (
186
186
  (scope='user_identity' AND project_path IS NULL)
187
187
  OR (scope='project' AND project_path=?)
188
- OR (scope IN ('team_memory','agent_team','agent_repo') AND agent_id=? AND (project_path IS NULL OR project_path=?))
188
+ OR (scope IN ('team_memory','agent_team') AND (agent_id IS NULL OR agent_id=?) AND (project_path IS NULL OR project_path=?))
189
+ OR (scope='agent_repo' AND agent_id=? AND (project_path IS NULL OR project_path=?))
189
190
  )
190
191
  ORDER BY created_at DESC LIMIT 16
191
- `).all(projectPath, agentId, projectPath)
192
+ `).all(projectPath, agentId, projectPath, agentId, projectPath)
192
193
  : db.prepare(`
193
194
  SELECT id,kind,content,confidence,context_json,created_at
194
195
  FROM memory_entries
195
196
  WHERE superseded_at IS NULL AND (
196
197
  (scope='user_identity' AND project_path IS NULL)
197
- OR (scope IN ('team_memory','agent_team','agent_repo') AND agent_id=? AND project_path IS NULL)
198
+ OR (scope IN ('team_memory','agent_team') AND (agent_id IS NULL OR agent_id=?) AND project_path IS NULL)
199
+ OR (scope='agent_repo' AND agent_id=? AND project_path IS NULL)
198
200
  )
199
201
  ORDER BY created_at DESC LIMIT 16
200
- `).all(agentId);
202
+ `).all(agentId, agentId);
201
203
  // R21 W2d — confidence was stored (governance normalizeConfidence) but
202
204
  // never reached retrieval: no ranking function existed and the render
203
205
  // dropped the column, so a one-off guess and a high-confidence procedure
@@ -268,9 +270,10 @@ function curateCliReply(db, text, ctx) {
268
270
  if (scope === "discard" || scope === "session") { logCli(ctx.projectPath, { action: scope, kind, content, at: now }); continue; }
269
271
  if (scope === "project" && !ctx.projectPath) scope = "team_memory";
270
272
  const ppath = scope === "project" ? ctx.projectPath : null;
273
+ const scopedAgentId = scope === "agent_repo" ? (ctx.agentId || null) : null;
271
274
  const requestContext = normalizeRequestContext(ev, ctx, ppath);
272
275
  try {
273
- const dup = db.prepare("SELECT id,scope,kind,content,confidence,sensitivity,context_json FROM memory_entries WHERE scope=? AND kind=? AND lower(trim(content))=? AND superseded_at IS NULL AND (project_path IS ? OR project_path=?) LIMIT 1").get(scope, kind, content.toLowerCase(), ppath, ppath);
276
+ const dup = db.prepare("SELECT id,scope,kind,content,confidence,sensitivity,context_json FROM memory_entries WHERE scope=? AND kind=? AND lower(trim(content))=? AND superseded_at IS NULL AND (project_path IS ? OR project_path=?) AND (agent_id IS ? OR agent_id=?) LIMIT 1").get(scope, kind, content.toLowerCase(), ppath, ppath, scopedAgentId, scopedAgentId);
274
277
  if (dup) {
275
278
  rememberCurated({ ...dup, requestContext });
276
279
  continue;
@@ -278,7 +281,7 @@ function curateCliReply(db, text, ctx) {
278
281
  const memoryId = randomUUID();
279
282
  const confidence = ev.confidence || "medium";
280
283
  const sensitivity = ev.sensitivity || "internal";
281
- db.prepare("INSERT INTO memory_entries (id,scope,kind,content,project_id,project_path,agent_id,chat_id,confidence,sensitivity,evidence_json,context_json,superseded_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NULL,?)").run(memoryId, scope, kind, content, ctx.projectId || null, ppath, ctx.agentId || null, null, confidence, sensitivity, JSON.stringify(Array.isArray(ev.evidence_refs) ? ev.evidence_refs : []), JSON.stringify(requestContext), now);
284
+ db.prepare("INSERT INTO memory_entries (id,scope,kind,content,project_id,project_path,agent_id,chat_id,confidence,sensitivity,evidence_json,context_json,superseded_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NULL,?)").run(memoryId, scope, kind, content, ctx.projectId || null, ppath, scopedAgentId, null, confidence, sensitivity, JSON.stringify(Array.isArray(ev.evidence_refs) ? ev.evidence_refs : []), JSON.stringify(requestContext), now);
282
285
  rememberCurated({ id: memoryId, scope, kind, content, confidence, sensitivity, requestContext });
283
286
  logCli(ctx.projectPath, { action: "written", scope, kind, content, request_context: requestContext, at: now });
284
287
  } catch { /* ignore */ }
@@ -11,9 +11,10 @@
11
11
  * manifest/inbox를 만든다 — 초기화되지 않았으면 던진다.
12
12
  */
13
13
  const fs = require("node:fs");
14
+ const crypto = require("node:crypto");
14
15
  const os = require("node:os");
15
16
  const path = require("node:path");
16
- const { spawn } = require("node:child_process");
17
+ const { spawnSync } = require("node:child_process");
17
18
  const { loadArch } = require("../core/db.cjs");
18
19
  const { initializedAgentlasProjectPathCli } = require("./state.cjs");
19
20
 
@@ -64,7 +65,7 @@ function readJsonSafeCli(filePath, fallback) {
64
65
  }
65
66
 
66
67
  function writeJsonSafeCli(filePath, value) {
67
- fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
68
+ return writeJsonPrivateAtomicCli(filePath, value);
68
69
  }
69
70
 
70
71
  // 원자적(temp+rename) + 소유자 전용(0600) JSON 쓰기. 세션 ID/경로 등 민감 상태 파일용:
@@ -72,8 +73,12 @@ function writeJsonSafeCli(filePath, value) {
72
73
  // (2) 기본 umask(0644)로 cli-sessions.json/agent-routes.json이 world-readable이던 정보 노출을 함께 막는다.
73
74
  function writeJsonPrivateAtomicCli(filePath, value) {
74
75
  const dir = path.dirname(filePath);
75
- const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`);
76
- fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
76
+ const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
77
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", {
78
+ encoding: "utf8",
79
+ mode: 0o600,
80
+ flag: "wx",
81
+ });
77
82
  try {
78
83
  fs.renameSync(tmp, filePath);
79
84
  } catch (e) {
@@ -415,9 +420,12 @@ function registerOntologySourceCli(paths, source, kind, scope, cwd, lang) {
415
420
  function openLocalPathCli(targetPath, notify) {
416
421
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
417
422
  try {
418
- spawn(command, [targetPath], { detached: true, stdio: "ignore" }).unref();
423
+ const result = spawnSync(command, [targetPath], { stdio: "ignore", windowsHide: true });
424
+ if (result.error || result.status !== 0) throw result.error || new Error(`${command} exited ${result.status}`);
425
+ return true;
419
426
  } catch {
420
427
  if (typeof notify === "function") notify(`Open manually: ${targetPath}`);
428
+ return false;
421
429
  }
422
430
  }
423
431
 
@@ -450,8 +458,10 @@ async function runOntologyCli(args, opts) {
450
458
  }
451
459
  const paths = ensureOntologyCli(projectPath, opts.lang);
452
460
  if (sub === "open") {
453
- if (!opts.noOpen) openLocalPathCli(paths.inboxPath, opts.notify);
454
- return [`${ko ? "온톨로지 수신함을 열었습니다" : "Opened ontology inbox"}: ${paths.inboxPath}`];
461
+ const opened = opts.noOpen ? true : openLocalPathCli(paths.inboxPath, opts.notify);
462
+ return [`${opened
463
+ ? (ko ? "온톨로지 수신함을 열었습니다" : "Opened ontology inbox")
464
+ : (ko ? "온톨로지 수신함을 자동으로 열지 못했습니다. 직접 여세요" : "Could not open ontology inbox automatically; open it manually")}: ${paths.inboxPath}`];
455
465
  }
456
466
  if (sub === "add") {
457
467
  const flags = parseFlagsCli(normalizedArgs.slice(1));
@@ -10,6 +10,7 @@
10
10
  * 조용히 다른 런타임으로 넘어가지 않는다.
11
11
  */
12
12
  const { loadCoreAcpRuntime } = require("../core/desktop-core.cjs");
13
+ const permissions = require("../agentlas-permissions.cjs");
13
14
 
14
15
  // 정본(runtimes/kinds.cjs)의 ACP 3종에서 파생 — resolve.cjs의 ACP_CLI_KINDS와 같은 원소.
15
16
  const ACP_KINDS = new Set(require("./kinds.cjs").ACP_CLI_KINDS);
@@ -47,6 +48,28 @@ async function runAcpTurn(req) {
47
48
  return { text: "", session: req.session || {}, error: `runtime '${kind}' is not an ACP agent in this core`, errorKind: "unsupported", errorSource: "marker" };
48
49
  }
49
50
  const runner = mod.createAcpRunner(spec);
51
+ let mcpConfigPath;
52
+ try {
53
+ // ACP runner consumes the same Claude-compatible MCP config shape as Desktop.
54
+ // Reuse native-host's content-addressed, credential-isolating materializer so
55
+ // Cursor/Grok/Kimi receive exactly the already-consented Terminal allowlist.
56
+ const nativeHost = require("../agentlas-native-host.cjs");
57
+ const allowedMcpServers = permissions.normalize(req.permission) === "full"
58
+ ? (req.mcpServers || [])
59
+ : [];
60
+ mcpConfigPath = nativeHost.cliMcpConfigPath(allowedMcpServers, {
61
+ exactAllowlist: req.mcpAllowlistMode === "exact",
62
+ env: req.env || process.env,
63
+ }).file;
64
+ } catch (error) {
65
+ return {
66
+ text: "",
67
+ session: req.session || {},
68
+ error: `ACP MCP configuration failed: ${error && error.message ? error.message : error}`,
69
+ errorKind: "configuration",
70
+ errorSource: "marker",
71
+ };
72
+ }
50
73
  const locale = req.locale === "ko" ? "ko" : "en";
51
74
  let streaming = false;
52
75
  let lastText = "";
@@ -69,7 +92,12 @@ async function runAcpTurn(req) {
69
92
  try {
70
93
  const result = await runner({
71
94
  systemPrompt: req.systemPrompt || "",
72
- history: [],
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
+ : [],
73
101
  userPrompt: req.prompt || "",
74
102
  backendLabel: spec.label,
75
103
  locale,
@@ -78,10 +106,20 @@ async function runAcpTurn(req) {
78
106
  cwd: req.cwd,
79
107
  env: req.env || process.env,
80
108
  signal: req.signal,
109
+ mcpConfigPath,
110
+ runtimeSessionId: (req.session && (req.session.id || req.session.acpSessionId)) || undefined,
111
+ chatId: req.chatId,
112
+ approvalChatId: req.chatId,
113
+ agentId: req.agentId,
114
+ sessionFingerprintSeed: req.sessionFingerprintSeed,
115
+ unattended: req.unattended === true,
81
116
  ...(req.model ? { model: req.model } : {}),
82
117
  }, events);
83
118
  if (streaming) ui.streamEnd();
84
- const session = { ...(req.session || {}), ...(result.sessionId ? { acpSessionId: result.sessionId } : {}) };
119
+ const session = {
120
+ ...(req.session || {}),
121
+ ...(result.sessionId ? { id: result.sessionId, acpSessionId: result.sessionId } : {}),
122
+ };
85
123
  if (result.failure) {
86
124
  return { text: result.text || "", session, usage: null, error: result.failure.message, errorKind: result.failure.kind, errorSource: result.failure.source };
87
125
  }
@@ -89,7 +127,8 @@ async function runAcpTurn(req) {
89
127
  } catch (e) {
90
128
  if (streaming) ui.streamEnd();
91
129
  const message = e && e.message ? e.message : String(e);
92
- return { text: "", session: req.session || {}, usage: null, error: message, errorKind: /abort/i.test(message) ? "cancelled" : "exit", errorSource: "marker" };
130
+ const detail = process.env.AGENTLAS_DEBUG && e && e.stack ? e.stack : message;
131
+ return { text: "", session: req.session || {}, usage: null, error: detail, errorKind: /abort/i.test(message) ? "cancelled" : "exit", errorSource: "marker" };
93
132
  }
94
133
  }
95
134