agentlas 1.0.64 → 1.0.65

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.65 — 2026-08-29
4
+
5
+ - Browser, document, and Telegram CDP flows now create and track an Agentlas-owned
6
+ tab instead of attaching to the first page exposed by the debugging port. Existing
7
+ pages require explicit target selection, owned temporary pages are cleaned up, and
8
+ WebSocket attachment has a bounded open timeout.
9
+ - First-run SQLite bootstrap now starts at schema `user_version=106`, generated from
10
+ the Desktop migration ladder, including the multimodal model-role constraint.
11
+
3
12
  ## 1.0.64 — 2026-08-29
4
13
 
5
14
  - First-run database bootstrap now refuses symbolic links and non-file entries
package/README.md CHANGED
@@ -104,7 +104,7 @@ agentlas hep-local "<prompt>" # Same staffing, registered Local agents only
104
104
  agentlas hep-cloud "<prompt>" # Same staffing, owner Agent Cloud only
105
105
  agentlas hep-hub "<prompt>" # Same staffing, public Hub only
106
106
  agentlas call "a,b" "<context>" # Call explicitly named Hub or Cloud agents
107
- agentlas browser [...] # Real browser hardpoint launcher
107
+ agentlas browser [...] # Real browser hardpoint launcher (opens an owned tab)
108
108
  agentlas route "<prompt>" [--json] # Preview routing decisions without execution
109
109
  agentlas research <sub> # Research loadout (status|gather|search|read|plan)
