agentlas 1.0.49 → 1.0.50

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.
@@ -485,14 +485,18 @@ function createDecisionReceipt({ taskId, stage, decision, resolution, role, usag
485
485
  modelId: normalized && normalized.exactModelId
486
486
  ? normalized.exactModelId
487
487
  : source === "user-pin" ? cleanText(resolution && resolution.model, 255) || null : null,
488
- effort: normalized ? normalized.effort : "none",
488
+ // "none" is an effort level; no request is not one. Rendering the
489
+ // absence as a level made the receipt claim a decision nobody made —
490
+ // measured on the desktop twin, 17 of 46 receipts said resolved.effort
491
+ // "none" while carrying no effort-* reason code at all.
492
+ effort: normalized ? normalized.effort : null,
489
493
  },
490
494
  resolved: {
491
495
  tier: resolution && resolution.tier ? cleanText(resolution.tier, 32) : normalized ? normalized.tier : null,
492
496
  provider: cleanText(resolution && resolution.provider, 80) || null,
493
497
  modelId: cleanText(resolution && resolution.model, 255) || null,
494
498
  sessionId: cleanText(resolution && resolution.runtimeId, 255) || null,
495
- effort: cleanText(resolution && resolution.effort, 16) || "none",
499
+ effort: cleanText(resolution && resolution.effort, 16) || null,
496
500
  },
497
501
  reasonCodes,
498
502
  inputFeatureHash: normalized && normalized.inputFeatureHash ? normalized.inputFeatureHash : featureHash,
@@ -506,6 +506,47 @@ async function runGraph(ctx, needle, flags) {
506
506
  return 1;
507
507
  }
508
508
 
509
+ const fallbackRow = {
510
+ id: row.id,
511
+ name: row.name,
512
+ scheduleHuman: row.schedule,
513
+ targetType: row.target_type,
514
+ targetId: row.target_id,
515
+ enabled: Boolean(row.enabled),
516
+ createdBy: row.created_by || "terminal",
517
+ graph,
518
+ };
519
+ const initialVars = requirement && flags.input ? { [requirement.varName]: flags.input } : {};
520
+
521
+ /*
522
+ * ★데몬 우선 (Phase 3). 데몬이 떠 있으면 이 터미널은 코어를 **로드하지 않는다** —
523
+ * 64MB 벤더 사본도, 두 번째 DB 주인도 그 순간에는 없다. 그래프 실행은 완주 후
524
+ * 결과 JSON 하나라 요청/응답 소켓으로 손실 없이 옮겨진다.
525
+ *
526
+ * 데몬이 있는데 실행이 **실패한 것**은 폴백 사유가 아니다 — 같은 그래프를 코어로
527
+ * 다시 돌리면 부작용(외부 게시·파일 쓰기)이 두 번 난다. 폴백은 "데몬 없음"에만 걸린다.
528
+ */
529
+ const daemon = require("../core/daemon-client.cjs");
530
+ if (await daemon.daemonAvailable()) {
531
+ try {
532
+ const result = await daemon.callDaemon("graph.run", {
533
+ automationId: row.id,
534
+ automation: fallbackRow,
535
+ graph,
536
+ initialVars,
537
+ }, 30 * 60 * 1000);
538
+ ctx.out(JSON.stringify(result, null, 2));
539
+ return result && result.ok === true ? 0 : 1;
540
+ } catch (error) {
541
+ ctx.err(JSON.stringify({
542
+ ok: false,
543
+ via: "daemon",
544
+ error: error instanceof Error ? error.message : String(error),
545
+ }, null, 2));
546
+ return 1;
547
+ }
548
+ }
549
+
509
550
  const core = ctx.desktopCore || desktopCore.loadDesktopCore();
510
551
  if (!core || core.error || typeof core.runGraph !== "function") {
511
552
  const cause = core?.error instanceof Error ? core.error.message : "vendored Desktop Core is unavailable";
@@ -518,18 +559,8 @@ async function runGraph(ctx, needle, flags) {
518
559
  ? core.require("store/automations").getAutomation(row.id)
519
560
  : null;
520
561
  } catch { /* test/fallback row below */ }
521
- automation ||= {
522
- id: row.id,
523
- name: row.name,
524
- scheduleHuman: row.schedule,
525
- targetType: row.target_type,
526
- targetId: row.target_id,
527
- enabled: Boolean(row.enabled),
528
- createdBy: row.created_by || "terminal",
529
- graph,
530
- };
562
+ automation ||= fallbackRow;
531
563
  automation.graph = graph;
532
- const initialVars = requirement && flags.input ? { [requirement.varName]: flags.input } : {};
533
564
  try {
534
565
  const result = await core.runGraph(automation, graph, { initialVars });
535
566
  ctx.out(JSON.stringify(result, null, 2));
@@ -17,6 +17,7 @@
17
17
  */
18
18
  const { RUNTIME_BIN, whichSync } = require("../runtimes/detect.cjs");
19
19
  const { runtimeAuthEvidence } = require("../runtimes/auth-evidence.cjs");
20
+ const { canonicalRuntimeKind, storedRuntimeKind } = require("../runtimes/kinds.cjs");
20
21
  const { MODEL_ROLE_TABLE, VALID_ROLES, resolvedModelRole, roleMembers } = require("../runtimes/roles.cjs");
21
22
  const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
22
23
  const { EFFORTS } = require("../agentlas-workload-routing.cjs");
@@ -137,11 +138,13 @@ function set(ctx, args) {
137
138
  if (existing) {
138
139
  // kind가 바뀌면 이전 모델 id는 새 런타임의 어휘가 아니다(예: kimi에 opus).
139
140
  // --model 미지정 시 유지가 아니라 초기화 — 무의미한 좌표를 승계하지 않는다.
140
- const keepModel = existing.kind === kind ? existing.model : null;
141
+ const keepModel = canonicalRuntimeKind(existing.kind) === kind ? existing.model : null;
141
142
  db.prepare(
142
143
  "UPDATE model_roles SET kind=?, model=?, effort=?, inherit=?, updated_at=? WHERE role=?",
143
144
  ).run(
144
- kind,
145
+ // 공유 DB 는 데스크탑 어휘로 적는다(runtimes/kinds.cjs) — 여기서 이 저장소의
146
+ // 이름을 그대로 넣으면 데스크탑이 그 역할을 못 읽는다.
147
+ storedRuntimeKind(kind),
145
148
  model === undefined ? keepModel : model,
146
149
  flags.effort === undefined ? existing.effort : (flags.effort === "none" ? null : flags.effort),
147
150
  inherit,
@@ -151,7 +154,7 @@ function set(ctx, args) {
151
154
  } else {
152
155
  db.prepare(
153
156
  "INSERT INTO model_roles (role, kind, model, effort, inherit, updated_at) VALUES (?,?,?,?,?,?)",
154
- ).run(role, kind, model === undefined ? null : model, flags.effort === undefined || flags.effort === "none" ? null : flags.effort, inherit, now);
157
+ ).run(role, storedRuntimeKind(kind), model === undefined ? null : model, flags.effort === undefined || flags.effort === "none" ? null : flags.effort, inherit, now);
155
158
  }
156
159
  });
157
160
 
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ /*
3
+ * core/daemon-client — 같은 머신의 Agentlas 데몬(agentlasd)에 일을 시키는 클라이언트.
4
+ *
5
+ * ★왜 있나 (데몬 Phase 3). 이 터미널은 지금 데스크탑 코어를 **자기 프로세스에서**
6
+ * 돌린다 — 64MB 벤더 사본을 로드하고, 같은 DB 를 여는 두 번째 주인이 된다. 데몬이
7
+ * 떠 있으면 그럴 이유가 없다: "이거 해 줘" 한 줄이면 되고, 코어는 한 곳에서만 돈다.
8
+ *
9
+ * 프로토콜은 데몬 쪽(electron/daemon/control-socket.ts)과 같은 줄 단위 JSON-RPC.
10
+ * 주소 규칙도 같은 계산이다 — userDataDir()/daemon.sock (Windows 는 named pipe).
11
+ * **이 두 파일의 주소 계산이 어긋나면 서로를 영영 못 찾는다.** 여기 경로는
12
+ * core/paths.cjs 의 userDataDir 를 쓰므로, 데스크탑 앱과 같은 곳을 본다.
13
+ *
14
+ * 데몬이 없으면? 없다고 답할 뿐이다. 호출자는 예전처럼 벤더 코어로 폴백한다 —
15
+ * 데몬은 빠른 길이지 유일한 길이 아니다(그게 유일한 길이 되는 순간, 데몬이 죽으면
16
+ * 터미널 전체가 죽는다).
17
+ */
18
+ const net = require("node:net");
19
+ const crypto = require("node:crypto");
20
+ const path = require("node:path");
21
+ const { userDataDir } = require("./paths.cjs");
22
+
23
+ /** 데몬 소켓 주소 — control-socket.ts 의 defaultControlSocketPath 와 같은 규칙. */
24
+ function daemonSocketPath() {
25
+ const dir = userDataDir();
26
+ if (process.platform === "win32") {
27
+ const tag = Buffer.from(dir).toString("base64url").slice(-16);
28
+ return `\\\\.\\pipe\\agentlas-daemon-${tag}`;
29
+ }
30
+ // ★유닉스 소켓 경로 ~104바이트 한계(sockaddr_un). 실측: 128바이트에서 bind 는
31
+ // 조용히 지나가고 connect 만 EINVAL — 데몬 쪽(control-socket.ts)과 같은 폴백 규칙.
32
+ const preferred = path.join(dir, "daemon.sock");
33
+ if (Buffer.byteLength(preferred, "utf8") <= 100) return preferred;
34
+ const tag = crypto.createHash("sha256").update(dir).digest("hex").slice(0, 16);
35
+ return path.join(require("node:os").tmpdir(), `agentlas-daemon-${tag}.sock`);
36
+ }
37
+
38
+ /** JSON-RPC 한 번. 데몬이 에러를 주면 그 문장 그대로 throw 한다. */
39
+ function callDaemon(method, params, timeoutMs) {
40
+ const limit = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120000;
41
+ return new Promise((resolve, reject) => {
42
+ const socket = net.connect(daemonSocketPath());
43
+ const id = crypto.randomUUID();
44
+ let buffer = "";
45
+ let settled = false;
46
+ const finish = (fn) => {
47
+ if (settled) return;
48
+ settled = true;
49
+ clearTimeout(timer);
50
+ socket.destroy();
51
+ fn();
52
+ };
53
+ const timer = setTimeout(
54
+ () => finish(() => reject(new Error(`daemon did not answer ${method} within ${limit}ms`))),
55
+ limit,
56
+ );
57
+ socket.on("connect", () => socket.write(`${JSON.stringify({ id, method, params })}\n`));
58
+ socket.on("data", (chunk) => {
59
+ buffer += chunk.toString("utf8");
60
+ let nl;
61
+ while ((nl = buffer.indexOf("\n")) >= 0) {
62
+ const line = buffer.slice(0, nl).trim();
63
+ buffer = buffer.slice(nl + 1);
64
+ if (!line) continue;
65
+ let message;
66
+ try { message = JSON.parse(line); } catch { continue; }
67
+ if (message.id !== id) continue;
68
+ if (message.error) finish(() => reject(new Error(message.error.message || "daemon error")));
69
+ else finish(() => resolve(message.result));
70
+ }
71
+ });
72
+ socket.on("error", (error) => finish(() => reject(error)));
73
+ });
74
+ }
75
+
76
+ /**
77
+ * 데몬이 지금 떠서 답하는가. **접속이 아니라 응답**으로 판정한다 — 죽은 데몬이 남긴
78
+ * 소켓 파일에는 접속 자체가 실패하고, 산 데몬이라도 멎어 있으면 쓸 수 없다.
79
+ */
80
+ async function daemonAvailable() {
81
+ try {
82
+ const pong = await callDaemon("daemon.ping", undefined, 2000);
83
+ if (!pong || pong.ok !== true) return false;
84
+ return sharesOurStore(pong);
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * 데몬이 **우리와 같은 DB** 를 열었는가.
92
+ *
93
+ * ★`AGENTLAS_STORE_PATH` 는 이 프로세스에만 있다 — 소켓 너머 데몬은 그 값을 모른다.
94
+ * 그래서 사본을 지정해 놓고 일을 넘기면, 우리는 사본에 자동화를 만들고 데몬은
95
+ * **라이브에** 실행 기록·부수효과를 남긴다. 둘 다 "성공" 이라고 답하는데 결과는
96
+ * 두 데이터베이스에 반씩 흩어진다. 넘기기 전에 물어보고, 다르면 넘기지 않는다.
97
+ *
98
+ * 경로를 못 받는 옛 데몬은 예전처럼 신뢰한다 — 그때는 양쪽 다 기본 경로였다.
99
+ * 우리가 경로를 명시했는데 상대가 답을 못 하면, 그건 확인할 수 없는 상태이므로 거절한다.
100
+ */
101
+ function sharesOurStore(pong) {
102
+ const ours = String(process.env.AGENTLAS_STORE_PATH || "").trim();
103
+ if (!ours) return true;
104
+ const theirs = typeof pong.storePath === "string" ? pong.storePath.trim() : "";
105
+ if (!theirs) {
106
+ console.error(
107
+ "[daemon] AGENTLAS_STORE_PATH is set here, but the daemon does not report which database it opened.\n"
108
+ + "[daemon] Running the graph locally instead, so the work cannot land in a different store.",
109
+ );
110
+ return false;
111
+ }
112
+ const path = require("node:path");
113
+ if (path.resolve(theirs) === path.resolve(ours)) return true;
114
+ console.error(
115
+ `[daemon] the daemon opened a different database (${theirs}) than this command (${ours}).\n`
116
+ + "[daemon] Running the graph locally instead, so the work cannot land in a different store.",
117
+ );
118
+ return false;
119
+ }
120
+
121
+ module.exports = { daemonSocketPath, callDaemon, daemonAvailable, sharesOurStore };
@@ -69,7 +69,16 @@ function storeMigrationRole() {
69
69
  * owner 로 명시 지정된 경우에만, 거절 대신 벤더 코어의 사다리를 한 번 돌려 승급한다.
70
70
  */
71
71
  function openDb() {
72
- const file = dbPath();
72
+ /*
73
+ * ★AGENTLAS_STORE_PATH 를 존중한다 — 이 줄이 없어서 CLI 는 무슨 값을 주든 **항상 라이브
74
+ * DB** 를 열었다. 실측 2026-08-19: 복사본 스토어를 지정하고 `graph new` 로 자동화를
75
+ * 만들었는데 사용자의 라이브 데이터베이스에 저장됐다. 벤더 코어(desktop-core.cjs:150)
76
+ * 와 데스크탑(store/db)은 이 환경변수를 이미 존중하므로, 이 모듈만 어긋나 있었다.
77
+ *
78
+ * 그 결과 (1) 격리 QA 가 불가능하고 (2) 같은 명령이 여는 파일이 코어 경로냐 이 경로냐에
79
+ * 따라 갈린다 — 같은 프로세스가 두 DB 를 동시에 보게 된다.
80
+ */
81
+ const file = process.env.AGENTLAS_STORE_PATH || dbPath();
73
82
  if (!fs.existsSync(file)) {
74
83
  throw new Error(`Agentlas database not found: ${file} (run via bin/agentlas.cjs)`);
75
84
  }
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ /*
3
+ * core/keychain-read — 키체인 읽기를 "죽일 수 있는 곳"에서 한다.
4
+ *
5
+ * ★이 저장소는 이 함정을 이미 한 번 만났다: telegram/connect.cjs 는 "standalone Node 에서
6
+ * keytar 는 macOS 키체인에 막혀 멈추므로" 라며 keytar 를 아예 쓰지 않고 0600 파일로 갔다.
7
+ * 그런데 그 회피는 그 한 곳에만 있었고, 나머지는 `.catch(() => null)` 로 "거부는 없는 키로
8
+ * 본다" 는 가정을 들고 있었다. **거부가 아니라 정지가 실제 실패 모양이다.**
9
+ *
10
+ * macOS 키체인 항목에는 그것을 만든 프로그램의 ACL 이 붙는다. 다른 실행 파일이 읽으려 하면
11
+ * OS 가 승인 창을 띄우고, 띄울 화면이 없는 호스트에서는 답할 사람이 없어 호출이 안 돌아온다.
12
+ * 그리고 그건 **이벤트 루프 정지**다 — 같은 프로세스의 25초 setTimeout 조차 발화하지 않는
13
+ * 것을 측정했다(2026-08-19). 그래서 `Promise.race([call, timeout])` 은 원리적으로 못 막는다.
14
+ * 상한이 실제로 동작하려면 호출이 **다른 프로세스**에 있어야 한다.
15
+ *
16
+ * 데스크탑 쪽 같은 계약: agentlas_desktop/electron/secrets/keychain-host.ts.
17
+ */
18
+ const { execFile } = require("node:child_process");
19
+
20
+ const DEFAULT_TIMEOUT_MS = 10_000;
21
+ const MAX_TIMEOUT_MS = 120_000;
22
+
23
+ function keychainTimeoutMs() {
24
+ const raw = Number(process.env.AGENTLAS_KEYCHAIN_TIMEOUT_MS);
25
+ if (Number.isFinite(raw) && raw > 0) return Math.min(Math.floor(raw), MAX_TIMEOUT_MS);
26
+ return DEFAULT_TIMEOUT_MS;
27
+ }
28
+
29
+ /**
30
+ * 키체인 값 하나를 상한 안에서 읽는다. 못 읽으면 `null` — 호출부는 "키 없음" 갈래를 이미 갖고
31
+ * 있고, 멈춰 서는 것보다 그 갈래가 낫다. 값은 stdout(파이프)으로만 오고 argv 에는 안 실린다.
32
+ */
33
+ function readKeychainPassword(service, account) {
34
+ return new Promise((resolve) => {
35
+ let keytarPath;
36
+ try {
37
+ keytarPath = require.resolve("keytar");
38
+ } catch {
39
+ resolve(null);
40
+ return;
41
+ }
42
+ const script = `
43
+ const [modPath, service, account] = process.argv.slice(1);
44
+ const keytar = require(modPath);
45
+ keytar.getPassword(service, account)
46
+ .then((v) => { process.stdout.write(JSON.stringify({ value: v ?? null })); process.exit(0); })
47
+ .catch(() => { process.stdout.write(JSON.stringify({ value: null })); process.exit(0); });
48
+ `;
49
+ execFile(
50
+ process.execPath,
51
+ ["-e", script, keytarPath, String(service), String(account)],
52
+ { timeout: keychainTimeoutMs(), killSignal: "SIGKILL", maxBuffer: 1024 * 1024 },
53
+ (error, stdout) => {
54
+ if (error) {
55
+ resolve(null);
56
+ return;
57
+ }
58
+ try {
59
+ const parsed = JSON.parse(String(stdout || "{}"));
60
+ resolve(typeof parsed.value === "string" ? parsed.value : null);
61
+ } catch {
62
+ resolve(null);
63
+ }
64
+ },
65
+ );
66
+ });
67
+ }
68
+
69
+ module.exports = { readKeychainPassword, keychainTimeoutMs };
@@ -184,6 +184,20 @@ const RULES = [
184
184
  " the fact (kind:\"code\" or a read step with uses) into its own produces, then set the",
185
185
  " check's evidence:\"<that name>\". The check then compares result against evidence.",
186
186
  " Only ask when the goal itself is too vague to know what the result even is.",
187
+ " · MUTATION RESULT — verify it landed, not that the model said so (REQUIRED for every",
188
+ " effect:\"mutation\" step that acts on the outside world — posting, sending, saving,",
189
+ " updating). A run that writes \"posted 3 replies\" has not posted anything; only an",
190
+ " INDEPENDENT observation of the outside world proves it. So the mutation step must",
191
+ " produce the identifiers needed to observe its result (the posted URLs/ids, the file",
192
+ " path, the updated row id) — put that in its produces. Then add, AFTER it, a read step",
193
+ " (effect:\"read\") that RE-OBSERVES the result from the outside — a fresh look, not the",
194
+ " mutation's own report: open each returned URL, re-read the saved file, re-fetch the",
195
+ " updated row — into its own produces. Then add a check whose subject is the mutation's",
196
+ " result and whose evidence is that observation, with must-items like 'each posted item",
197
+ " exists at its URL with the expected author and text' / 'the file exists and contains",
198
+ " the expected fields' / 'the row was appended with the submitted values'. If the outside",
199
+ " result genuinely cannot be re-observed, say so in the step instruction rather than",
200
+ " skipping the check.",
187
201
  " · repeatOn says which side loops. Write the condition the way the person said it and",
188
202
  " put the loop on the side they meant — do not flip either one to make it fit.",
189
203
  "",
@@ -480,6 +494,57 @@ function validateBlueprint(bp, ctx = {}) {
480
494
  });
481
495
  }
482
496
 
497
+ /*
498
+ * ★바깥을 바꾼 mutation의 **결과**는 독립 재조회로 확인해야 한다(입력이 아니라 결과).
499
+ *
500
+ * 위 블록은 mutation이 **소비하는 입력값**을 검증하게 한다(게시할 목록이 채워졌나).
501
+ * 하지만 "게시가 실제로 됐나"는 그것으로 답이 안 된다 — 모델이 "게시 완료"라고 써도
502
+ * 바깥에는 아무것도 없을 수 있다(실측 2026-08-19: X 자동화가 두 런타임에서 4/4로 끝나며
503
+ * "3건 게시"라고 적었지만 X엔 0건). 결과가 실제로 반영됐는지는 **바깥을 다시 관측한
504
+ * 근거**로만 판정된다.
505
+ *
506
+ * 데스크탑 정본(shared/graph-blueprint.ts)과 같은 규칙 — 플러그인(hep-graph)으로 만든
507
+ * 자동화도 같은 보장을 받아야 한다. 두 입구가 다른 그래프를 만들면 안 된다.
508
+ */
509
+ {
510
+ const resultChecks = new Map();
511
+ for (const check of bp.checks || []) {
512
+ const subj = (check.subject || "").trim();
513
+ if (subj) resultChecks.set(subj, check);
514
+ }
515
+ steps.forEach((step, index) => {
516
+ if (step.effect !== "mutation") return;
517
+ const result = String(step.produces || "").trim();
518
+ if (!result) {
519
+ push(
520
+ `"${step.title || `${index + 1}번째 단계`}"는 바깥을 바꾸는데 결과값(produces)이 없습니다. `
521
+ + `무엇이 반영됐는지(게시된 URL·저장된 경로·갱신된 행 id 등)를 produces로 내보내야 `
522
+ + `그 결과가 실제로 일어났는지 확인할 수 있습니다.`,
523
+ );
524
+ return;
525
+ }
526
+ const check = resultChecks.get(result);
527
+ const hasEvidence = !!check && !!String((check && check.evidence) || "").trim();
528
+ if (!hasEvidence) {
529
+ const madeBy = steps.findIndex(
530
+ (s, i) => i > index && s.effect === "read"
531
+ && (Array.isArray(s.consumes) ? s.consumes : []).map((v) => String(v).trim()).includes(result),
532
+ );
533
+ const evName = `${result}_observed`;
534
+ push(
535
+ `"${step.title || `${index + 1}번째 단계`}"는 바깥을 바꿨지만, 그 결과가 실제로 반영됐는지 `
536
+ + `**독립적으로 다시 관측해** 확인하는 검증이 없습니다. 모델이 "완료"라고 써도 바깥은 그대로일 수 `
537
+ + `있습니다. 단계를 지우지 말고: (1) 이 단계 뒤에 결과를 바깥에서 다시 보는 read 단계`
538
+ + `(게시 URL 열기·파일 다시 읽기·행 재조회)를 두어 그 관측을 "${evName}"으로 내보내고, `
539
+ + `(2) top-level checks[]에 {"afterStep":${madeBy >= 0 ? madeBy : index + 1},"subject":"${result}",`
540
+ + `"criteria":"${result}이(가) 바깥에 실제로 반영됐다","evidence":"${evName}",`
541
+ + `"produces":"${result}_ok","items":[{"text":"관측된 결과가 반영하려던 것과 일치한다","kind":"must"},`
542
+ + `{"text":"관측되지 않았거나 지어낸 확인이 아니다","kind":"mustNot"}]} 를 추가하세요.`,
543
+ );
544
+ }
545
+ });
546
+ }
547
+
483
548
  // 반복이 있는데 검증이 없으면 "마음에 들 때까지"를 글자 찾기로 흉내 내게 된다(실측).
484
549
  for (const branch of bp.branches || []) {
485
550
  if (branch.repeatStep === undefined) continue;
@@ -634,7 +699,17 @@ function humanSchedule(schedule, locale) {
634
699
 
635
700
  function hhmm(hour, minute, locale) {
636
701
  if (locale !== "ko") return `${hour}:${minute}`;
637
- return `${hour}:${minute}`;
702
+ /*
703
+ * ★한국어는 사람이 말하는 대로 — "오전 8시", "오후 6시 30분" (오너 결정 2026-08-19).
704
+ * 정본은 데스크탑 shared/schedule-describe.ts 의 koClock 이고, 이 파일은 그 손복사본이다
705
+ * (test/graph-interview-parity.cjs 가 두 벌이 갈리는 순간 실패한다).
706
+ */
707
+ const h = Number(hour);
708
+ const m = Number(minute);
709
+ if (!Number.isFinite(h) || !Number.isFinite(m)) return `${hour}:${minute}`;
710
+ const half = h < 12 ? "오전" : "오후";
711
+ const display = h % 12 === 0 ? 12 : h % 12;
712
+ return m === 0 ? `${half} ${display}시` : `${half} ${display}시 ${m}분`;
638
713
  }
639
714
 
640
715
  const DOW_KO = { "0": "일", "1": "월", "2": "화", "3": "수", "4": "목", "5": "금", "6": "토", "7": "일" };
@@ -7,7 +7,7 @@
7
7
  "use strict";
8
8
 
9
9
  const GRAPH_WIRE = "graph/1";
10
- const GRAPH_ERROR_CODES = ["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"];
10
+ const GRAPH_ERROR_CODES = ["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_FAILED","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_CLAIMED_WITHOUT_TOOLS","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}};
@@ -30,6 +30,43 @@ const RUNTIME_KIND_SPECS = [
30
30
  /** kind → 실행 파일 이름. CLI 런타임 전체(네이티브 + ACP). */
31
31
  const RUNTIME_BIN = Object.fromEntries(RUNTIME_KIND_SPECS.map((s) => [s.kind, s.bin]));
32
32
 
33
+ /**
34
+ * 데스크탑이 **같은 DB 에 적어 둔 이름** → 이 저장소의 이름.
35
+ *
36
+ * ★터미널과 데스크탑은 하나의 SQLite 를 공유하는데 같은 런타임을 다르게 부른다:
37
+ * 데스크탑 `shared/runtime-kinds.ts` 는 `antigravity`, 여기는 `agy` 다. 그 차이는
38
+ * 주석에만 적혀 있었고 **읽는 자리에서 번역되지 않았다**.
39
+ *
40
+ * 결과(실측 2026-08-19): 사용자가 오케스트레이터를 Antigravity 로 골라 두면
41
+ * `model_roles.kind = "antigravity"` 가 저장되는데, 터미널의 실행 가능 집합에는
42
+ * 그 이름이 없어 **"이 컴퓨터에서 실행 불가"로 걸러지고** 풀 3순위(codex)가 대신 돌았다.
43
+ * `agentlas roles` 는 antigravity 라고 보여 주면서 그래프 빌더는 codex 로 지었다 —
44
+ * 고른 대로 안 도는데 화면은 고른 대로 보였다.
45
+ *
46
+ * 저장된 이름은 못 바꾼다(데스크탑이 계속 그렇게 쓴다). 그러니 **읽는 쪽이 번역한다**.
47
+ */
48
+ const STORED_KIND_ALIASES = { antigravity: "agy" };
49
+
50
+ /** 저장소에서 읽은 kind 를 이 저장소의 정본 이름으로. 모르는 값은 그대로 돌려준다. */
51
+ function canonicalRuntimeKind(kind) {
52
+ const text = typeof kind === "string" ? kind.trim() : "";
53
+ if (!text) return text;
54
+ return STORED_KIND_ALIASES[text] ?? text;
55
+ }
56
+
57
+ /** 역방향 — 공유 DB 에 적을 이름. 어긋남은 **양쪽으로** 난다:
58
+ * `agentlas roles set orchestrator agy` 가 `agy` 를 그대로 적으면, 이번에는
59
+ * 데스크탑이 그 이름을 모른다(shared/runtime-kinds.ts 에 `agy` 가 없다).
60
+ * 저장 어휘는 스키마 주인인 데스크탑 쪽으로 통일하고, 읽을 때 위에서 되돌린다. */
61
+ const CANONICAL_TO_STORED = Object.fromEntries(
62
+ Object.entries(STORED_KIND_ALIASES).map(([stored, canonical]) => [canonical, stored]),
63
+ );
64
+ function storedRuntimeKind(kind) {
65
+ const text = typeof kind === "string" ? kind.trim() : "";
66
+ if (!text) return text;
67
+ return CANONICAL_TO_STORED[text] ?? text;
68
+ }
69
+
33
70
  /** CLI 런타임 kind 전체(탐지 순서). */
34
71
  const CLI_KINDS = RUNTIME_KIND_SPECS.map((s) => s.kind);
35
72
 
@@ -71,6 +108,9 @@ const CONTRACT_RUNTIME_BACKENDS = [
71
108
  module.exports = {
72
109
  RUNTIME_KIND_SPECS,
73
110
  RUNTIME_BIN,
111
+ STORED_KIND_ALIASES,
112
+ canonicalRuntimeKind,
113
+ storedRuntimeKind,
74
114
  CLI_KINDS,
75
115
  NATIVE_CLI_KINDS,
76
116
  ACP_CLI_KINDS,
@@ -20,6 +20,7 @@ const {
20
20
  API_EXECUTABLE_KINDS,
21
21
  } = require("./resolve.cjs");
22
22
  const { pickRoleFromPool } = require("./roles.cjs");
23
+ const { canonicalRuntimeKind } = require("./kinds.cjs");
23
24
 
24
25
  const OVERRIDE_TABLE = "agent_runtime_overrides";
25
26
  // 데스크탑 VALID_SCOPES 동형. v2 터미널 호출자는 주로 'agent'지만 firm/division도 읽을 수 있다.
@@ -38,7 +39,8 @@ function rowToOverride(row) {
38
39
  targetId: row.target_id,
39
40
  label: cleanText(row.label),
40
41
  selection: {
41
- kind: String(row.kind),
42
+ // 저장된 이름(데스크탑 표기) → 이 저장소의 이름. kinds.cjs 참고.
43
+ kind: canonicalRuntimeKind(String(row.kind)),
42
44
  backend: cleanText(row.backend) || undefined,
43
45
  source: cleanText(row.source) || undefined,
44
46
  model: cleanText(row.model) || undefined,
@@ -9,6 +9,7 @@
9
9
  * The worker may inherit upward for quality; the orchestrator never falls
10
10
  * downward to the worker row.
11
11
  */
12
+ const { canonicalRuntimeKind } = require("./kinds.cjs");
12
13
  const { tableExists, columnExists } = require("../core/db.cjs");
13
14
 
14
15
  const MODEL_ROLE_TABLE = "model_roles";
@@ -49,7 +50,7 @@ function legacyOrchestrator(db) {
49
50
  }
50
51
  return {
51
52
  role: "orchestrator",
52
- kind: cleanText(row.kind),
53
+ kind: canonicalRuntimeKind(cleanText(row.kind)),
53
54
  backend: cleanText(row.backend),
54
55
  source: cleanText(row.source),
55
56
  model: cleanText(row.model),
@@ -68,7 +69,8 @@ function normalizedRow(row, role) {
68
69
  if (!row || !cleanText(row.kind)) return null;
69
70
  return {
70
71
  role,
71
- kind: cleanText(row.kind),
72
+ // 저장된 이름(데스크탑 표기)을 이 저장소의 이름으로 번역한다 — kinds.cjs 참고.
73
+ kind: canonicalRuntimeKind(cleanText(row.kind)),
72
74
  backend: cleanText(row.backend),
73
75
  source: cleanText(row.source),
74
76
  model: cleanText(row.model),
@@ -28,17 +28,10 @@ const { dbPath, userDataDir } = require("../core/paths.cjs");
28
28
  // 있지만 buildArgs/텍스트 추출 계약이 없으므로 캡처 검증 파생본만 쓴다 — 새 kind 를
29
29
  // 정본에 추가해도 capture:true 를 명시하기 전엔 여기 조용히 들어오지 않는다.
30
30
  const { CAPTURE_RUNTIME_BIN: RUNTIME_BIN } = require("../runtimes/kinds.cjs");
31
+ const { readKeychainPassword } = require("../core/keychain-read.cjs");
31
32
 
32
33
  const SERVICE = "com.agentlas.desktop";
33
34
 
34
- function readKeytar() {
35
- try {
36
- return require("keytar");
37
- } catch {
38
- return null;
39
- }
40
- }
41
-
42
35
  // v1 which 포팅: PATH + 알려진 설치 위치. detect.whichSync(spawn `which`)와 달리
43
36
  // 캡처 경로는 프로세스 spawn 없이 결정론적으로 실행 파일을 찾는다.
44
37
  function which(cmd) {
@@ -664,10 +657,11 @@ const ANTHROPIC_COMPAT_API = {
664
657
  const DEFAULT_CUSTOM_API_BASE_URL = "https://api.openai.com/v1";
665
658
 
666
659
  async function apiKey(backend) {
667
- const keytar = readKeytar();
668
- if (!keytar) return null;
669
- // 키체인 접근 거부(서명 standalone Node)는 "키 없음"으로 조용히 처리.
670
- return keytar.getPassword(SERVICE, "byok:" + backend).catch(() => null);
660
+ // ★거부가 아니라 **정지**가 실제 실패 모양이다. 예전에는 `.catch(() => null)` 하나로
661
+ // "접근 거부는 키 없음" 이라 적어 두었는데, 화면 없는 호스트에서 keytar 거부하지 않고
662
+ // 영영 돌아오지 않는다(이벤트 루프째로). core/keychain-read 상한을 실제로 있는
663
+ // 자식 프로세스에서 읽고, 읽으면 여기 계약대로 "키 없음"을 돌려준다.
664
+ return readKeychainPassword(SERVICE, "byok:" + backend);
671
665
  }
672
666
 
673
667
  // Custom BYOK 키가 전송될 origin 재검증: 공개 주소는 HTTPS만, HTTP는 localhost/LAN만.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlas",
3
- "version": "1.0.49",
3
+ "version": "1.0.50",
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"