agentlas 1.0.60 → 1.0.62

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 (56) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/engine/acp/server.cjs +47 -17
  3. package/engine/agentlas-evolution.cjs +85 -36
  4. package/engine/agentlas-experience-intake.cjs +21 -8
  5. package/engine/agentlas-memory-governance.cjs +88 -10
  6. package/engine/agentlas-permissions.cjs +95 -1
  7. package/engine/agentlas-tools.cjs +45 -2
  8. package/engine/agentlas.cjs +6 -3
  9. package/engine/agents/builder.cjs +4 -0
  10. package/engine/architecture.data.json +1 -1
  11. package/engine/automation/daemon.cjs +36 -3
  12. package/engine/automation/store.cjs +13 -11
  13. package/engine/bootstrap-schema.sql +216 -24
  14. package/engine/cloud/auth.cjs +34 -8
  15. package/engine/cloud-assets/cas.cjs +27 -1
  16. package/engine/cloud-assets/package.cjs +126 -0
  17. package/engine/cloud-assets/upload-scan-catalog.generated.cjs +13 -0
  18. package/engine/commands/career-graph.cjs +1 -1
  19. package/engine/commands/connect.cjs +34 -6
  20. package/engine/commands/graph.cjs +6 -2
  21. package/engine/commands/index.cjs +4 -1
  22. package/engine/commands/oberon.cjs +1 -1
  23. package/engine/commands/one.cjs +307 -0
  24. package/engine/commands/ontology.cjs +2 -2
  25. package/engine/commands/plugin.cjs +61 -16
  26. package/engine/commands/uninstall.cjs +43 -13
  27. package/engine/commands/update.cjs +25 -4
  28. package/engine/core/capability-grants.cjs +204 -0
  29. package/engine/core/desktop-core-fetch.cjs +94 -21
  30. package/engine/core/desktop-core.cjs +22 -0
  31. package/engine/experience/build.cjs +8 -0
  32. package/engine/graph/node-effect.cjs +56 -0
  33. package/engine/graph/package.cjs +6 -1
  34. package/engine/graph/vocabulary.generated.cjs +1 -1
  35. package/engine/hephaestus/local-core.cjs +44 -9
  36. package/engine/hub/install.cjs +7 -3
  37. package/engine/hub/plugins.cjs +165 -0
  38. package/engine/mcp/consent.cjs +140 -12
  39. package/engine/mcp/index.cjs +1 -0
  40. package/engine/mcp/plan.cjs +63 -8
  41. package/engine/memory-cli/curate.cjs +5 -2
  42. package/engine/oberon/outputs.cjs +10 -3
  43. package/engine/project/career-graph.cjs +9 -12
  44. package/engine/project/memory-context.cjs +9 -6
  45. package/engine/project/ontology.cjs +88 -36
  46. package/engine/runtimes/acp-driver.cjs +42 -3
  47. package/engine/sessions/memory-turn.cjs +39 -0
  48. package/engine/sessions/orchestrator.cjs +53 -4
  49. package/engine/sessions/session.cjs +28 -2
  50. package/engine/sessions/store.cjs +48 -12
  51. package/engine/telegram/connect.cjs +31 -10
  52. package/engine/ui/commands-catalog.cjs +1 -0
  53. package/engine/ui/repl.cjs +3 -1
  54. package/engine/ui/screens.cjs +30 -6
  55. package/engine/vendor/desktop-core.manifest.json +5 -5
  56. package/package.json +6 -3
@@ -7,21 +7,70 @@
7
7
  * 링버퍼에 쌓는다. 백그라운드 세션의 턴 종료는 'notice' 이벤트로 전면 세션에
8
8
  * 한 줄 알림된다.
9
9
  *
10
- * 동시 실행 상한: AGENTLAS_MAX_PARALLEL (기본 4). 상한 초과 스폰은 대기가 아니라
11
- * 정직한 거부 사용자가 세션을 정리하거나 상한을 올리게 안내한다.
10
+ * 동시 실행 상한: 공유 DB agent_concurrency(데스크탑 슬라이더와 같은 값)가 기본이고,
11
+ * AGENTLAS_MAX_PARALLEL **override**다(명시했을 때만 이긴다 예전엔 env 가
12
+ * 유일한 소스라 데스크탑과 터미널이 같은 머신에서 다른 예산을 들고 있었다).
13
+ * 상한 초과 스폰은 대기가 아니라 정직한 거부 — 사용자가 세션을 정리하거나 상한을 올리게 안내한다.
12
14
  */