110
110
  ```
@@ -230,7 +230,7 @@ The launcher (`bin/agentlas.cjs`) runs system Node against `engine/`. The defaul
230
230
  | Windows | `%APPDATA%\Agentlas` |
231
231
  | Linux | `$XDG_CONFIG_HOME/Agentlas` (default `~/.config/Agentlas`) |
232
232
 
233
- The SQLite database file is `agentlas.sqlite` (`user_version=97`). When launched for the first time without an existing database, it bootstraps schemas using `engine/bootstrap-schema.sql`. Consequently, **projects, installed agents, task history, automation sessions, and MCP registrations are shared across Desktop and Terminal**. The first ordered project agent remains the controller; additional agents are task-scoped and are stored only as execution ledgers, never as global conversations or durable owners.
233
+ The SQLite database file is `agentlas.sqlite` (`user_version=106`). When launched for the first time without an existing database, it bootstraps schemas using `engine/bootstrap-schema.sql`. Consequently, **projects, installed agents, task history, automation sessions, and MCP registrations are shared across Desktop and Terminal**. The first ordered project agent remains the controller; additional agents are task-scoped and are stored only as execution ledgers, never as global conversations or durable owners.
234
234
 
235
235
  **Single migration authority.** The Desktop app owns the schema migration ladder; the CLI never migrates the shared database. `engine/bootstrap-schema.sql` is generated by running that ladder to completion against an empty database, so a CLI-created store already sits at the ladder head and has nothing left for Desktop to upgrade. If the CLI opens a store older than the version it knows, it refuses with an actionable message instead of migrating or silently proceeding — a second migrator on this lock-free file is what corrupted the store once before. On a machine with no Desktop app, an operator can set `AGENTLAS_STORE_MIGRATION_ROLE=owner` for a single deliberate upgrade run with every other Agentlas process closed.
236
236
 
@@ -1,4 +1,4 @@
1
- -- Agentlas 첫 실행 부트스트랩 스키마 (생성: 2026-08-29T07:55:23Z)
1
+ -- Agentlas 첫 실행 부트스트랩 스키마 (생성: 2026-08-29T13:29:10Z)
2
2
  --
3
3
  -- ★생성물이다. 손으로 고치지 말고 재생성하라:
4
4
  -- node scripts/gen-bootstrap-schema.cjs
@@ -6,7 +6,7 @@
6
6
  -- 정본은 Desktop 의 마이그레이션 사다리(agentlas_desktop/electron/store/db.ts, SCHEMA_VERSION).
7
7
  -- 이 파일은 그 사다리를 **빈 DB** 에 끝까지 돌린 결과의 덤프이므로, 터미널이 만든 DB 는
8
8
  -- 처음부터 사다리 머리에 있다 — 데스크탑이 나중에 승급할 것이 남지 않는다.
9
- PRAGMA user_version=105;
9
+ PRAGMA user_version=106;
10
10
  CREATE TABLE active_runtime (
11
11
  id INTEGER PRIMARY KEY CHECK(id = 1),
12
12
  kind TEXT NOT NULL
@@ -1015,7 +1015,7 @@ CREATE TABLE meta (
1015
1015
  value TEXT NOT NULL
1016
1016
  );
1017
1017
  CREATE TABLE model_role_members (
1018
- role TEXT NOT NULL CHECK(role IN ('orchestrator','worker')),
1018
+ role TEXT NOT NULL CHECK(role IN ('orchestrator','worker','multimodal')),
1019
1019
  position INTEGER NOT NULL CHECK(position >= 1),
1020
1020
  kind TEXT NOT NULL,
1021
1021
  backend TEXT,
@@ -1027,7 +1027,7 @@ CREATE TABLE model_role_members (
1027
1027
  PRIMARY KEY(role, position)
1028
1028
  );
1029
1029
  CREATE TABLE model_roles (
1030
- role TEXT PRIMARY KEY CHECK(role IN ('orchestrator','worker')),
1030
+ role TEXT PRIMARY KEY CHECK(role IN ('orchestrator','worker','multimodal')),
1031
1031
  kind TEXT NOT NULL,
1032
1032
  backend TEXT,
1033
1033
  source TEXT,
@@ -20,6 +20,8 @@
20
20
  const http = require("node:http");
21
21
 
22
22
  const DEFAULT_PORT = Number(process.env.AGENTLAS_CDP_PORT || 9222);
23
+ const DEFAULT_WS_OPEN_TIMEOUT_MS = 5_000;
24
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
23
25
 
24
26
  function httpJson(port, path, { timeout = 3000, method = "GET" } = {}) {
25
27
  return new Promise((resolve, reject) => {
@@ -46,16 +48,90 @@ async function cdpReady(port = DEFAULT_PORT) {
46
48
  } catch { return false; }
47
49
  }
48
50
 
49
- /** 조종할 page 타겟 하나를 고른다(없으면 새로 연다). 반환: webSocketDebuggerUrl. */
50
- async function pickPageTarget(port = DEFAULT_PORT) {
51
- const targets = await httpJson(port, "/json");
52
- let page = (Array.isArray(targets) ? targets : []).find((t) => t.type === "page" && t.webSocketDebuggerUrl);
53
- if (!page) {
54
- // 탭을 연다(DevTools HTTP: PUT /json/new).
55
- page = await httpJson(port, "/json/new?about:blank", { method: "PUT" });
51
+ function verifiedPageTarget(target, port, ownsTarget = false) {
52
+ if (!target || target.type !== "page" || typeof target.id !== "string" || typeof target.webSocketDebuggerUrl !== "string") return null;
53
+ try {
54
+ const socket = new URL(target.webSocketDebuggerUrl);
55
+ if (socket.protocol !== "ws:" || !LOOPBACK_HOSTS.has(socket.hostname.toLowerCase()) || Number(socket.port) !== port) return null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ return { id: target.id, webSocketDebuggerUrl: target.webSocketDebuggerUrl, ownsTarget };
60
+ }
61
+
62
+ async function resolvePageTarget(port, { selection = "new", targetId = null } = {}) {
63
+ if (selection !== "new" && selection !== "existing") {
64
+ throw new Error(`unsupported CDP target selection: ${selection}`);
56
65
  }
57
- if (!page || !page.webSocketDebuggerUrl) throw new Error("no CDP page target available");
58
- return page.webSocketDebuggerUrl;
66
+ const rawTargets = await httpJson(port, "/json");
67
+ const pages = (Array.isArray(rawTargets) ? rawTargets : [])
68
+ .map((target) => verifiedPageTarget(target, port))
69
+ .filter(Boolean);
70
+
71
+ if (selection === "existing") {
72
+ if (!targetId) throw new Error("existing CDP target selection requires an explicit targetId");
73
+ const exact = pages.find((target) => target.id === String(targetId));
74
+ if (!exact) throw new Error(`no CDP page target matches targetId ${targetId}`);
75
+ return exact;
76
+ }
77
+
78
+ // Never reuse an arbitrary first page. It may be a user's active tab, and
79
+ // browser/document/Telegram flows must not navigate it behind their back.
80
+ // Create a target we own, then return its socket identity to the caller.
81
+ const created = await httpJson(port, `/json/new?${encodeURIComponent("about:blank")}`, { method: "PUT" });
82
+ const exact = verifiedPageTarget(created, port, true);
83
+ if (!exact) throw new Error("CDP did not return a verified page target for the new tab");
84
+ return exact;
85
+ }
86
+
87
+ /**
88
+ * Pick a page target without guessing ownership.
89
+ *
90
+ * The safe default is a fresh target. Reusing a page requires selection:
91
+ * `pickPageTarget(port, { selection: "existing", targetId })`.
92
+ */
93
+ async function pickPageTarget(port = DEFAULT_PORT, options = {}) {
94
+ return (await resolvePageTarget(port, options)).webSocketDebuggerUrl;
95
+ }
96
+
97
+ function closePageTarget(port, targetId) {
98
+ return new Promise((resolve) => {
99
+ const req = http.request({
100
+ host: "127.0.0.1",
101
+ port,
102
+ path: `/json/close/${encodeURIComponent(String(targetId))}`,
103
+ timeout: 3_000,
104
+ }, (res) => {
105
+ res.resume();
106
+ res.once("end", resolve);
107
+ });
108
+ req.once("error", resolve);
109
+ req.once("timeout", () => { req.destroy(); resolve(); });
110
+ req.end();
111
+ });
112
+ }
113
+
114
+ function waitForWebSocketOpen(ws, timeoutMs) {
115
+ return new Promise((resolve, reject) => {
116
+ let settled = false;
117
+ let timer;
118
+ const finish = (error) => {
119
+ if (settled) return;
120
+ settled = true;
121
+ clearTimeout(timer);
122
+ if (error) reject(error);
123
+ else resolve();
124
+ };
125
+ timer = setTimeout(() => {
126
+ const error = new Error(`CDP websocket open timed out after ${timeoutMs}ms`);
127
+ error.code = "CDP_WEBSOCKET_OPEN_TIMEOUT";
128
+ try { ws.close(); } catch { /* already closed */ }
129
+ finish(error);
130
+ }, timeoutMs);
131
+ ws.addEventListener("open", () => finish(), { once: true });
132
+ ws.addEventListener("error", () => finish(new Error("CDP websocket failed to open")), { once: true });
133
+ ws.addEventListener("close", () => finish(new Error("CDP websocket closed before opening")), { once: true });
134
+ });
59
135
  }
60
136
 
61
137
  /**
@@ -65,16 +141,31 @@ async function pickPageTarget(port = DEFAULT_PORT) {
65
141
  * evaluate(js, {awaitPromise}) Runtime.evaluate(returnByValue) — 값 반환
66
142
  * waitFor(jsPredicate, {timeoutMs, pollMs}) predicate 가 truthy 될 때까지
67
143
  */
68
- async function attachPage({ port = DEFAULT_PORT, wsUrl } = {}) {
69
- const url = wsUrl || (await pickPageTarget(port));
70
- const ws = new WebSocket(url);
71
- await new Promise((resolve, reject) => {
72
- ws.addEventListener("open", resolve, { once: true });
73
- ws.addEventListener("error", () => reject(new Error("CDP websocket failed to open")), { once: true });
74
- });
144
+ async function attachPage({ port = DEFAULT_PORT, wsUrl, selection = "new", targetId, connectTimeoutMs = DEFAULT_WS_OPEN_TIMEOUT_MS } = {}) {
145
+ const target = wsUrl
146
+ ? (() => {
147
+ try {
148
+ const socket = new URL(wsUrl);
149
+ if (socket.protocol !== "ws:" || !LOOPBACK_HOSTS.has(socket.hostname.toLowerCase()) || Number(socket.port) !== port) return null;
150
+ } catch { return null; }
151
+ return { id: null, webSocketDebuggerUrl: wsUrl, ownsTarget: false };
152
+ })()
153
+ : await resolvePageTarget(port, { selection, targetId });
154
+ if (!target) throw new Error(`invalid CDP websocket target for port ${port}`);
155
+
156
+ const url = target.webSocketDebuggerUrl;
157
+ let ws;
158
+ try {
159
+ ws = new WebSocket(url);
160
+ await waitForWebSocketOpen(ws, connectTimeoutMs);
161
+ } catch (error) {
162
+ if (target.ownsTarget) await closePageTarget(port, target.id);
163
+ throw error;
164
+ }
75
165
 
76
166
  let nextId = 0;
77
167
  const pending = new Map();
168
+ let closed = false;
78
169
  ws.addEventListener("message", (event) => {
79
170
  let msg;
80
171
  try { msg = JSON.parse(typeof event.data === "string" ? event.data : String(event.data)); } catch { return; }
@@ -85,8 +176,16 @@ async function attachPage({ port = DEFAULT_PORT, wsUrl } = {}) {
85
176
  else resolve(msg.result);
86
177
  }
87
178
  });
179
+ ws.addEventListener("close", () => {
180
+ for (const { reject, timer } of pending.values()) {
181
+ clearTimeout(timer);
182
+ reject(new Error("CDP websocket closed"));
183
+ }
184
+ pending.clear();
185
+ }, { once: true });
88
186
 
89
187
  function send(method, params = {}, { timeout = 30000 } = {}) {
188
+ if (closed) return Promise.reject(new Error("CDP page is closed"));
90
189
  const id = ++nextId;
91
190
  return new Promise((resolve, reject) => {
92
191
  const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP ${method} timed out`)); }, timeout);
