agentlas 1.0.29 → 1.0.36

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 (50) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/bin/agentlas.cjs +15 -0
  3. package/engine/agentlas-cloud-runtime.cjs +54 -4
  4. package/engine/agentlas-input.cjs +10 -1
  5. package/engine/agentlas-judgment.cjs +0 -0
  6. package/engine/agentlas-native-host.cjs +69 -4
  7. package/engine/agentlas-ui.cjs +2 -0
  8. package/engine/agentlas-workforce.cjs +28 -5
  9. package/engine/agentlas.cjs +64 -2
  10. package/engine/agents/builder.cjs +84 -0
  11. package/engine/agents/router.cjs +8 -2
  12. package/engine/automation/launchd.cjs +131 -0
  13. package/engine/bootstrap-schema.sql +6 -44
  14. package/engine/browser/cdp.cjs +188 -0
  15. package/engine/browser/vault.cjs +124 -0
  16. package/engine/cli-output.cjs +262 -0
  17. package/engine/cloud-assets/package.cjs +5 -1
  18. package/engine/commands/automation.cjs +37 -1
  19. package/engine/commands/browser.cjs +166 -8
  20. package/engine/commands/build.cjs +101 -20
  21. package/engine/commands/connect.cjs +162 -9
  22. package/engine/commands/creds.cjs +16 -2
  23. package/engine/commands/doctor.cjs +20 -1
  24. package/engine/commands/document.cjs +79 -0
  25. package/engine/commands/graph.cjs +50 -10
  26. package/engine/commands/help.cjs +8 -6
  27. package/engine/commands/index.cjs +1 -0
  28. package/engine/commands/list.cjs +10 -1
  29. package/engine/commands/project.cjs +79 -16
  30. package/engine/commands/roles.cjs +10 -2
  31. package/engine/commands/telegram.cjs +23 -16
  32. package/engine/core/desktop-core-fetch.cjs +98 -0
  33. package/engine/core/desktop-core.cjs +170 -0
  34. package/engine/graph/ask-model.cjs +29 -1
  35. package/engine/graph/interview.cjs +105 -20
  36. package/engine/graph/layout.cjs +36 -34
  37. package/engine/graph/vocabulary.generated.cjs +1 -1
  38. package/engine/hephaestus/runtime.cjs +10 -2
  39. package/engine/project/controller.cjs +8 -8
  40. package/engine/project/team.cjs +99 -0
  41. package/engine/runtime-refusal.cjs +71 -0
  42. package/engine/runtimes/detect.cjs +3 -0
  43. package/engine/runtimes/resolve.cjs +1 -1
  44. package/engine/sessions/session.cjs +15 -2
  45. package/engine/telegram/connect.cjs +202 -0
  46. package/engine/ui/palette.cjs +1 -0
  47. package/engine/ui/repl.cjs +112 -1
  48. package/engine/vendor/desktop-core.manifest.json +7 -0
  49. package/engine/workforce/capture.cjs +51 -1
  50. package/package.json +3 -2
@@ -53,62 +53,64 @@ function computeDepths(graph) {
53
53
  return depth;
54
54
  }
55
55
 
