agentlas 1.0.28 → 1.0.35

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 (70) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +5 -2
  3. package/bin/agentlas.cjs +15 -0
  4. package/engine/agentlas-cloud-runtime.cjs +54 -4
  5. package/engine/agentlas-i18n.cjs +4 -4
  6. package/engine/agentlas-input.cjs +10 -3
  7. package/engine/agentlas-judgment.cjs +0 -0
  8. package/engine/agentlas-native-host.cjs +69 -4
  9. package/engine/agentlas-onboard.cjs +20 -0
  10. package/engine/agentlas-ui.cjs +2 -0
  11. package/engine/agentlas-workforce.cjs +69 -13
  12. package/engine/agentlas.cjs +71 -3
  13. package/engine/agents/builder.cjs +84 -0
  14. package/engine/agents/router.cjs +8 -2
  15. package/engine/automation/launchd.cjs +131 -0
  16. package/engine/bootstrap-schema.sql +6 -44
  17. package/engine/browser/cdp.cjs +188 -0
  18. package/engine/browser/vault.cjs +124 -0
  19. package/engine/cli-output.cjs +262 -0
  20. package/engine/cloud-assets/package.cjs +5 -1
  21. package/engine/commands/automation.cjs +37 -1
  22. package/engine/commands/billing.cjs +3 -0
  23. package/engine/commands/browser.cjs +166 -8
  24. package/engine/commands/build.cjs +101 -20
  25. package/engine/commands/connect.cjs +162 -9
  26. package/engine/commands/creds.cjs +65 -3
  27. package/engine/commands/doctor.cjs +66 -4
  28. package/engine/commands/document.cjs +79 -0
  29. package/engine/commands/graph.cjs +1190 -0
  30. package/engine/commands/help.cjs +87 -9
  31. package/engine/commands/hep-cloud.cjs +9 -23
  32. package/engine/commands/hep-hub.cjs +9 -22
  33. package/engine/commands/hep-local.cjs +9 -24
  34. package/engine/commands/hep-network.cjs +9 -35
  35. package/engine/commands/index.cjs +50 -25
  36. package/engine/commands/list.cjs +10 -1
  37. package/engine/commands/mcp.cjs +6 -2
  38. package/engine/commands/native.cjs +18 -2
  39. package/engine/commands/plugin.cjs +22 -0
  40. package/engine/commands/project.cjs +79 -16
  41. package/engine/commands/roles.cjs +210 -0
  42. package/engine/commands/telegram.cjs +23 -16
  43. package/engine/commands/workforce.cjs +63 -12
  44. package/engine/core/desktop-core-fetch.cjs +98 -0
  45. package/engine/core/desktop-core.cjs +170 -0
  46. package/engine/graph/ask-model.cjs +159 -0
  47. package/engine/graph/interview.cjs +960 -0
  48. package/engine/graph/layout.cjs +139 -0
  49. package/engine/graph/package.cjs +223 -0
  50. package/engine/graph/vocabulary.generated.cjs +30 -0
  51. package/engine/hephaestus/local-core.cjs +159 -0
  52. package/engine/hephaestus/runtime.cjs +14 -10
  53. package/engine/project/controller.cjs +8 -8
  54. package/engine/project/team.cjs +99 -0
  55. package/engine/runtime-refusal.cjs +71 -0
  56. package/engine/runtimes/auth-evidence.cjs +78 -0
  57. package/engine/runtimes/detect.cjs +3 -0
  58. package/engine/runtimes/resolve.cjs +1 -1
  59. package/engine/sessions/prompt.cjs +16 -0
  60. package/engine/sessions/session.cjs +24 -2
  61. package/engine/telegram/connect.cjs +202 -0
  62. package/engine/tools/access-notice.cjs +86 -0
  63. package/engine/ui/palette.cjs +7 -3
  64. package/engine/ui/repl.cjs +120 -3
  65. package/engine/vendor/desktop-core.manifest.json +7 -0
  66. package/engine/workforce/capture.cjs +51 -1
  67. package/engine/workforce/deps.cjs +13 -0
  68. package/engine/workforce/local-core-transport.cjs +298 -0
  69. package/package.json +4 -2
  70. package/engine/commands/legacy-network.cjs +0 -29