@@ -94,7 +193,8 @@ async function attachPage({ port = DEFAULT_PORT, wsUrl } = {}) {
94
193
  resolve: (r) => { clearTimeout(timer); resolve(r); },
95
194
  reject: (e) => { clearTimeout(timer); reject(e); },
96
195
  });
97
- ws.send(JSON.stringify({ id, method, params }));
196
+ try { ws.send(JSON.stringify({ id, method, params })); }
197
+ catch (error) { pending.delete(id); clearTimeout(timer); reject(error); }
98
198
  });
99
199
  }
100
200
 
@@ -186,9 +286,19 @@ async function attachPage({ port = DEFAULT_PORT, wsUrl } = {}) {
186
286
  return { path: outPath, bytes: buf.length };
187
287
  }
188
288
 
189
- function close() { try { ws.close(); } catch { /* already closed */ } }
289
+ async function close({ closeTarget = false } = {}) {
290
+ if (closed) return;
291
+ closed = true;
292
+ try { ws.close(); } catch { /* already closed */ }
293
+ if (closeTarget && target.ownsTarget && target.id) await closePageTarget(port, target.id);
294
+ }
190
295
 
191
- return { navigate, evaluate, evalExpr, waitFor, focusSelector, typeInto, pressKey, clickSelector, printPdf, close, send };
296
+ return {
297
+ navigate, evaluate, evalExpr, waitFor, focusSelector, typeInto, pressKey,
298
+ clickSelector, printPdf, close, send,
299
+ targetId: target.id,
300
+ ownsTarget: target.ownsTarget,
301
+ };
192
302
  }