15
+ const os = require("node:os");
13
16
  const { EventEmitter } = require("node:events");
14
17
  const { Session } = require("./session.cjs");
15
18
 
19
+ // 데스크탑 electron/store/concurrency.ts 와 같은 상수/공식 — 값이 갈리면 같은 머신의
20
+ // 두 제품이 다른 예산을 말한다. 바꿀 때는 반드시 양쪽을 함께 바꿀 것.
21
+ const AGENT_CONCURRENCY_HARD_MAX = 32;
22
+
23
+ /** 사양 기반 추천 동시성(데스크탑 recommendedConcurrency 와 동일 공식). */
24
+ function recommendedConcurrency() {
25
+ let cores = 4;
26
+ let totalMemGB = 8;
27
+ try { cores = Math.max(1, os.cpus().length); } catch { /* fall back */ }
28
+ try { totalMemGB = os.totalmem() / 1024 ** 3; } catch { /* fall back */ }
29
+ const coreBound = Math.max(1, cores - 2);
30
+ const memBound = Math.max(1, Math.floor((totalMemGB - 4) / 2));
31
+ return Math.max(1, Math.min(coreBound, memBound, AGENT_CONCURRENCY_HARD_MAX));
32
+ }
33
+
34
+ /*
35
+ * 공유 DB 핸들 — Orchestrator 생성 시 등록된다. maxParallel 이 배너 출력 등에서
36
+ * 오케스트레이터 없이도 불리므로(ui/shell.cjs), 핸들이 없을 때는 추천값으로 답한다.
37
+ */
38
+ let _concurrencyDb = null;
39
+ function setConcurrencyDb(db) {
40
+ _concurrencyDb = db || null;
41
+ }
42
+
43
+ function sharedDbConcurrency() {
44
+ if (!_concurrencyDb) return null;
45
+ try {
46
+ const row = _concurrencyDb.prepare("SELECT value FROM meta WHERE key='agent_concurrency'").get();
47
+ if (!row || row.value == null || row.value === "") return null;
48
+ const parsed = Number(row.value);
49
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
50
+ return Math.max(1, Math.min(Math.floor(parsed), AGENT_CONCURRENCY_HARD_MAX));
51
+ } catch {
52
+ // meta 테이블이 없는 옛/부분 DB — 추천값으로 폴백(조용한 실패가 아니라 설계된 폴백).
53
+ return null;
54
+ }
55
+ }
56
+
16
57
  function maxParallel() {
58
+ // 1) env 는 명시적 override — 사람이 이번 셸에서 일부러 정한 값이 항상 이긴다.
17
59
  const n = Number(process.env.AGENTLAS_MAX_PARALLEL);
18
- return Number.isInteger(n) && n > 0 ? Math.min(n, 16) : 4;
60
+ if (Number.isInteger(n) && n > 0) return Math.min(n, AGENT_CONCURRENCY_HARD_MAX);
61
+ // 2) 공유 DB 의 사용자 슬라이더 값(데스크탑과 동일 예산).
62
+ const shared = sharedDbConcurrency();
63
+ if (shared !== null) return shared;
64
+ // 3) 둘 다 없으면 사양 기반 추천값(데스크탑의 미설정 동작과 동일).
65
+ return recommendedConcurrency();
19
66
  }
20
67
 