56
- /** 한 (band)에 넣을 컬럼 수 — 데스크탑 columnsPerBand와 같은 식. */
57
- function columnsPerBand(totalCols) {
58
- if (totalCols <= 4) return totalCols;
59
- return Math.max(4, Math.ceil(Math.sqrt(totalCols * 2)));
56
+ /** 한 줄기(strip)에 넣을 **행** 수 — 데스크탑 rowsPerStrip과 같은 식. */
57
+ function rowsPerStrip(totalRows) {
58
+ if (totalRows <= 5) return totalRows;
59
+ return Math.max(5, Math.ceil(Math.sqrt(totalRows * 2)));
60
60
  }
61
61
 
62
+ /** @deprecated 가로 배치 시절 이름. */
63
+ const columnsPerBand = rowsPerStrip;
64
+
62
65
  /**
63
- * 그래프를 결정적 사행(蛇行) 배치로 재배치한 새 노드 배열.
64
- * 사슬은 띠로 접어 좌→우 / 우→좌로 번갈아 흐르고(뱀 모양), 같은 컬럼은 세로 분산.
65
- * (일직선은 14단계에서 4,000px가 되어 아무도 읽었다 — 실측 항목 3.)
66
+ * 그래프를 결정적 **세로 사행(蛇行)** 배치로 재배치한 새 노드 배열(데스크탑과 같은 규칙).
67
+ * 위→아래로 흐르다 줄기가 차면 오른쪽으로 접고, 다음 줄기는 아래→위로 올라간다.
68
+ * (오너 결정 2026-08-06: 사람이 순서를 읽는 방향이 위→아래다.)
66
69
  */
67
70
  function layoutGraph(graph) {
68
71
  const depth = computeDepths(graph);
69
- const byCol = new Map();
72
+ const byRow = new Map();
70
73
  for (const n of graph.nodes) {
71
74
  const d = depth.get(n.id) || 0;
72
- if (!byCol.has(d)) byCol.set(d, []);
73
- byCol.get(d).push(n);
75
+ if (!byRow.has(d)) byRow.set(d, []);
76
+ byRow.get(d).push(n);
74
77
  }
75
- const cols = [...byCol.keys()].sort((a, b) => a - b);
76
- const colOrder = new Map(cols.map((c, i) => [c, i]));
77
- const totalCols = cols.length;
78
- const perBand = columnsPerBand(totalCols);
78
+ const rows = [...byRow.keys()].sort((a, b) => a - b);
79
+ const rowOrder = new Map(rows.map((r, i) => [r, i]));
80
+ const totalRows = rows.length;
81
+ const perStrip = rowsPerStrip(totalRows);
79
82
 
80
- const bandCount = Math.ceil(totalCols / perBand);
81
- const bandHeight = [];
82
- for (let b = 0; b < bandCount; b += 1) {
83
- let maxRows = 1;
84
- for (const [c, i] of colOrder) {
85
- if (Math.floor(i / perBand) === b) maxRows = Math.max(maxRows, byCol.get(c).length);
83
+ const stripCount = Math.ceil(totalRows / perStrip);
84
+ const stripWidth = [];
85
+ for (let b = 0; b < stripCount; b += 1) {
86
+ let maxCols = 1;
87
+ for (const [r, i] of rowOrder) {
88
+ if (Math.floor(i / perStrip) === b) maxCols = Math.max(maxCols, byRow.get(r).length);
86
89
  }
87
- bandHeight.push(maxRows * ROW_H + ROW_H);
90
+ stripWidth.push(maxCols * COL_W + COL_W);
88
91
  }
89
- const bandTop = [];
92
+ const stripLeft = [];
90
93
  let acc = 0;
91
- for (let b = 0; b < bandCount; b += 1) { bandTop.push(acc); acc += bandHeight[b]; }
94
+ for (let b = 0; b < stripCount; b += 1) { stripLeft.push(acc); acc += stripWidth[b]; }
92
95
 
93
96
  const out = [];
94
- for (const [col, nodes] of byCol) {
95
- const i = colOrder.get(col) || 0;
96
- const band = Math.floor(i / perBand);
97
- let c = i % perBand;
98
- if (band % 2 === 1) c = perBand - 1 - c;
97
+ for (const [row, nodes] of byRow) {
98
+ const i = rowOrder.get(row) || 0;
99
+ const strip = Math.floor(i / perStrip);
100
+ const r = i % perStrip;
101
+ // ★모든 줄기는 위→아래(사행 뒤집기 폐기 데스크탑 graph-layout.ts와 동일, 2026-08-06).
99
102
  const count = nodes.length;
100
- nodes.forEach((n, row) => {
101
- const offset = (row - (count - 1) / 2) * ROW_H;
103
+ nodes.forEach((n, col) => {
104
+ const offset = (col - (count - 1) / 2) * COL_W;
102
105
  out.push({
103
106
  ...n,
104
107
  position: {
105
- x: NODE_ORIGIN_X + c * COL_W,
106
- y: NODE_ORIGIN_Y + bandTop[band] + (bandHeight[band] - ROW_H) / 2 + offset,
108
+ x: NODE_ORIGIN_X + stripLeft[strip] + (stripWidth[strip] - COL_W) / 2 + offset,
109
+ y: NODE_ORIGIN_Y + r * ROW_H,
107
110
  },
108
111
  });
109
112
  });
110
113
  }
111
- // 원래 순서 보존(렌더러 key 안정).
112
114
  const orderIndex = new Map(graph.nodes.map((n, i) => [n.id, i]));
113
115
  out.sort((a, b) => (orderIndex.get(a.id) || 0) - (orderIndex.get(b.id) || 0));
114
116
  return out;
@@ -134,4 +136,4 @@ function needsLayout(graph) {
134
136
  return false;
135
137
  }
136
138
 
137
- module.exports = { layoutGraph, needsLayout, columnsPerBand, COL_W, ROW_H, NODE_W, NODE_H };
139
+ module.exports = { layoutGraph, needsLayout, rowsPerStrip, columnsPerBand, COL_W, ROW_H, NODE_W, NODE_H };
@@ -7,7 +7,7 @@
7
7
  "use strict";
8
8
 
9
9
  const GRAPH_WIRE = "graph/1";
10
- const GRAPH_ERROR_CODES = ["APPROVAL_REJECTED","APPROVAL_REQUIRED","APPROVAL_TIMED_OUT","ARCHITECT_NO_CHANGE","ARCHITECT_NO_REQUEST","ARCHITECT_OUTPUT_MALFORMED","ARCHITECT_OUTPUT_TOO_LARGE","ARCHITECT_OUTPUT_UNREADABLE","ARCHITECT_UNAVAILABLE","AUTOMATION_NOT_CONNECTED","BUDGET_EXHAUSTED","CODE_DEPENDENCY_MISSING","CODE_NODE_EMPTY","CODE_STEP_FAILED","CREATE_INPUT_INVALID","EDGE_CONDITION_UNRESOLVED","EVAL_INCOMPLETE","EVAL_STUCK","EVAL_UNAVAILABLE","INTERVIEW_MODEL_UNAVAILABLE","INTERVIEW_OUTPUT_UNREADABLE","INTERVIEW_REPEATED_QUESTIONS","INTERVIEW_SELF_CORRECTION_EXHAUSTED","INTERVIEW_STATE_INVALID","LOOP_BOUND_INVALID","LOOP_BOUND_UNDECLARED","LOOP_LIMIT_REACHED","LOOP_WITHOUT_EXIT","MUTATION_UNVERIFIED","NODE_FAILED","NODE_INPUT_MISSING","NODE_NEVER_REACHED","NODE_NO_RESULT","NODE_TIMEOUT","NODE_TYPE_UNSUPPORTED","NO_MATCHING_EDGE","OUTPUT_NODE_EMPTY","PATCH_CODE_EMPTY","PATCH_EDGE_CONFLICT","PATCH_EDGE_DANGLING","PATCH_EDGE_HANDLE_MISSING","PATCH_EDGE_MISSING","PATCH_EMPTY","PATCH_LOOP_BOUND_MISSING","PATCH_NODE_CONFLICT","PATCH_NODE_MISSING","PATCH_NO_GRAPH","PATCH_OP_UNKNOWN","REDUCER_MERGE_CONFLICT","REDUCER_WRITE_CONFLICT","RESUME_CONFLICT","RUN_REQUEST_DISABLED","RUN_REQUEST_INPUT_REQUIRED","RUN_REQUEST_NOT_FOUND","RUN_REQUEST_QUEUE_UNAVAILABLE","RUN_REQUEST_REF_AMBIGUOUS","RUN_REQUEST_REF_MISSING","SUBGRAPH_DEPTH_EXCEEDED","SUBGRAPH_FAILED","SUBGRAPH_NOT_FOUND","SUBGRAPH_NO_RESULT","SUBGRAPH_SELF_CALL","SWAP_CAPABILITY_MISMATCH","SWAP_HUB_RELEASE_UNPINNED","SWAP_NODE_NOT_FOUND","SWAP_NOT_AGENT_NODE","SWAP_NO_MATCH","SWAP_UNKNOWN_PROVIDER","TOOL_BROKER_CALL_UNREADABLE","TOOL_BROKER_MUTATION_IN_SIMULATION","TOOL_BROKER_PLAN_UNREADABLE","TOOL_BROKER_TOOL_NOT_DECLARED","TOOL_NODE_UNATTACHED","TOOL_NODE_UNCONFIGURED","TRANSFORM_MODE_UNKNOWN","TRANSFORM_NODE_UNCONFIGURED"];
10
+ const GRAPH_ERROR_CODES = ["APPROVAL_REJECTED","APPROVAL_REQUIRED","APPROVAL_TIMED_OUT","ARCHITECT_NO_CHANGE","ARCHITECT_NO_REQUEST","ARCHITECT_OUTPUT_MALFORMED","ARCHITECT_OUTPUT_TOO_LARGE","ARCHITECT_OUTPUT_UNREADABLE","ARCHITECT_UNAVAILABLE","AUTOMATION_NOT_CONNECTED","BUDGET_EXHAUSTED","CODE_DEPENDENCY_MISSING","CODE_NODE_EMPTY","CODE_PRODUCED_NOTHING","CODE_STEP_FAILED","CREATE_INPUT_INVALID","EDGE_CONDITION_UNRESOLVED","EVAL_INCOMPLETE","EVAL_STUCK","EVAL_UNAVAILABLE","INTERVIEW_MODEL_UNAVAILABLE","INTERVIEW_OUTPUT_UNREADABLE","INTERVIEW_REPEATED_QUESTIONS","INTERVIEW_SELF_CORRECTION_EXHAUSTED","INTERVIEW_STATE_INVALID","LOOP_BOUND_INVALID","LOOP_BOUND_UNDECLARED","LOOP_LIMIT_REACHED","LOOP_WITHOUT_EXIT","MUTATION_UNVERIFIED","NODE_FAILED","NODE_INPUT_MISSING","NODE_NEVER_REACHED","NODE_NO_RESULT","NODE_TIMEOUT","NODE_TYPE_UNSUPPORTED","NO_MATCHING_EDGE","OUTPUT_NODE_EMPTY","PATCH_CODE_EMPTY","PATCH_EDGE_CONFLICT","PATCH_EDGE_DANGLING","PATCH_EDGE_HANDLE_MISSING","PATCH_EDGE_MISSING","PATCH_EMPTY","PATCH_LOOP_BOUND_MISSING","PATCH_NODE_CONFLICT","PATCH_NODE_MISSING","PATCH_NO_GRAPH","PATCH_OP_UNKNOWN","REDUCER_MERGE_CONFLICT","REDUCER_WRITE_CONFLICT","RESUME_CONFLICT","RUN_REQUEST_DISABLED","RUN_REQUEST_INPUT_REQUIRED","RUN_REQUEST_NOT_FOUND","RUN_REQUEST_QUEUE_UNAVAILABLE","RUN_REQUEST_REF_AMBIGUOUS","RUN_REQUEST_REF_MISSING","SUBGRAPH_DEPTH_EXCEEDED","SUBGRAPH_FAILED","SUBGRAPH_NOT_FOUND","SUBGRAPH_NO_RESULT","SUBGRAPH_SELF_CALL","SWAP_CAPABILITY_MISMATCH","SWAP_HUB_RELEASE_UNPINNED","SWAP_NODE_NOT_FOUND","SWAP_NOT_AGENT_NODE","SWAP_NO_MATCH","SWAP_UNKNOWN_PROVIDER","TOOL_BROKER_CALL_UNREADABLE","TOOL_BROKER_MUTATION_IN_SIMULATION","TOOL_BROKER_PLAN_UNREADABLE","TOOL_BROKER_TOOL_NOT_DECLARED","TOOL_NODE_UNATTACHED","TOOL_NODE_UNCONFIGURED","TRANSFORM_MODE_UNKNOWN","TRANSFORM_NODE_UNCONFIGURED"];
11
11
  const GRAPH_JOURNAL_KINDS = ["blob_externalized","node_failed","node_intent","node_reserved","node_retry","node_routed","node_settled","resumed","run_completed","run_created","run_failed","run_validated","suspended"];
12
12
  const GRAPH_NODE_KINDS = ["action","agent","code","condition","eval","output","subgraph","tool","transform","trigger"];
13
13
  const GRAPH_BLOCK_UI = {"trigger":{"section":"none","placeable":false,"placeReason":"그래프마다 하나뿐이고 처음 만들 때 함께 지어진다"},"agent":{"section":"inventory","placeable":true},"eval":{"section":"flow","placeable":true},"condition":{"section":"flow","placeable":true},"transform":{"section":"flow","placeable":true},"code":{"section":"flow","placeable":true},"tool":{"section":"inventory","placeable":true},"action":{"section":"actions","placeable":true},"output":{"section":"flow","placeable":true},"loop":{"section":"none","placeable":false,"placeReason":"노드가 아니라 되돌아가는 연결의 성질이다 — 엣지를 이어서 만든다"},"subgraph":{"section":"flow","placeable":true}};
@@ -36,7 +36,7 @@ const { CONTEXT_MAP_MIN_CORE_VERSION } = coreHarness;
36
36
  // ── 명령 usage 문자열 (v1 TOP_LEVEL_COMMAND_USAGE에서 hephaestus 클러스터만 발췌) ──
37
37
  const USAGE = Object.freeze({
38
38
  build: 'usage: agentlas build "<request>"',
39
- browser: "usage: agentlas browser <url-or-query|subcommand>",
39
+ browser: "usage: agentlas browser <url-or-query> | status | sites | add <site> | login <site> | mark <site> <valid|expired|none> | go <url> | rm <site>",
40
40
  call: 'usage: agentlas call "<agent-slugs>" "<context>"',
41
41
  connect: "usage: agentlas connect [status|telegram|help]",
42
42
  hep: "usage: agentlas hep <subcommand> [args]",
@@ -399,7 +399,15 @@ function create(ctx, deps = {}) {
399
399
  if (raw) return runHephaestusInteractive(cleanArgs, { ...opts, human: false });
400
400
  if (!hephaestusBin()) return runHephaestusInteractive(cleanArgs, { ...opts, human: false });
401
401
  const ui = opts.ui || newUi();
402
- const result = await captureHephaestus(cleanArgs, opts);
402
+ // route는 Hub 왕복이라 실측 ~13s 걸린다 — research처럼 진행 표시를 준다.
403
+ // (clig.dev: 몇 초 넘는 작업엔 진행 인디케이터. 예전엔 그 시간 내내 침묵했다.)
404
+ if (typeof ui.startSpinner === "function") ui.startSpinner(ui.lang === "ko" ? "라우팅 미리보기 계산 중…" : "Previewing routing…");
405
+ let result;
406
+ try {
407
+ result = await captureHephaestus(cleanArgs, opts);
408
+ } finally {
409
+ if (typeof ui.stopSpinner === "function") ui.stopSpinner();
410
+ }
403
411
  let json = null;
404
412
  try {
405
413
  const start = result.stdout.indexOf("{");
@@ -31,10 +31,10 @@ function parseAgentPool(raw) {
31
31
  try {
32
32
  parsed = JSON.parse(raw || "[]");
33
33
  } catch {
34
- throw new Error("This project's agent team cannot be read. Open the project in Agentlas Desktop and save its team again.");
34
+ throw Object.assign(new Error("This project's agent team cannot be read. Open the project in Agentlas Desktop and save its team again."), { code: "project_team_unreadable", honestStop: true });
35
35
  }
36
36
  if (!Array.isArray(parsed)) {
37
- throw new Error("This project's agent team cannot be read. Open the project in Agentlas Desktop and save its team again.");
37
+ throw Object.assign(new Error("This project's agent team cannot be read. Open the project in Agentlas Desktop and save its team again."), { code: "project_team_unreadable", honestStop: true });
38
38
  }
39
39
  return parsed.filter((member) => member && typeof member === "object"
40
40
  && typeof member.agentId === "string" && member.agentId.trim()
@@ -45,7 +45,7 @@ function parseAgentPool(raw) {
45
45
  function resolveProjectForCwd(db, cwd) {
46
46
  const columns = projectColumns(db);
47
47
  if (!columns.has("folder_path") || !columns.has("agent_pool_json")) {
48
- throw new Error("This Agentlas data store does not support project teams yet. Open the latest Agentlas Desktop once, then retry.");
48
+ throw Object.assign(new Error("This Agentlas data store does not support project teams yet. Open the latest Agentlas Desktop once, then retry."), { code: "project_teams_unsupported", honestStop: true });
49
49
  }
50
50
  const rows = db.prepare(
51
51
  `SELECT id, name, system_prompt, agent_pool_json, source_type, source_ref, folder_path
@@ -57,12 +57,12 @@ function resolveProjectForCwd(db, cwd) {
57
57
  .filter(({ root }) => pathContains(root, target))
58
58
  .sort((a, b) => b.root.length - a.root.length);
59
59
  if (!matches.length) {
60
- throw new Error("This folder is not connected to an Agentlas project. Connect it in Desktop Work, or pass an exact agent for an advanced direct invocation.");
60
+ throw Object.assign(new Error("This folder is not connected to an Agentlas project. Connect it in Desktop Work, or pass an exact agent for an advanced direct invocation."), { code: "project_not_connected", honestStop: true });
61
61
  }
62
62
  const bestLength = matches[0].root.length;
63
63
  const best = matches.filter((entry) => entry.root.length === bestLength);
64
64
  if (best.length !== 1) {
65
- throw new Error("More than one Agentlas project is connected to this folder. Keep one source connection, then retry.");
65
+ throw Object.assign(new Error("More than one Agentlas project is connected to this folder. Keep one source connection, then retry."), { code: "project_ambiguous", honestStop: true });
66
66
  }
67
67
  return { ...best[0].row, rootPath: best[0].root, agentPool: parseAgentPool(best[0].row.agent_pool_json) };
68
68
  }
@@ -70,15 +70,15 @@ function resolveProjectForCwd(db, cwd) {
70
70
  function resolveProjectController(db, cwd) {
71
71
  const project = resolveProjectForCwd(db, cwd);
72
72
  if (!project.agentPool.length) {
73
- throw new Error("This project has no agent team. Drag agents into the project in Desktop Work, then retry.");
73
+ throw Object.assign(new Error("This project has no agent team. Drag agents into the project in Desktop Work, then retry."), { code: "project_team_empty", honestStop: true });
74
74
  }
75
75
  const controllerRef = project.agentPool[0];
76
76
  if (controllerRef.source !== "local") {
77
- throw new Error(`The project controller “${controllerRef.nameSnapshot}” is not installed locally for Terminal execution. Install that exact release locally or reorder the project team.`);
77
+ throw Object.assign(new Error(`The project controller “${controllerRef.nameSnapshot}” is not installed locally for Terminal execution. Install that exact release locally or reorder the project team.`), { code: "controller_not_installed", honestStop: true });
78
78
  }
79
79
  const controller = listRoutableAgents(db).find((agent) => agent.id === controllerRef.agentId) || null;
80
80
  if (!controller) {
81
- throw new Error(`The project controller “${controllerRef.nameSnapshot}” is unavailable. Restore that agent or explicitly choose a new first agent in Desktop Work.`);
81
+ throw Object.assign(new Error(`The project controller “${controllerRef.nameSnapshot}” is unavailable. Restore that agent or explicitly choose a new first agent in Desktop Work.`), { code: "controller_unavailable", honestStop: true });
82
82
  }
83
83
  return { project, controller };
84
84
  }
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ /*
3
+ * project/team — 터미널에서 프로젝트를 만들고 순서 팀을 편성한다 (독립).
4
+ *
5
+ * 배경(2026-08-06, 오너 원칙): 데스크탑/플러그인은 산출물·설정을 **공유**할 뿐,
6
+ * 설치되어 있거나 거기서 작업이 선행되어야 하는 것은 아니다. 그런데 projects
7
+ * 행을 쓰는 곳이 터미널 엔진에 0곳이라, `run "<task>"`가 "Desktop Work에서
8
+ * 연결하세요"로 막혔다 — 데스크탑을 강제하는 격차. 스키마(projects)는 터미널이
9
+ * 부트스트랩하는 공유 스키마이므로, 여기서 직접 쓴다.
10
+ *
11
+ * 계약(데스크탑 tasks.createProject와 동형):
12
+ * - 순서 팀의 index 0(컨트롤러)은 로컬 설치 에이전트여야 한다. 로컬이 아니면
13
+ * 폰이 프로젝트를 시작 불가로 만들 수 있다는 모바일 계약과 같은 이유 —
14
+ * 컨트롤러는 이 머신에서 반드시 실행 가능해야 한다.
15
+ * - 풀 멤버 형태는 controller.cjs parseAgentPool과 정확히 일치:
16
+ * { agentId, source, releaseId, nameSnapshot }.
17
+ * - 폴더당 프로젝트는 하나로 수렴한다. 같은 folder_path가 이미 있으면 그 행을
18
+ * 갱신하고, 없으면 만든다(데스크탑이 만든 프로젝트도 그대로 이어받는다).
19
+ */
20
+ const crypto = require("node:crypto");
21
+ const path = require("node:path");
22
+ const fs = require("node:fs");
23
+ const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
24
+ const { findAgent } = require("../agents/registry.cjs");
25
+
26
+ function canonical(p) {
27
+ const resolved = path.resolve(String(p || ""));
28
+ try { return fs.realpathSync.native(resolved); } catch { return resolved; }
29
+ }
30
+
31
+ /** slug/이름 목록 → 검증된 순서 풀. 못 찾은 이름은 정직하게 실패. */
32
+ function resolveTeam(db, tokens) {
33
+ const pool = [];
34
+ for (const token of tokens) {
35
+ const agent = findAgent(db, token);
36
+ if (!agent) {
37
+ throw Object.assign(new Error(`agent not found: ${token}`), { code: "team_agent_not_found", honestStop: true, token });
38
+ }
39
+ // 설치 에이전트는 로컬이다(installed_agents에 있음 = 이 머신에서 실행 가능).
40
+ pool.push({
41
+ agentId: agent.id,
42
+ source: "local",
43
+ releaseId: null,
44
+ nameSnapshot: agent.name || agent.slug || agent.id,
45
+ });
46
+ }
47
+ return pool;
48
+ }
49
+
50
+ /**
51
+ * connectProjectTeam(db, folder, tokens, opts)
52
+ * folder: 프로젝트 루트(cwd)
53
+ * tokens: 순서 팀 slug/이름 배열 (index 0 = 컨트롤러)
54
+ * opts.name / opts.systemPrompt (선택)
55
+ * → { id, name, folderPath, team: [{agentId, nameSnapshot}], created }
56
+ */
57
+ function connectProjectTeam(db, folder, tokens, opts = {}) {
58
+ if (!Array.isArray(tokens) || tokens.length === 0) {
59
+ throw Object.assign(new Error("a project team needs at least one agent (the first is the controller)"), { code: "team_empty", honestStop: true });
60
+ }
61
+ const columns = new Set(db.prepare("PRAGMA table_info(projects)").all().map((r) => r.name));
62
+ if (!columns.has("folder_path") || !columns.has("agent_pool_json")) {
63
+ throw Object.assign(new Error("This Agentlas data store does not support project teams yet."), { code: "project_teams_unsupported", honestStop: true });
64
+ }
65
+ const team = resolveTeam(db, tokens);
66
+ const root = canonical(folder);
67
+ const now = new Date().toISOString();
68
+ const poolJson = JSON.stringify(team);
69
+ const controllerId = team[0].agentId;
70
+ // default_agent_id 는 데스크탑 마이그레이션이 추가한 컬럼이라 터미널이 신선
71
+ // 부트스트랩한 스키마엔 없을 수 있다 — 있을 때만 쓴다. 컨트롤러 정본은 늘
72
+ // agent_pool_json[0]다(default_agent_id는 데스크탑 호환용 보조 필드).
73
+ const hasDefaultCol = columns.has("default_agent_id");
74
+
75
+ return runWriteTransaction(db, () => {
76
+ // 같은 폴더의 기존 프로젝트를 찾는다(데스크탑이 만든 것 포함, 정확 경로만).
77
+ const existing = db.prepare(
78
+ "SELECT id, name FROM projects WHERE folder_path IS NOT NULL AND folder_path = ?",
79
+ ).get(root) || null;
80
+ if (existing) {
81
+ const sets = ["agent_pool_json=?", "updated_at=?"];
82
+ const vals = [poolJson, now];
83
+ if (hasDefaultCol) { sets.push("default_agent_id=?"); vals.push(controllerId); }
84
+ if (opts.name) { sets.push("name=?"); vals.push(opts.name); }
85
+ if (opts.systemPrompt !== undefined) { sets.push("system_prompt=?"); vals.push(opts.systemPrompt); }
86
+ db.prepare(`UPDATE projects SET ${sets.join(", ")} WHERE id=?`).run(...vals, existing.id);
87
+ return { id: existing.id, name: opts.name || existing.name, folderPath: root, team, created: false };
88
+ }
89
+ const id = `project:local:${crypto.randomUUID()}`;
90
+ const name = opts.name || path.basename(root) || "Project";
91
+ const cols = ["id", "name", "description", "system_prompt", "agent_pool_json", "source_type", "source_ref", "created_at", "updated_at", "folder_path"];
92
+ const vals = [id, name, null, opts.systemPrompt ?? null, poolJson, "local", null, now, now, root];
93
+ if (hasDefaultCol) { cols.splice(3, 0, "default_agent_id"); vals.splice(3, 0, controllerId); }
94
+ db.prepare(`INSERT INTO projects (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(",")})`).run(...vals);
95
+ return { id, name, folderPath: root, team, created: true };
96
+ });
97
+ }
98
+
99
+ module.exports = { connectProjectTeam, resolveTeam };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 런타임 거절 고지문 판별 — **표식이 없는 런타임을 위한 최후 수단, 이 저장소에서 이 파일 하나뿐.**
3
+ *
4
+ * 원칙(2026-08-06 실측 사고에서): 실패 판정은 런타임의 기계 표식(res.error / errorKind)으로
5
+ * 한다. 텍스트 모양을 보는 것은 표식을 아예 안 주는 케이스(실측: codex 한도 — 거절문이
6
+ * agent_message로 오고 turn.completed, 표식 0)에서만 허용되고, 그 판별 로직은 여기 한 곳에만
7
+ * 산다. 흩어지면 조율 불가능한 키워드 그물이 여러 벌 생긴다.
8
+ *
9
+ * 오탐 방어(이게 이 모듈의 존재 이유다):
10
+ * - 전체 출력이 짧을 때만(고지문은 한두 문장이다 — 긴 답 속의 "429" 언급은 산출물).
11
+ * - 구조(JSON·다문단)가 보이면 산출물로 간주.
12
+ * - 앵커된 고지 문구만("You've hit", "usage limit" …) — 낱말 하나로 판정하지 않는다.
13
+ * - 판별 결과는 항상 heuristic 출처로 표기해야 한다 — 화면은 단정 대신 완곡하게 말하고,
14
+ * 원문은 저널에 보존한다.
15
+ *
16
+ * 쌍둥이: agentlas_desktop/electron/runtime/runtime-refusal.ts (수동 동기 — 런타임 계층은
17
+ * 미러 코드가 아니라 패리티 게이트가 없다. 규칙을 바꾸면 양쪽을 같이 바꿀 것.)
18
+ */
19
+ "use strict";
20
+
21
+ const MAX_NOTICE_LENGTH = 400;
22
+
23
+ /**
24
+ * 앵커된 고지 문구 — "거절을 사람에게 알리는 문장"의 형태.
25
+ * 낱말(limit, quota)이 아니라 구절이다. 여기 추가할 때는 실측 원문을 근거로.
26
+ */
27
+ const NOTICE_PATTERNS = [
28
+ /\byou'?ve hit\b/i, // "You've hit your weekly/usage limit" (claude·codex 실측)
29
+ /\busage limit\b/i,
30
+ /\brate.?limit(ed)?\b/i,
31
+ /\bquota (exceeded|reached)\b/i,
32
+ /\bresets? (at|on)\b/i, // "resets Aug 8 at 6pm"
33
+ /\btry again (at|later)\b/i,
34
+ /\bupgrade to\b/i, // "Upgrade to Pro" (codex 실측)
35
+ /\bpurchase more credits\b/i,
36
+ /\bout of credits\b/i,
37
+ /\bplease (log ?in|sign ?in)\b/i,
38
+ /\bnot logged in\b/i,
39
+ /\b(login|session|token) (expired|invalid)\b/i,
40
+ /\bsubscription (required|expired)\b/i,
41
+ // ── 모델이 쓴 기계 자기보고 (2026-08-08 ollama 실측) ──
42
+ // "The system encountered a timeout error while processing a request. No further
43
+ // function calls are required. Please retry the operation..." — 도구 왕복이 무너진 뒤
44
+ // 로컬 모델이 뱉은 문장이 최종 답으로 저장됐다. 사람에게 하는 답이 아니라 프로토콜 잡담.
45
+ /\bno further (function|tool) calls?\b/i,
46
+ /\bsystem encountered (a|an) [a-z]+ error\b/i,
47
+ /\bretry the (operation|request)\b/i,
48
+ ];
49
+
50
+ /** 종류 추정 — 표식이 없으니 문구에서. 조율은 여기 한 곳. */
51
+ function kindOf(text) {
52
+ if (/\btimed? ?out\b|\btimeout\b/i.test(text)) return "timeout";
53
+ if (/\b(log ?in|sign ?in|logged in|expired|unauthorized|subscription)\b/i.test(text)) return "auth";
54
+ if (/\b(limit|quota|credits?|resets?|try again)\b/i.test(text)) return "quota";
55
+ return "refused";
56
+ }
57
+
58
+ /**
59
+ * 텍스트가 산출물이 아니라 거절 고지문인가.
60
+ * @returns {{ kind: "quota"|"auth"|"refused"|"timeout", message: string } | null}
61
+ */
62
+ function detectRuntimeRefusal(text) {
63
+ const t = String(text || "").trim();
64
+ if (!t || t.length > MAX_NOTICE_LENGTH) return null;
65
+ // 구조가 보이면 산출물이다 — JSON, 코드펜스, 다문단.
66
+ if (t.includes("{") || t.includes("```") || /\n\s*\n/.test(t)) return null;
67
+ if (!NOTICE_PATTERNS.some((re) => re.test(t))) return null;
68
+ return { kind: kindOf(t), message: t };
69
+ }
70
+
71
+ module.exports = { detectRuntimeRefusal, MAX_NOTICE_LENGTH };
@@ -12,6 +12,9 @@ const RUNTIME_BIN = {
12
12
  "claude-code": "claude",
13
13
  codex: "codex",
14
14
  gemini: "gemini",
15
+ // Antigravity CLI — gemini 후속. 공식 gemini CLI가 계정 티어로 죽어도(IneligibleTierError,
16
+ // 실측 2026-08-06) 이쪽은 산다. 데스크탑 gemini 러너의 agy 경로와 같은 실물.
17
+ agy: "agy",
15
18
  kimi: "kimi",
16
19
  grok: "grok",
17
20
  cursor: "cursor-agent",
@@ -10,7 +10,7 @@ const { RUNTIME_BIN, whichSync, listAvailableCliRuntimes, activeRuntimeRow } = r
10
10
  // Session이 실제 드라이버를 갖춘 런타임만 실행 대상으로 삼는다.
11
11
  // CLI는 native-host, Ollama는 로컬 API loop를 쓴다. 다른 드라이버가 포팅되면
12
12
  // 해당 집합에 추가한다(조용한 오폭 방지).
13
- const CLI_EXECUTABLE_KINDS = new Set(["claude-code", "codex", "gemini"]);
13
+ const CLI_EXECUTABLE_KINDS = new Set(["claude-code", "codex", "gemini", "agy"]);
14
14
  const API_EXECUTABLE_KINDS = new Set(["ollama"]);
15
15
  const EXECUTABLE_KINDS = new Set([
16
16
  ...CLI_EXECUTABLE_KINDS,
@@ -282,7 +282,13 @@ class Session extends EventEmitter {
282
282
  if (this._timeoutConfig) req.timeoutConfig = this._timeoutConfig;
283
283
  try {
284
284
  res = await nativeHost.runNativeTurn(req);
285
- if (res && res.error && !res.text && !res.finalText) {
285
+ /*
286
+ * ★복구 게이트는 **표식**으로 연다 — `!res.text`를 조건에 끼우면 안 된다.
287
+ * 실측(2026-08-06): claude 한도 거절은 error와 함께 거절문이 text에도 실려 온다.
288
+ * 그래서 `!res.text`가 영원히 거짓이 되어, 예비 런타임이 등록돼 있는데도
289
+ * 복구가 한 번도 발화하지 않았다. 실패는 error가 말하고, text는 표시용이다.
290
+ */
291
+ if (res && res.error) {
286
292
  const nextRuntime = this._nextRecoveryRuntime();
287
293
  if (nextRuntime) {
288
294
  const privateEvidence = [...this._privateRecoveryEvidence, String(res.error)]
@@ -420,7 +426,14 @@ class Session extends EventEmitter {
420
426
  }
421
427
  if (res && res.error) {
422
428
  this.status = "failed";
423
- this.lastError = null;
429
+ /*
430
+ * ★사유를 버리지 않는다. 예전에는 여기서 null로 지워서, daemon이 run_history에
431
+ * "runtime turn failed"라는 고정 문자열만 남기고 run/build는 아무 말 없이 exit 1이었다
432
+ * — 사람이 알아야 고칠 수 있는 것(한도 리셋 시각, 로그인 필요)을 아는 쪽이 지웠다.
433
+ * 렌더러 완곡 문구는 Ui 계층이 따로 지킨다(recovery-presentation-contract) —
434
+ * 기록과 화면은 다른 문제다.
435
+ */
436
+ this.lastError = String(res.error).slice(0, 2000);
424
437
  this._record({ type: "turn-end", at: Date.now(), ok: false, recoveryRequired: true });
425
438
  } else {
426
439
  this.status = "done";
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ /*
3
+ * telegram/connect — 터미널 독립 Telegram 연결 (2026-08-06).
4
+ *
5
+ * 배경(오너): 데스크탑에서 쉽게 되는데 터미널이 왜 안 되냐. 확인해 보니
6
+ * 데스크탑 electron/telegram/connect.ts(2193줄)의 대부분은 Electron이 BotFather
7
+ * **브라우저 창을 자동 조종**해 봇을 자동 생성하는 편의 로직이다. 실제 연결
8
+ * 코어는 순수 HTTPS(api.telegram.org)이고 Electron이 필요 없다:
9
+ * verifyBotToken(getMe) → 바인딩 저장 → getUpdates 폴링으로 방 귀속 → sendMessage.
10
+ * 그 코어만 이식한다. 봇 토큰은 사용자가 @BotFather로 만들어 stdin으로 준다
11
+ * (비밀은 argv 금지 — 히스토리·ps 노출). 저장은 0600 파일(standalone Node에서
12
+ * keytar는 macOS 키체인에 막혀 멈추므로 — creds.cjs 계약과 동일).
13
+ *
14
+ * 데스크탑과 같은 telegram_bindings 테이블·같은 페어링 규칙을 쓴다(공유 스키마).
15
+ */
16
+ const crypto = require("node:crypto");
17
+ const fs = require("node:fs");
18
+ const path = require("node:path");
19
+ const { userDataDir } = require("../core/paths.cjs");
20
+ const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
21
+
22
+ const TELEGRAM_REQUEST_TIMEOUT_MS = 20_000;
23
+
24
+ /** 순수 HTTPS. fetch 주입 가능(테스트). */
25
+ async function telegramApi(token, method, payload, { fetchImpl } = {}) {
26
+ const doFetch = fetchImpl || globalThis.fetch;
27
+ if (typeof doFetch !== "function") throw new Error("this runtime has no fetch");
28
+ const longPoll = method === "getUpdates" && typeof payload.timeout === "number" ? Math.max(0, payload.timeout) : 0;
29
+ const controller = new AbortController();
30
+ const timer = setTimeout(() => controller.abort(new Error(`Telegram ${method} timed out`)), TELEGRAM_REQUEST_TIMEOUT_MS + longPoll * 1000);
31
+ try {
32
+ const res = await doFetch(`https://api.telegram.org/bot${token}/${method}`, {
33
+ method: "POST",
34
+ headers: { "content-type": "application/json" },
35
+ body: JSON.stringify(payload),
36
+ signal: controller.signal,
37
+ });
38
+ const json = await res.json().catch(() => null);
39
+ if (!res.ok || !json || json.ok !== true) {
40
+ throw new Error((json && json.description) || `Telegram ${method} failed (${res.status})`);
41
+ }
42
+ return json.result;
43
+ } finally {
44
+ clearTimeout(timer);
45
+ }
46
+ }
47
+
48
+ async function verifyBotToken(token, opts) {
49
+ const me = await telegramApi(token, "getMe", {}, opts);
50
+ if (!me || !me.is_bot) throw new Error("that token does not belong to a Telegram bot");
51
+ return me;
52
+ }
53
+
54
+ // ── 토큰 비밀 저장 (0600 파일) ─────────────────────────────────────────────
55
+ function tokenDir() {
56
+ const dir = path.join(userDataDir(), "telegram");
57
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
58
+ return dir;
59
+ }
60
+ 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 readToken(id) {
63
+ try { return fs.readFileSync(tokenFile(id), "utf8").trim() || null; } catch { return null; }
64
+ }
65
+ function deleteToken(id) { try { fs.rmSync(tokenFile(id)); } catch { /* gone */ } }
66
+ function tokenFingerprint(token) { return crypto.createHash("sha256").update(token).digest("hex").slice(0, 24); }
67
+
68
+ // ── 바인딩 ─────────────────────────────────────────────────────────────────
69
+ function listBindings(db) {
70
+ try { return db.prepare("SELECT * FROM telegram_bindings ORDER BY rowid DESC").all(); } catch { return []; }
71
+ }
72
+ function getBinding(db, id) {
73
+ try { return db.prepare("SELECT * FROM telegram_bindings WHERE id=?").get(id) || null; } catch { return null; }
74
+ }
75
+
76
+ /** 봇 토큰으로 바인딩을 만든다(방 미페어링). 반환: {id, botUsername}. */
77
+ async function startConnection(db, targetKind, targetId, token, opts) {
78
+ const me = await verifyBotToken(token, opts);
79
+ const id = crypto.randomUUID();
80
+ const now = new Date().toISOString();
81
+ runWriteTransaction(db, () => {
82
+ db.prepare(
83
+ "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,?,?,?)",
85
+ ).run(id, targetKind, targetId, me.id, me.username || null, me.first_name || null, tokenFingerprint(token), now, now);
86
+ });
87
+ saveToken(id, token);
88
+ // 남은 웹훅이 있으면 getUpdates가 막히므로 제거(있어도 무해).
89
+ await telegramApi(token, "deleteWebhook", { drop_pending_updates: false }, opts).catch(() => null);
90
+ return { id, botUsername: me.username || null };
91
+ }
92
+
93
+ /**
94
+ * getUpdates 폴링으로 첫 private 메시지의 chat을 이 바인딩에 귀속한다.
95
+ * 보안: 데스크탑과 같은 규칙 — 미페어링·enabled·waiting_for_chat 인 신선 바인딩
96
+ * (30분 이내)에만, private 채팅만. 반환: 페어링된 바인딩 또는 null(시간초과).
97
+ */
98
+ async function pairByPolling(db, id, { timeoutMs = 120_000, opts, onWait } = {}) {
99
+ const token = readToken(id);
100
+ if (!token) throw new Error("no stored token for this binding");
101
+ const deadline = Date.now() + timeoutMs;
102
+ let offset = (getBinding(db, id) || {}).last_update_id || 0;
103
+ while (Date.now() < deadline) {
104
+ if (typeof onWait === "function") onWait();
105
+ let updates;
106
+ try {
107
+ updates = await telegramApi(token, "getUpdates", { offset: offset + 1, timeout: 25, allowed_updates: ["message"] }, opts);
108
+ } catch { updates = []; }
109
+ for (const update of updates || []) {
110
+ if (typeof update.update_id === "number") offset = Math.max(offset, update.update_id);
111
+ const message = update.message;
112
+ if (!message || !message.chat || message.chat.type !== "private") continue;
113
+ // 신선 미페어링 바인딩인지 재확인(경합 방지) 후 귀속.
114
+ const row = getBinding(db, id);
115
+ if (!row || row.telegram_chat_id || row.status !== "waiting_for_chat") continue;
116
+ const createdAt = Date.parse(row.created_at);
117
+ if (!Number.isFinite(createdAt) || Date.now() - createdAt > 30 * 60 * 1000) throw new Error("pairing window expired (30 min) — reconnect");
118
+ const now = new Date().toISOString();
119
+ 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);
123
+ });
124
+ return getBinding(db, id);
125
+ }
126
+ if (offset) {
127
+ runWriteTransaction(db, () => {
128
+ db.prepare("UPDATE telegram_bindings SET last_update_id=MAX(last_update_id,?), updated_at=? WHERE id=?").run(offset, new Date().toISOString(), id);
129
+ });
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /** 페어링된 방에 확인 메시지를 보낸다. */
136
+ async function sendTest(db, id, text, opts) {
137
+ const row = getBinding(db, id);
138
+ if (!row) throw new Error("binding not found");
139
+ if (!row.telegram_chat_id) throw new Error("this binding is not paired to a chat yet");
140
+ const token = readToken(id);
141
+ if (!token) throw new Error("no stored token for this binding");
142
+ await telegramApi(token, "sendMessage", { chat_id: row.telegram_chat_id, text }, opts);
143
+ return true;
144
+ }
145
+
146
+ function removeBinding(db, id) {
147
+ runWriteTransaction(db, () => {
148
+ db.prepare("DELETE FROM telegram_bindings WHERE id=?").run(id);
149
+ });
150
+ deleteToken(id);
151
+ }
152
+
153
+ /*
154
+ * 브라우저 조종으로 BotFather 토큰을 자동 포착 (2026-08-06).
155
+ * 데스크탑 electron/telegram/connect.ts 의 readTelegramWebState 와 같은 방식:
156
+ * 페이지 innerText 에서 봇 토큰 정규식을 읽는다. 데스크탑은 Electron
157
+ * executeJavaScript, 여기서는 CDP Runtime.evaluate — 동형. 봇 생성(/newbot)은
158
+ * 열린 Agentlas Chrome 에서 사용자가 하거나 이미 만든 봇을 열면 되고, 토큰은
159
+ * 터미널이 페이지에서 직접 읽어 복붙을 없앤다.
160
+ *
161
+ * 반환: 포착한 토큰 문자열 또는 null(시간초과). 브라우저(CDP)가 없으면
162
+ * cdp_unavailable 로 던진다 — 호출자가 수동 토큰 경로로 안내.
163
+ */
164
+ const BOTFATHER_WEB_URL = "https://web.telegram.org/k/#@BotFather";
165
+ const TOKEN_RE_SRC = "\\b\\d{8,12}:[A-Za-z0-9_-]{30,}\\b";
166
+
167
+ async function captureBotTokenViaBrowser({ timeoutMs = 180_000, onWait } = {}) {
168
+ const cdp = require("../browser/cdp.cjs");
169
+ if (!(await cdp.cdpReady())) {
170
+ const err = new Error("Agentlas browser (CDP) is not running");
171
+ err.code = "cdp_unavailable";
172
+ throw err;
173
+ }
174
+ const page = await cdp.attachPage();
175
+ try {
176
+ await page.navigate(BOTFATHER_WEB_URL, { waitMs: 2500 });
177
+ const deadline = Date.now() + timeoutMs;
178
+ while (Date.now() < deadline) {
179
+ if (typeof onWait === "function") onWait();
180
+ // 페이지 텍스트에서 마지막 토큰을 읽는다(가장 최근 발급).
181
+ let token = null;
182
+ try {
183
+ token = await page.evalExpr(
184
+ "(document.body && document.body.innerText ? document.body.innerText : '').match(/" + TOKEN_RE_SRC + "/g)?.slice(-1)[0] || null",
185
+ );
186
+ } catch { token = null; }
187
+ if (token) return token;
188
+ await new Promise((r) => setTimeout(r, 1200));
189
+ }
190
+ return null;
191
+ } finally {
192
+ page.close();
193
+ }
194
+ }
195
+
196
+ module.exports = {
197
+ telegramApi, verifyBotToken,
198
+ startConnection, pairByPolling, sendTest, removeBinding,
199
+ listBindings, getBinding,
200
+ saveToken, readToken, deleteToken, tokenFingerprint,
201
+ captureBotTokenViaBrowser, BOTFATHER_WEB_URL,
202
+ };
@@ -77,6 +77,7 @@ const SLASH_COMMANDS = [
77
77
  { command: "/creds", args: "<sub>", ko: "자격증명", en: "Credentials" },
78
78
  { command: "/env", args: "", ko: "공유 환경 키", en: "Shared env keys" },
79
79
  { command: "/multimodal", args: "", ko: "이미지·영상·음성 설정", en: "Image/video/audio providers" },
80
+ { command: "/document", args: "pdf <html|url>", ko: "문서 PDF 내보내기", en: "Export a document to PDF" },
80
81
  { command: "/roles", args: "[set <role> <runtime>]", ko: "오케스트레이터·워커 모델 역할 조회/설정", en: "Show or set orchestrator/worker model roles" },
81
82
  { command: "/telegram", args: "[sub]", ko: "텔레그램 연결", en: "Telegram bindings" },
82
83
  { command: "/oberon", args: "[sub]", ko: "AI 필름", en: "AI film" },