@@ -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 };
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ /*
3
+ * runtimes/auth-evidence — CLI 런타임의 "로그인 흔적" 관측.
4
+ *
5
+ * 배경(2026-08-05 감사, 결함 B): doctor의 런타임 검사는 which(bin) 하나였다.
6
+ * codex가 로그아웃 상태여도 "✓ runtimes … ✓ active runtime — codex" +
7
+ * "doctor: all clear"가 나갔고, 사용자는 첫 실행이 죽을 때까지 몰랐다.
8
+ * 설치 여부와 인증 여부는 다른 축이다.
9
+ *
10
+ * 계약 — 증거이지 증명이 아니다:
11
+ * - 여기서는 각 CLI가 로그인 시 남기는 로컬 산출물(파일·키체인 항목·환경 변수)의
12
+ * 존재만 관측한다. 모델 호출·네트워크 왕복 없음. 파일 내용도 읽지 않는다
13
+ * (토큰 만료까지는 판정하지 않는다 — 그건 실행이 판정한다).
14
+ * - 산출물이 있어도 만료됐을 수 있으므로 "로그인됨"이라고 단정하지 않는다.
15
+ * 산출물이 없으면 "흔적 없음 — 로그인이 필요할 수 있음"까지만 말한다.
16
+ * - 검사법을 모르는 런타임은 unknown — 모르는 것을 없다고 말하지 않는다.
17
+ *
18
+ * 반환: { status: "evidence" | "none" | "unknown", detail: string }
19
+ */
20
+ const fs = require("node:fs");
21
+ const os = require("node:os");
22
+ const path = require("node:path");
23
+ const { spawnSync } = require("node:child_process");
24
+
25
+ const home = () => os.homedir();
26
+
27
+ function fileEvidence(rel, label) {
28
+ const p = path.join(home(), ...rel);
29
+ try {
30
+ if (fs.existsSync(p) && fs.statSync(p).size > 0) return { status: "evidence", detail: label || p };
31
+ } catch { /* unreadable == no evidence */ }
32
+ return null;
33
+ }
34
+
35
+ function envEvidence(names) {
36
+ for (const name of names) {
37
+ if (typeof process.env[name] === "string" && process.env[name].trim()) {
38
+ return { status: "evidence", detail: `env ${name}` };
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /** macOS 키체인 generic password 존재 확인 — 값은 읽지 않는다(-w 금지). */
45
+ function keychainEvidence(service, label) {
46
+ if (process.platform !== "darwin") return null;
47
+ try {
48
+ const res = spawnSync("security", ["find-generic-password", "-s", service], {
49
+ stdio: ["ignore", "ignore", "ignore"],
50
+ timeout: 3000,
51
+ });
52
+ if (res.status === 0) return { status: "evidence", detail: label || `keychain ${service}` };
53
+ } catch { /* security unavailable == no evidence */ }
54
+ return null;
55
+ }
56
+
57
+ const CHECKS = {
58
+ "claude-code": () =>
59
+ envEvidence(["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"])
60
+ || keychainEvidence("Claude Code-credentials", "keychain")
61
+ || fileEvidence([".claude", ".credentials.json"], "~/.claude/.credentials.json"),
62
+ codex: () =>
63
+ fileEvidence([".codex", "auth.json"], "~/.codex/auth.json")
64
+ || envEvidence(["OPENAI_API_KEY"]),
65
+ gemini: () =>
66
+ fileEvidence([".gemini", "oauth_creds.json"], "~/.gemini/oauth_creds.json")
67
+ || envEvidence(["GEMINI_API_KEY", "GOOGLE_API_KEY"]),
68
+ };
69
+
70
+ function runtimeAuthEvidence(kind) {
71
+ const check = CHECKS[kind];
72
+ if (!check) return { status: "unknown", detail: "no local evidence check for this runtime" };
73
+ const found = check();
74
+ if (found) return found;
75
+ return { status: "none", detail: "" };
76
+ }
77
+
78
+ module.exports = { runtimeAuthEvidence };
@@ -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,
@@ -190,6 +190,22 @@ function augmentSystem(db, baseSystem, ctx, withEmitter, request = "") {
190
190
  if (connectionSkill) sys += "\n\n" + connectionSkill;
191
191
  const mem = cliMemoryContext(db, ctx && ctx.projectPath, ctx && ctx.agentId, request);
192
192
  if (mem) sys += "\n\n" + mem;
193
+ // 도구 접근 고지 — 터미널에는 이게 아예 없었다. 도구가 붙지 않은 턴에서 CLI는 아무
194
+ // 말도 하지 않았고, 에이전트는 "이 기계엔 도구가 없다"고 단정하거나 없는 도구를
195
+ // 불렀다. Desktop `shared/tool-access-notice.ts`와 같은 문장을 낸다(패리티 테스트로 고정).
196
+ //
197
+ // ★메모리 블록보다 **앞**에 둔다. 메모리 코어 예산은 `## Memory` 이후를 잘라서 재므로
198
+ // (test/memory-prompt-budget.cjs), 뒤에 붙이면 도구 고지가 메모리 예산으로 잘못 계산된다.
199
+ // 고지는 메모리 블록의 일부가 아니다.
200
+ try {
201
+ const { buildToolAccessNotice } = require("../tools/access-notice.cjs");
202
+ sys += "\n\n" + buildToolAccessNotice({
203
+ availableTools: (ctx && Array.isArray(ctx.availableTools)) ? ctx.availableTools : [],
204
+ // 터미널은 Hub 카탈로그를 hephaestus-network MCP로만 본다. 그 서버가 이번 턴에
205
+ // 붙지 않았으면 "찾아보라"고 말하면 안 된다 — 부를 수 없는 도구를 안내하는 셈이다.
206
+ hubCatalogAvailable: Boolean(ctx && ctx.hubCatalogAvailable),
207
+ });
208
+ } catch { /* 고지 실패가 턴을 막지 않는다 */ }
193
209
  if (withEmitter) {
194
210
  sys += "\n\n" + memoryEmitterPromptFor(request, arch, ctx && ctx.turnId, ctx && ctx.permission);
195
211
  const credentialReminder = credentialIndexReminderFor(request);
@@ -205,12 +205,21 @@ class Session extends EventEmitter {
205
205
  try {
206
206
  const { augmentSystem } = require("./prompt.cjs");
207
207
  const projectPath = governedTurn ? governedTurn.projectPath : memoryTurn.initializedProjectPath(this.cwd);
208
+ // 이번 턴에 실제로 붙는 도구를 고지에 넘긴다. 권한이 낮아 목록을 읽지 않는 턴은
209
+ // 빈 배열이 되고, 고지는 "붙은 도구가 없다"고 정확히 말한다 — 침묵하지 않는다.
210
+ const consentedServers = this._consentedMcpServers();
211
+ const toolNames = consentedServers
212
+ .map((server) => String((server && (server.name || server.id)) || "").trim())
213
+ .filter(Boolean);
208
214
  systemPrompt = augmentSystem(this.db, systemPrompt, {
209
215
  lang: this.lang,
210
216
  projectPath,
211
217
  agentId: this.agent.id,
212
218
  turnId: governedTurn && governedTurn.memoryTurn.turnId,
213
219
  permission: this.permission,
220
+ availableTools: toolNames,
221
+ // Hub 카탈로그는 hephaestus-network MCP를 통해서만 보인다.
222
+ hubCatalogAvailable: toolNames.some((name) => /hephaestus[-_]?network|agentlas/i.test(name)),
214
223
  }, true, prompt);
215
224
  } catch { /* 프롬프트 증강 실패는 턴을 막지 않는다 — 원 프롬프트로 진행 */ }
216
225
 
@@ -273,7 +282,13 @@ class Session extends EventEmitter {
273
282
  if (this._timeoutConfig) req.timeoutConfig = this._timeoutConfig;
274
283
  try {
275
284
  res = await nativeHost.runNativeTurn(req);
276
- 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) {
277
292
  const nextRuntime = this._nextRecoveryRuntime();
278
293
  if (nextRuntime) {
279
294
  const privateEvidence = [...this._privateRecoveryEvidence, String(res.error)]
@@ -411,7 +426,14 @@ class Session extends EventEmitter {
411
426
  }
412
427
  if (res && res.error) {
413
428
  this.status = "failed";
414
- 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);
415
437
  this._record({ type: "turn-end", at: Date.now(), ok: false, recoveryRequired: true });
416
438
  } else {
417
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
+ };
@@ -0,0 +1,86 @@
1
+ // 도구 접근 고지 — Desktop `shared/tool-access-notice.ts`의 터미널 미러.
2
+ //
3
+ // 정본은 Desktop 쪽이고, 이 파일은 **같은 문장을 내야 한다**. 두 벌이 갈라지면 같은
4
+ // 제품이 표면마다 다른 말을 하게 되고, 사용자는 어느 쪽이 맞는지 알 수 없다.
5
+ // `test/tool-access-notice-parity.cjs`가 두 구현을 같은 입력으로 돌려 대조한다.
6
+ //
7
+ // 터미널에는 이 고지가 **아예 없었다**. 도구가 붙지 않은 실행에서 CLI는 아무 말도 하지
8
+ // 않았고, 에이전트는 "이 기계엔 도구가 없다"고 단정하거나 없는 도구를 부르거나 그냥
9
+ // 침묵했다. 도구가 없을 때가 안내가 가장 필요한 순간이다.
10
+
11
+ "use strict";
12
+
13
+ const DEFAULT_RESOLVE_TOOL = "agentlas_resolve_plugins";
14
+
15
+ /**
16
+ * 모든 표면이 공유하는 도구 접근 고지.
17
+ * 절대 빈 문자열을 반환하지 않는다 — 붙은 도구가 없다는 사실 자체가 정보다.
18
+ *
19
+ * @param {{
20
+ * availableTools: string[],
21
+ * blockedTools?: string[],
22
+ * pendingApprovalTools?: string[],
23
+ * hubCatalogAvailable: boolean,
24
+ * hubCatalogError?: string|null,
25
+ * resolveToolName?: string,
26
+ * }} input
27
+ * @returns {string}
28
+ */
29
+ function buildToolAccessNotice(input) {
30
+ const resolveTool = (input.resolveToolName || "").trim() || DEFAULT_RESOLVE_TOOL;
31
+ const clean = (list) => (Array.isArray(list) ? list : []).filter((name) => String(name || "").trim().length > 0);
32
+ const available = clean(input.availableTools);
33
+ const blocked = clean(input.blockedTools);
34
+ const pending = clean(input.pendingApprovalTools);
35
+ const lines = [];
36
+
37
+ lines.push(
38
+ available.length > 0
39
+ ? `Tools available in this run: ${available.join(", ")}.`
40
+ : "No tools are connected in this run.",
41
+ );
42
+
43
+ if (pending.length > 0) {
44
+ lines.push(
45
+ `Already attached but switched off, waiting for the user to approve local execution: ${pending.join(", ")}. ` +
46
+ "Ask the user to approve it instead of installing anything new.",
47
+ );
48
+ }
49
+
50
+ if (input.hubCatalogAvailable) {
51
+ lines.push(
52
+ `Before telling the user a capability is unavailable, call ${resolveTool} with the capability you need. ` +
53
+ "The Agentlas Hub catalog covers integrations that are not installed here yet.",
54
+ );
55
+ } else if (input.hubCatalogError) {
56
+ lines.push(
57
+ `The Agentlas Hub catalog could not be reached this run (${input.hubCatalogError}). ` +
58
+ "Say that the catalog is unreachable rather than that no such tool exists.",
59
+ );
60
+ } else {
61
+ lines.push(
62
+ "The Agentlas Hub catalog is not reachable from this surface. " +
63
+ "Say what you cannot do and why; do not claim a capability that is not connected.",
64
+ );
65
+ }
66
+
67
+ lines.push(
68
+ "Never install or enable a tool on your own. Show the slug, what it will be allowed to do, " +
69
+ "and whether it needs credentials, then let the user decide.",
70
+ );
71
+
72
+ if (blocked.length > 0) {
73
+ lines.push(
74
+ `Matched but unusable until credentials are set: ${blocked.join(", ")}. ` +
75
+ "Ask for those only if this task actually needs them.",
76
+ );
77
+ }
78
+
79
+ lines.push(
80
+ "If nothing covers the need, say so plainly. Do not describe a tool call you did not make.",
81
+ );
82
+
83
+ return lines.join("\n");
84
+ }
85
+
86
+ module.exports = { buildToolAccessNotice, DEFAULT_RESOLVE_TOOL };