21
68
  class Orchestrator extends EventEmitter {
22
69
  constructor({ db, lang }) {
23
70
  super();
24
71
  this.db = db;
72
+ // 공유 DB 를 동시성 기본값의 소스로 등록 — 데스크탑 슬라이더와 같은 예산을 쓴다.
73
+ if (db) setConcurrencyDb(db);
25
74
  this.lang = lang || "en";
26
75
  this.sessions = new Map(); // "s1" -> Session
27
76
  this._seq = 0;
@@ -189,4 +238,4 @@ class Orchestrator extends EventEmitter {
189
238
  }
190
239
  }
191
240
 
192
- module.exports = { Orchestrator, maxParallel };
241
+ module.exports = { Orchestrator, maxParallel, setConcurrencyDb };
@@ -76,7 +76,7 @@ class Session extends EventEmitter {
76
76
  this.fingerprint = crypto.createHash("sha256")
77
77
  .update(`${this.runtime.kind}\n${this.agent.id}\n${this.agent.systemPrompt || ""}`)
78
78
  .digest("hex");
79
- this.runtimeSession = store.loadRuntimeSession(this.db, this.chatId, this.runtime.kind, this.fingerprint);
79
+ this.runtimeSession = store.loadRuntimeSession(this.db, this.chatId, this.runtime.kind, this.fingerprint, this.agent.id);
80
80
 
81
81
  this._sink = new EventSink({
82
82
  lang: this.lang,
@@ -183,6 +183,13 @@ class Session extends EventEmitter {
183
183
  this.lastError = null;
184
184
  this._privateRecoveryEvidence.length = 0;
185
185
  this._record({ type: "turn-start", at: Date.now(), prompt });
186
+ // ACP agents that cannot load a provider-side session need prior conversation
187
+ // reattached on a fresh session. Capture it before appending this turn so the
188
+ // current user prompt is not duplicated in both history and userPrompt.
189
+ const priorHistory = store.chatHistory(this.db, this.chatId).map((row) => ({
190
+ role: row.role,
191
+ text: row.text,
192
+ }));
186
193
  store.appendMessage(this.db, this.chatId, "user", prompt);
187
194
  let governedTurn = null;
188
195
  try {
@@ -242,6 +249,14 @@ class Session extends EventEmitter {
242
249
  cwd: this.cwd,
243
250
  permission: this.permission,
244
251
  env: process.env,
252
+ /*
253
+ * 공유 능력 규칙(capability_grants)을 도구 관문이 읽을 수 있게 한다 —
254
+ * 데스크탑에서 "항상 허용"/"영구 거부"한 행동이 여기서도 그대로 적용된다
255
+ * (agentlas-tools.runTool). 이 세 칸이 없으면 관문은 등급만 보고 판단한다.
256
+ */
257
+ db: this.db,
258
+ chatId: this.chatId,
259
+ agentId: this.agent && this.agent.id,
245
260
  },
246
261
  ui: this._sink,
247
262
  signal: ctrl.signal,
@@ -252,6 +267,9 @@ class Session extends EventEmitter {
252
267
  this._apiAbort = null;
253
268
  }
254
269
  } else {
270
+ const isAcpRuntime = require("../runtimes/acp-driver.cjs").ACP_KINDS.has(this.runtime.kind);
271
+ const turnAbort = isAcpRuntime ? new AbortController() : null;
272
+ if (turnAbort) this._apiAbort = turnAbort;
255
273
  const req = {
256
274
  kind: this.runtime.kind,
257
275
  bin: this.runtime.bin,
@@ -261,6 +279,11 @@ class Session extends EventEmitter {
261
279
  systemPrompt,
262
280
  permission: this.permission,
263
281
  session: { ...this.runtimeSession },
282
+ history: priorHistory,
283
+ chatId: this.chatId,
284
+ agentId: this.agent.id,
285
+ locale: this.lang,
286
+ sessionFingerprintSeed: this.fingerprint,
264
287
  model: this.runtime.model,
265
288
  effort: this.runtime.effort,
266
289
  // 사용자가 이미 동의한 MCP 서버를 턴에 싣는다.
@@ -278,6 +301,7 @@ class Session extends EventEmitter {
278
301
  mcpServers: this._consentedMcpServers(),
279
302
  onSpawn: (child) => { this._child = child; },
280
303
  };
304
+ if (turnAbort) req.signal = turnAbort.signal;
281
305
  if (this._spawnImpl) req.spawn = this._spawnImpl;
282
306
  if (this._timeoutConfig) req.timeoutConfig = this._timeoutConfig;
283
307
  try {
@@ -317,6 +341,8 @@ class Session extends EventEmitter {
317
341
  }
318
342
  } catch (e) {
319
343
  res = { text: "", session: req.session, error: (e && e.message) || String(e) };
344
+ } finally {
345
+ if (turnAbort && this._apiAbort === turnAbort) this._apiAbort = null;
320
346
  }
321
347
  }
322
348
  this._child = null;
@@ -370,7 +396,7 @@ class Session extends EventEmitter {
370
396
  if (persistText) store.appendMessage(this.db, this.chatId, "assistant", persistText);
371
397
  if (res && res.session && res.session.id) {
372
398
  this.runtimeSession = { id: res.session.id };
373
- store.saveRuntimeSession(this.db, this.chatId, this.runtime.kind, this.runtimeSession, this.fingerprint);
399
+ store.saveRuntimeSession(this.db, this.chatId, this.runtime.kind, this.runtimeSession, this.fingerprint, this.agent.id);
374
400
  }
375
401
  if (res && res.usage) this.usage = res.usage;
376
402
 
@@ -51,29 +51,65 @@ function chatHistory(db, chatId, limit = 40) {
51
51
  }
52
52
 
53
53
  /*
54
- * CLI resume 세션 ID 영속화 — 데스크탑 chat_runtime_sessions동일 스키마
55
- * (chat_id, kind, session_id, fingerprint). fingerprint가 다르면 resume하지
56
- * 않는다 시스템 프롬프트가 바뀐 세션을 이어붙이면 지시가 오염된다.
54
+ * CLI resume 세션 ID 영속화 — 데스크탑 `store/runtime-sessions.ts` 같은 키를 쓴다.
55
+ *
56
+ * ★키가 셋이다: (chat_id, kind, **agent_id**). 좌석-세션 전에는 둘이었고, 이 사본은
57
+ * 둘로 남아 있었다. 그래서 `ON CONFLICT(chat_id, kind)` 가 실제 기본키와 맞지 않아
58
+ * INSERT 가 예외를 냈고, 그 예외를 아래 catch 가 삼켰다 — **터미널의 resume 이 통째로
59
+ * 사라진 채 아무 표시도 나지 않았다.** 매 턴이 새 세션으로 시작하니 사용자에게는
60
+ * "말한 걸 자꾸 잊는다"로 보인다.
61
+ *
62
+ * 조용히 실패하는 catch 가 이 병을 몇 판이나 숨겼다. 이제 저장 성공 여부를 boolean 으로
63
+ * 돌려주고, 실패는 한 줄 남긴다 — 턴 자체는 유효하므로 던지지는 않는다.
64
+ *
65
+ * fingerprint 가 다르면 resume 하지 않는다 — 시스템 프롬프트가 바뀐 세션을 이어붙이면
66
+ * 지시가 오염된다.
57
67
  */
58
- function loadRuntimeSession(db, chatId, kind, fingerprint) {
68
+ function normalizeRuntimeAgentId(agentId) {
69
+ return typeof agentId === "string" ? agentId.trim() : "";
70
+ }
71
+
72
+ function loadRuntimeSession(db, chatId, kind, fingerprint, agentId) {
73
+ const agent = normalizeRuntimeAgentId(agentId);
59
74
  try {
60
- const row = db.prepare("SELECT session_id, fingerprint FROM chat_runtime_sessions WHERE chat_id=? AND kind=?").get(chatId, kind);
75
+ const select = db.prepare(
76
+ "SELECT session_id, fingerprint FROM chat_runtime_sessions WHERE chat_id=? AND kind=? AND agent_id=?",
77
+ );
78
+ let row = select.get(chatId, kind, agent);
79
+ // v103 이전 행은 agent_id='' 로 이관돼 있다. 정확한 키에 없으면 그 행을 승계 후보로
80
+ // 읽는다 — 다른 봇의 세션이면 바로 아래 지문 검증이 스스로 버린다(데스크탑과 동형).
81
+ if (!row && agent !== "") row = select.get(chatId, kind, "");
61
82
  if (row && row.session_id && row.fingerprint === fingerprint) return { id: row.session_id };
62
- } catch { /* 테이블 부재 — resume 없이 진행 */ }
83
+ } catch { /* 테이블 부재(구형 DB) — resume 없이 진행 */ }
63
84
  return {};
64
85
  }
65
86
 
66
- function saveRuntimeSession(db, chatId, kind, session, fingerprint) {
87
+ function saveRuntimeSession(db, chatId, kind, session, fingerprint, agentId) {
67
88
  const sessionId = session && session.id ? String(session.id) : "";
68
- if (!sessionId) return;
89
+ if (!sessionId) return false;
90
+ const agent = normalizeRuntimeAgentId(agentId);
69
91
  try {
70
92
  runWriteTransaction(db, () => {
71
93
  db.prepare(
72
- "INSERT INTO chat_runtime_sessions (chat_id, kind, session_id, fingerprint, updated_at) VALUES (?,?,?,?,?) " +
73
- "ON CONFLICT(chat_id, kind) DO UPDATE SET session_id=excluded.session_id, fingerprint=excluded.fingerprint, updated_at=excluded.updated_at",
74
- ).run(chatId, kind, sessionId, fingerprint, nowIso());
94
+ "INSERT OR REPLACE INTO chat_runtime_sessions (chat_id, kind, agent_id, session_id, fingerprint, updated_at) VALUES (?,?,?,?,?,?)",
95
+ ).run(chatId, kind, agent, sessionId, fingerprint, nowIso());
96
+ // 레거시 행을 승계했다면 이제 새 키가 정본이다 — 같은 세션을 가리키는 '' 행을
97
+ // 정리해 다음 점유자가 이 봇의 세션을 승계 후보로 오인하지 않게 한다.
98
+ if (agent !== "") {
99
+ db.prepare(
100
+ "DELETE FROM chat_runtime_sessions WHERE chat_id=? AND kind=? AND agent_id='' AND session_id=?",
101
+ ).run(chatId, kind, sessionId);
102
+ }
75
103
  });
76
- } catch { /* 스키마가 다르면 resume만 포기 — 턴 자체는 유효 */ }
104
+ return true;
105
+ } catch (error) {
106
+ // 턴 자체는 유효하므로 던지지 않는다. 다만 조용히 지나가지도 않는다 — 이 자리의
107
+ // 침묵이 resume 유실을 여러 판 동안 숨겼다.
108
+ if (process.env.AGENTLAS_DEBUG) {
109
+ console.error(`[agentlas] resume session not persisted: ${error && error.message ? error.message : error}`);
110
+ }
111
+ return false;
112
+ }
77
113
  }
78
114
 
79
115
  module.exports = { createChat, appendMessage, retitleChat, chatHistory, loadRuntimeSession, saveRuntimeSession, newId };
@@ -55,10 +55,15 @@ async function verifyBotToken(token, opts) {
55
55
  function tokenDir() {
56
56
  const dir = path.join(userDataDir(), "telegram");
57
57
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
58
+ if (process.platform !== "win32") fs.chmodSync(dir, 0o700);
58
59
  return dir;
59
60
  }
60
61
  function tokenFile(id) { return path.join(tokenDir(), `${id}.token`); }
61
- function saveToken(id, token) { fs.writeFileSync(tokenFile(id), token, { encoding: "utf8", mode: 0o600 }); }
62
+ function saveToken(id, token) {
63
+ const file = tokenFile(id);
64
+ fs.writeFileSync(file, token, { encoding: "utf8", mode: 0o600 });
65
+ if (process.platform !== "win32") fs.chmodSync(file, 0o600);
66
+ }
62
67
  function readToken(id) {
63
68
  try { return fs.readFileSync(tokenFile(id), "utf8").trim() || null; } catch { return null; }
64
69
  }
@@ -81,19 +86,31 @@ async function startConnection(db, targetKind, targetId, token, opts) {
81
86
  runWriteTransaction(db, () => {
82
87
  db.prepare(
83
88
  "INSERT INTO telegram_bindings (id, target_kind, target_id, bot_user_id, bot_username, bot_display_name, status, enabled, token_saved, token_fingerprint, created_at, updated_at) " +
84
- "VALUES (?,?,?,?,?,?,'waiting_for_chat',1,1,?,?,?)",
89
+ "VALUES (?,?,?,?,?,?,'waiting_for_chat',1,0,?,?,?)",
85
90
  ).run(id, targetKind, targetId, me.id, me.username || null, me.first_name || null, tokenFingerprint(token), now, now);
86
91
  });
87
- saveToken(id, token);
92
+ try {
93
+ saveToken(id, token);
94
+ runWriteTransaction(db, () => {
95
+ db.prepare("UPDATE telegram_bindings SET token_saved=1, updated_at=? WHERE id=?").run(new Date().toISOString(), id);
96
+ });
97
+ } catch (error) {
98
+ deleteToken(id);
99
+ try {
100
+ runWriteTransaction(db, () => db.prepare("DELETE FROM telegram_bindings WHERE id=?").run(id));
101
+ } catch { /* preserve the original storage failure */ }
102
+ throw error;
103
+ }
88
104
  // 남은 웹훅이 있으면 getUpdates가 막히므로 제거(있어도 무해).
89
105
  await telegramApi(token, "deleteWebhook", { drop_pending_updates: false }, opts).catch(() => null);
90
106
  return { id, botUsername: me.username || null };
91
107
  }
92
108
 
93
109
  /**
94
- * getUpdates 폴링으로 private 메시지의 chat을 이 바인딩에 귀속한다.
95
- * 보안: 데스크탑과 같은 규칙 미페어링·enabled·waiting_for_chat 신선 바인딩
96
- * (30분 이내)에만, private 채팅만. 반환: 페어링된 바인딩 또는 null(시간초과).
110
+ * getUpdates 폴링으로 `/start <bindingId>`를 보낸 private chat을 이 바인딩에 귀속한다.
111
+ * 보안: 이름을 발견한 제3자의 메시지가 로컬 에이전트를 탈취하지 못하도록 정확한
112
+ * 페어링 토큰을 요구한다. 최종 UPDATE도 미페어링·enabled·waiting 상태를 조건으로 삼아
113
+ * 두 Terminal 프로세스가 같은 바인딩을 동시에 덮어쓰지 못하게 한다.
97
114
  */
98
115
  async function pairByPolling(db, id, { timeoutMs = 120_000, opts, onWait } = {}) {
99
116
  const token = readToken(id);
@@ -110,6 +127,8 @@ async function pairByPolling(db, id, { timeoutMs = 120_000, opts, onWait } = {})
110
127
  if (typeof update.update_id === "number") offset = Math.max(offset, update.update_id);
111
128
  const message = update.message;
112
129
  if (!message || !message.chat || message.chat.type !== "private") continue;
130
+ const pairingToken = String(message.text || "").match(/^\/start(?:@\w+)?\s+(\S+)/i)?.[1] || "";
131
+ if (pairingToken !== id) continue;
113
132
  // 신선 미페어링 바인딩인지 재확인(경합 방지) 후 귀속.
114
133
  const row = getBinding(db, id);
115
134
  if (!row || row.telegram_chat_id || row.status !== "waiting_for_chat") continue;
@@ -117,11 +136,13 @@ async function pairByPolling(db, id, { timeoutMs = 120_000, opts, onWait } = {})
117
136
  if (!Number.isFinite(createdAt) || Date.now() - createdAt > 30 * 60 * 1000) throw new Error("pairing window expired (30 min) — reconnect");
118
137
  const now = new Date().toISOString();
119
138
  const title = message.chat.title || [message.chat.first_name, message.chat.last_name].filter(Boolean).join(" ") || message.chat.username || String(message.chat.id);
120
- runWriteTransaction(db, () => {
121
- db.prepare("UPDATE telegram_bindings SET telegram_chat_id=?, telegram_chat_title=?, status='chat_paired', last_update_id=?, updated_at=? WHERE id=?")
122
- .run(String(message.chat.id), title, offset, now, id);
139
+ const claimed = runWriteTransaction(db, () => {
140
+ return db.prepare(
141
+ "UPDATE telegram_bindings SET telegram_chat_id=?, telegram_chat_title=?, status='chat_paired', last_update_id=?, updated_at=? " +
142
+ "WHERE id=? AND telegram_chat_id IS NULL AND enabled=1 AND status='waiting_for_chat'",
143
+ ).run(String(message.chat.id), title, offset, now, id).changes === 1;
123
144
  });
124
- return getBinding(db, id);
145
+ if (claimed) return getBinding(db, id);
125
146
  }
126
147
  if (offset) {
127
148
  runWriteTransaction(db, () => {
@@ -47,6 +47,7 @@ const CATALOG = [
47
47
  { name: "help", group: "start", tier: "core", surfaces: BOTH, args: "[all|<command>]", argsKo: "[all|<명령>]", ko: "명령 보기 (all = 전체 목록)", en: "Show commands (all = the full list)" },
48
48
 
49
49
  // ── 2 work ────────────────────────────────────────────────────────────────
50
+ { name: "one", group: "work", tier: "core", surfaces: CLI, args: '["<prompt>"] [--list|--new]', argsKo: '["<프롬프트>"] [--list|--new]', ko: "개인 에이전트 One — 같은 One 대화를 이어감", en: "Your personal agent One — continues the same One conversation" },
50
51
  { name: "run", group: "work", tier: "core", surfaces: CLI, args: '[agent] "<task>"', argsKo: '[에이전트] "<작업>"', ko: "이 프로젝트 컨트롤러로 1회 실행", en: "Run once with this project's controller" },
51
52
  { name: "project", group: "work", tier: "core", surfaces: BOTH, args: "[status|use <agent>]", argsKo: "[status|use <에이전트>]", ko: "이 폴더를 프로젝트로 연결", en: "Connect this folder to a project" },
52
53
  { name: "storm", group: "work", tier: "core", surfaces: BOTH, args: '"<goal>"', argsKo: '"<목표>"', ko: "목표 하나를 계획→실행→검증까지", en: "Drive one goal: plan, execute, verify" },
@@ -792,7 +792,9 @@ function handleSlash(ctx, cmdline, api) {
792
792
  */
793
793
  // help/agents/list/mcp/doctor 등은 위 케이스에서 이미 처리된다.
794
794
  // acp: stdout becomes the protocol wire — meaningless (and destructive) inside the REPL.
795
- const REPL_EXCLUDED = new Set(["firm", "setup", "run", "acp"]);
795
+ // `one` 자기 readline 루프를 여는 대화형 명령이라 셸 안에서 중첩하지 않는다
796
+ // (run/firm/setup/acp 와 같은 이유). 셸에서는 `agentlas one` 을 밖에서 연다.
797
+ const REPL_EXCLUDED = new Set(["firm", "setup", "run", "acp", "one"]);
796
798
  if (!REPL_EXCLUDED.has(cmd) && commands.COMMANDS[cmd]) {
797
799
  const result = commands.COMMANDS[cmd]().run(ctx, rest);
798
800
  if (result && typeof result.then === "function") {
@@ -11,6 +11,8 @@
11
11
  * - 모든 화면은 ctx.out 이 아니라 ui 를 직접 받아 pi 프레임 안에 그린다.
12
12
  */
13
13
 
14
+ const path = require("node:path");
15
+
14
16
  function table(ui, rows, opts = {}) {
15
17
  // rows: [[col, col, …]] — 첫 행이 헤더. 폭은 CJK 셀 폭으로 계산한다.
16
18
  const { visWidth, truncateWidth } = require("./width.cjs");
@@ -52,6 +54,28 @@ function rows(db, sql, args = []) {
52
54
  }
53
55
  const shortTs = (v) => (v ? String(v).replace("T", " ").slice(0, 16) : "");
54
56
 
57
+ const ATTENTION_RUN_WHERE = `
58
+ r.id = (
59
+ SELECT r2.id FROM automation_runs r2
60
+ WHERE r2.automation_id IS r.automation_id
61
+ ORDER BY COALESCE(r2.started_at,'') DESC, r2.rowid DESC LIMIT 1
62
+ )
63
+ AND (
64
+ r.status IN ('error','partial','blocked','needs_input')
65
+ OR r.outcome IN ('needs_input','blocked','rejected')
66
+ OR (r.status = 'running' AND julianday(r.last_activity_at) < julianday('now','-15 minutes'))
67
+ )`;
68
+
69
+ function pathContains(root, candidate) {
70
+ if (!root) return false;
71
+ try {
72
+ const relative = path.relative(path.resolve(String(root)), path.resolve(String(candidate)));
73
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
55
79
  /* ── /dashboard — 데스크탑 dashboard 의 관제 패널 집합 ── */
56
80
  function dashboard(ui, db, en) {
57
81
  const chip = (paint, s) => paint(` ${s} `);
@@ -66,11 +90,12 @@ function dashboard(ui, db, en) {
66
90
  ui.line(` ${chip(ui.c.inverse, `${en ? "agents" : "에이전트"} ${local}`)} ${chip(ui.c.dim, `builtin ${builtin}`)} ${chip(ui.c.inverse, `${en ? "firms" : "회사"} ${firms}`)} ${chip(ui.c.dim, `${en ? "bookmarks" : "북마크"} ${marks}`)} ${chip(ui.c.dim, `${en ? "borrowed" : "대여"} ${borrowed}`)}`);
67
91
 
68
92
  // ── 확인 필요 (D1 숨은 계약 2: 없으면 실행이 조용히 멈춘 채 정상처럼 보인다) ──
69
- const pending = count(db, "SELECT COUNT(*) n FROM automation_node_approvals WHERE decision NOT IN ('approved','rejected')");
93
+ const pending = count(db, `SELECT COUNT(*) n FROM automation_runs r WHERE ${ATTENTION_RUN_WHERE}`);
70
94
  const stalled = rows(db,
71
95
  `SELECT r.id, a.name, r.status, r.last_activity_at
72
96
  FROM automation_runs r LEFT JOIN automations a ON a.id = r.automation_id
73
- WHERE r.status NOT IN ('ok','error','cancelled') ORDER BY COALESCE(r.last_activity_at,'') DESC LIMIT 5`);
97
+ WHERE ${ATTENTION_RUN_WHERE}
98
+ ORDER BY COALESCE(r.last_activity_at,'') DESC LIMIT 5`);
74
99
  ui.line("");
75
100
  ui.line(ui.c.bold(en ? "Needs attention" : "확인 필요"));
76
101
  if (!pending && !stalled.length) {
@@ -242,10 +267,10 @@ function projects(ui, db, en) {
242
267
  if (list.length) {
243
268
  table(ui, [[en ? "project" : "프로젝트", en ? "source" : "소스", en ? "chats" : "채팅", en ? "tasks" : "작업", en ? "updated" : "수정"],
244
269
  ...list.map((p) => [
245
- (p.folder_path && cwd.startsWith(p.folder_path) ? "▸ " : " ") + (p.name || p.id),
270
+ (pathContains(p.folder_path, cwd) ? "▸ " : " ") + (p.name || p.id),
246
271
  p.source_type || "local", String(p.chats), String(p.tasks), shortTs(p.updated_at)])],
247
272
  { cap: [30, 10, 6, 6, 16] });
248
- const here = list.find((p) => p.folder_path && cwd.startsWith(p.folder_path));
273
+ const here = list.find((p) => pathContains(p.folder_path, cwd));
249
274
  ui.line("");
250
275
  ui.line(here
251
276
  ? ui.c.dim(en ? `▸ this folder is connected to "${here.name}"` : `▸ 이 폴더는 "${here.name}"에 연결돼 있습니다`)
@@ -350,5 +375,4 @@ function firms(ui, db, en, ctx, arg) {
350
375
  void ctx;
351
376
  }
352
377
 
353
- module.exports = { dashboard, library, marketplace, settings, projects, automations, firms, table };
354
-
378
+ module.exports = { dashboard, library, marketplace, settings, projects, automations, firms, table, pathContains, ATTENTION_RUN_WHERE };
@@ -1,7 +1,7 @@
1
1
  {
2
- "version": "12",
3
- "url": "https://github.com/agentlas-ai/agentlas-terminal/releases/download/desktop-core-v12/desktop-core.tar.gz",
4
- "sha256": "cc2ad07bd7695a9dcee4e61f7eb1f65ea86454caf79a6e449e5afb10d3ff8150",
5
- "sizeBytes": 12563495,
6
- "writtenAt": "2026-08-19T23:39:35.152Z"
2
+ "version": "15",
3
+ "url": "https://github.com/agentlas-ai/agentlas-terminal/releases/download/desktop-core-v15/desktop-core.tar.gz",
4
+ "sha256": "df39404b55ea4e6a3571e416167f64e75bcde92fdb86f19bd26e54746156f87b",
5
+ "sizeBytes": 18167726,
6
+ "writtenAt": "2026-08-29T08:38:53.468Z"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlas",
3
- "version": "1.0.60",
3
+ "version": "1.0.62",
4
4
  "description": "Agentlas project terminal — run project controllers and task-scoped agent teams from the terminal. Standalone: no desktop app process required.",
5
5
  "bin": {
6
6
  "agentlas": "bin/agentlas.cjs"
@@ -13,8 +13,9 @@
13
13
  "vendor:core": "node scripts/vendor-desktop-core.cjs",
14
14
  "test:tool-access-notice-parity": "node test/tool-access-notice-parity.cjs",
15
15
  "verify:engine-reachable": "node scripts/verify-engine-reachable.cjs",
16
- "prepublishOnly": "npm run verify:engine-reachable && npm run verify:way-out",
17
- "verify:way-out": "node scripts/verify-raised-errors-have-a-way-out.cjs"
16
+ "prepublishOnly": "npm run vendor:core && npm run verify:engine-reachable && npm run verify:way-out",
17
+ "verify:way-out": "node scripts/verify-raised-errors-have-a-way-out.cjs",
18
+ "verify:node-effect-parity": "node scripts/verify-node-effect-parity.cjs"
18
19
  },
19
20
  "engines": {
20
21
  "node": ">=20.19"
@@ -26,6 +27,8 @@
26
27
  "files": [
27
28
  "bin/",
28
29
  "engine/",
30
+ "!engine/vendor/desktop-core/",
31
+ "!engine/vendor/desktop-core.tar.gz",
29
32
  "install.sh",
30
33
  "install.ps1",
31
34
  "CHANGELOG.md",