193
303
 
194
- module.exports = { cdpReady, pickPageTarget, attachPage, DEFAULT_PORT };
304
+ module.exports = { cdpReady, pickPageTarget, attachPage, DEFAULT_PORT, DEFAULT_WS_OPEN_TIMEOUT_MS };
@@ -93,12 +93,13 @@ async function browserLogin(ctx, rest) {
93
93
  const url = siteUrl(row.site);
94
94
 
95
95
  if (await cdp.cdpReady()) {
96
- // 이미 열린 Agentlas 브라우저를 로그인 페이지로 몬다(조종). 로그인은 사용자가 직접.
96
+ // 사용자의 현재 탭을 건드리지 않고 Agentlas 브라우저의 탭을 연다.
97
+ // 로그인은 사용자가 직접 한다.
97
98
  try {
98
- const page = await cdp.attachPage();
99
+ const page = await cdp.attachPage({ selection: "new" });
99
100
  try { await page.navigate(url, { waitMs: 1500 }); } finally { page.close(); }
100
101
  vault.logBrowserAction(db, { site: row.site, action: "login.open", target: url, result: "navigated" });
101
- ctx.out(`${ctx.ui.green("✓")} ${ko ? "브라우저를 로그인 페이지로 옮겼습니다" : "moved the browser to the login page"}: ${url}`);
102
+ ctx.out(`${ctx.ui.green("✓")} ${ko ? " 브라우저 탭을 로그인 페이지로 열었습니다" : "opened a new browser tab for login"}: ${url}`);
102
103
  } catch (e) {
103
104
  ctx.err(`${ctx.ui.red("✖")} ${String((e && e.message) || e)}`);
104
105
  return 1;
@@ -140,10 +141,10 @@ async function browserGo(ctx, rest) {
140
141
  return 1;
141
142
  }
142
143
  try {
143
- const page = await cdp.attachPage();
144
+ const page = await cdp.attachPage({ selection: "new" });
144
145
  let title;
145
146
  try { await page.navigate(siteUrl(url), { waitMs: 1500 }); title = await page.evalExpr("document.title"); } finally { page.close(); }
146
- ctx.out(`${ctx.ui.green("✓")} ${ko ? "이동했습니다" : "navigated"}: ${siteUrl(url)}${title ? ctx.ui.dim(` — ${title}`) : ""}`);
147
+ ctx.out(`${ctx.ui.green("✓")} ${ko ? "새 브라우저 탭에서 열었습니다" : "opened a new browser tab"}: ${siteUrl(url)}${title ? ctx.ui.dim(` — ${title}`) : ""}`);
147
148
  return 0;
148
149
  } catch (e) { ctx.err(`${ctx.ui.red("✖")} ${String((e && e.message) || e)}`); return 1; }
149
150
  }
@@ -51,7 +51,7 @@ async function toPdf(ctx, args) {
51
51
  let page;
52
52
  try {
53
53
  if (typeof ctx.ui.startSpinner === "function") ctx.ui.startSpinner(ko ? "PDF 굽는 중…" : "Rendering PDF…");
54
- page = await cdp.attachPage();
54
+ page = await cdp.attachPage({ selection: "new" });
55
55
  await page.navigate(url, { waitMs: 1800 });
56
56
  await page.waitFor("document.readyState === 'complete'", { timeoutMs: 8000 });
57
57
  const r = await page.printPdf(outPath, { landscape: flags.landscape });
@@ -63,7 +63,7 @@ async function toPdf(ctx, args) {
63
63
  ctx.err(`${ctx.ui.red("✖")} ${String((e && e.message) || e)}`);
64
64
  return 1;
65
65
  } finally {
66
- if (page) page.close();
66
+ if (page) await page.close({ closeTarget: true });
67
67
  }
68
68
  }
69
69
 
@@ -192,7 +192,7 @@ async function captureBotTokenViaBrowser({ timeoutMs = 180_000, onWait } = {}) {
192
192
  err.code = "cdp_unavailable";
193
193
  throw err;
194
194
  }
195
- const page = await cdp.attachPage();
195
+ const page = await cdp.attachPage({ selection: "new" });
196
196
  try {
197
197
  await page.navigate(BOTFATHER_WEB_URL, { waitMs: 2500 });
198
198
  const deadline = Date.now() + timeoutMs;
@@ -210,7 +210,7 @@ async function captureBotTokenViaBrowser({ timeoutMs = 180_000, onWait } = {}) {
210
210
  }
211
211
  return null;
212
212
  } finally {
213
- page.close();
213
+ await page.close({ closeTarget: true });
214
214
  }
215
215
  }
216
216
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlas",
3
- "version": "1.0.64",
3
+ "version": "1.0.65",
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"