agentlas 1.0.11 → 1.0.14

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 (57) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +4 -2
  3. package/bin/agentlas.cjs +17 -3
  4. package/engine/agentlas-config.cjs +25 -18
  5. package/engine/agentlas-core-harness.cjs +14 -1
  6. package/engine/agentlas-i18n.cjs +2 -0
  7. package/engine/agentlas-input.cjs +62 -8
  8. package/engine/agentlas-memory-governance.cjs +10 -0
  9. package/engine/agentlas-onboard.cjs +22 -5
  10. package/engine/agentlas-sqlite-policy.cjs +18 -7
  11. package/engine/agentlas-workforce.cjs +989 -114
  12. package/engine/agentlas-workload-routing.cjs +61 -7
  13. package/engine/agentlas.cjs +8 -0
  14. package/engine/automation/daemon.cjs +76 -30
  15. package/engine/automation/schedule.cjs +16 -0
  16. package/engine/automation/store.cjs +92 -12
  17. package/engine/bootstrap-schema.sql +788 -29
  18. package/engine/commands/automation.cjs +8 -0
  19. package/engine/commands/chats.cjs +6 -1
  20. package/engine/commands/doctor.cjs +23 -1
  21. package/engine/commands/firm.cjs +66 -4
  22. package/engine/commands/help.cjs +31 -7
  23. package/engine/commands/hep-cloud.cjs +31 -0
  24. package/engine/commands/hep-hub.cjs +30 -0
  25. package/engine/commands/hep-local.cjs +32 -0
  26. package/engine/commands/hep-network.cjs +43 -0
  27. package/engine/commands/index.cjs +38 -7
  28. package/engine/commands/list.cjs +25 -1
  29. package/engine/commands/open.cjs +5 -1
  30. package/engine/commands/run.cjs +77 -13
  31. package/engine/commands/setup.cjs +12 -11
  32. package/engine/commands/storm.cjs +5 -9
  33. package/engine/commands/swarm.cjs +4 -4
  34. package/engine/commands/uninstall.cjs +36 -2
  35. package/engine/commands/version.cjs +29 -0
  36. package/engine/commands/workforce.cjs +10 -10
  37. package/engine/core/schema-ensure.cjs +75 -0
  38. package/engine/experience/variant.cjs +46 -2
  39. package/engine/firms/orchestrate.cjs +10 -3
  40. package/engine/hephaestus/runtime.cjs +7 -0
  41. package/engine/memory-cli/curate.cjs +3 -7
  42. package/engine/project/memory-context.cjs +3 -7
  43. package/engine/project/state.cjs +7 -6
  44. package/engine/runtimes/overrides.cjs +91 -22
  45. package/engine/runtimes/resolve.cjs +38 -8
  46. package/engine/runtimes/roles.cjs +162 -0
  47. package/engine/sessions/orchestrator.cjs +43 -4
  48. package/engine/sessions/prompt.cjs +4 -7
  49. package/engine/sessions/session.cjs +88 -20
  50. package/engine/storm/swarm.cjs +40 -12
  51. package/engine/ui/palette.cjs +52 -0
  52. package/engine/ui/renderer.cjs +37 -0
  53. package/engine/ui/repl.cjs +78 -10
  54. package/engine/workforce/capture.cjs +199 -14
  55. package/engine/workforce/concurrency.cjs +41 -0
  56. package/engine/workforce/deps.cjs +145 -29
  57. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.14 — 2026-07-29
4
+
5
+ - **Write-capable commands now prepare every project automatically.** The first
6
+ `run`, `storm`, `swarm`, or workforce execution in any folder installs the
7
+ same private, merge-only Agentlas project infrastructure through Core. This
8
+ is based on the folder the user opened, not on the Agentlas source checkout.
9
+ Read-only commands remain passive and do not create files.
10
+ - **Per-command help works before database startup.** Commands such as
11
+ `agentlas run --help` and `agentlas workforce --help` no longer require an
12
+ SQLite driver or open the project database just to print usage.
13
+
14
+ ## 1.0.13 — 2026-07-28
15
+
16
+ - **MCP servers reach the chat again.** `runNativeTurn` only injects them at
17
+ `full` permission and that gate was right, but `sessions/session.cjs` never
18
+ put `mcpServers` in the request — zero callers — so no CLI ever received a
19
+ server. `agentlas mcp probe` printed "connected" while the turn that followed
20
+ could not use it. Turns now carry servers the user already consented to;
21
+ nothing new is asked mid-turn, because a turn is not a place a user can answer.
22
+ - **The shared database stops taking a write lock every turn.** Four
23
+ `CREATE TABLE IF NOT EXISTS`, five indexes and an `ALTER TABLE` ran on every
24
+ single turn against the file Desktop also uses. All idempotent, all taking the
25
+ lock; during a Desktop migration that burns the 15s busy timeout and every
26
+ call site swallowed the failure. Schema repair is once per connection now, and
27
+ the three copies of `ensureMemoryContextColumn` are one function.
28
+ - **Rows written here are checked for referential integrity.** `foreign_keys` is
29
+ a connection property, not a file property — Desktop opened with it ON and the
30
+ terminal did not, so terminal writes skipped the check on shared tables.
31
+ - **A zero-byte database file no longer blocks first run forever.** One stray
32
+ `sqlite3 <missing-path>` left an empty file, and every run after that read
33
+ "already exists" and died on `no such table` with nothing visible to explain it.
34
+ - **Bootstrap schema is current again** (v81; it was a v45 snapshot from
35
+ 2026-07-07), and a mismatch with Desktop's migration target now fails a gate.
36
+ - **An engine update landing mid-run cannot mix two releases.** Core roots
37
+ resolve to their real path, so a long run keeps the modules it started with.
38
+ The next call still picks up the new release.
39
+ - `/ontology` says what it manages — this project's knowledge sources, not the
40
+ engine's knowledge runtime. The command itself is unchanged.
41
+
42
+ ## 1.0.12 — 2026-07-28
43
+
44
+ - **Orchestrator/worker model roles now resolve from ordered candidate
45
+ pools.** The shared `model_role_members` table (Desktop schema v80) holds
46
+ n candidates per role in priority order; the terminal picks the first
47
+ member that is actually executable here, records every skipped member
48
+ with its reason, and never silently substitutes a lower-priority runtime
49
+ when all members are unavailable — the head member is used and the skip
50
+ list is preserved. An empty worker pool inherits the orchestrator pool,
51
+ matching the single-row inherit contract, and databases without pools
52
+ keep resolving through the v79 single-row and legacy `active_runtime`
53
+ ladders unchanged.
54
+ - **Workforce escalation is bounded and receipted end to end.** A worker
55
+ that violates the handoff output contract twice — or is named in two
56
+ consecutive verifier failures — gets exactly one orchestrator-role
57
+ retry, stamped with `escalated-after-failure`, the failure count, and
58
+ the attempt number; a failing escalation stops honestly instead of
59
+ looping or downgrading.
60
+ - **BYOK Anthropic calls mark the system prefix as a prompt-cache
61
+ breakpoint.** Real Anthropic endpoints receive the system prompt as one
62
+ `cache_control: ephemeral` block (~90% cheaper cached input on hits;
63
+ a silent no-op below the per-model minimum). Anthropic-compatible
64
+ endpoints (GLM/Kimi/DeepSeek) keep the plain string form they expect.
65
+ - Model-allocation receipts split "no allocation was provided"
66
+ (`allocation_not_provided`) from "the allocation was malformed"
67
+ (`invalid_ai_allocation`), and real invocations now retain the
68
+ provider-reported token usage per role instead of discarding it.
69
+
3
70
  ## 1.0.11 — 2026-07-27
4
71
 
5
72
  - **The slash palette repaints in place instead of stacking copies of
package/README.md CHANGED
@@ -110,9 +110,11 @@ agentlas connect <sub> # Telegram 등 플랫폼 연결 (무인자는 u
110
110
  agentlas import <폴더> # 로컬 에이전트/팀 임포트
111
111
  agentlas native prepare <agent> # 네이티브 CLI 문맥 파일 생성
112
112
  agentlas list # 설치 에이전트/회사 + 활성 런타임
113
- agentlas uninstall <agent> # 설치 에이전트 제거 (빌트인 거부)
113
+ agentlas uninstall <agent> [--yes] # 설치 에이전트 제거 (빌트인 거부).
114
+ # 대화 이력이 있으면 건수를 보여주고 --yes 없이는 거절한다
115
+ # (챗/메시지가 CASCADE로 함께 영구 삭제되기 때문).
114
116
  agentlas experience <sub> # list|inspect|validate|save|publish|status|export|unpublish|withdraw
115
- agentlas variant resolve # 로컬 variant 호환성 프리뷰 (권위 없음)
117
+ agentlas variant resolve --base-release <id> # 로컬 variant 호환성 프리뷰 (권위 없음, `agentlas variant help`)
116
118
  ```
117
119
 
118
120
  ### EXECUTE
package/bin/agentlas.cjs CHANGED
@@ -117,9 +117,23 @@ function bootstrapDbIfMissing() {
117
117
  const dir = path.dirname(p);
118
118
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
119
119
  securePrivateMode(dir, 0o700);
120
+ // 존재 여부만 보면 **0바이트 파일이 부트스트랩을 영구히 막는다.** 실측 2026-07-28:
121
+ // `sqlite3 <없는경로> 'PRAGMA user_version;'` 같은 무해한 명령 하나가 빈 파일을 남기고,
122
+ // 그 뒤 모든 실행이 "이미 있음"으로 판단해 스키마 없이 진행하다 `no such table` 로
123
+ // 죽는다. 사용자에게는 원인이 전혀 안 보이는 상태다.
124
+ //
125
+ // SQLite 파일은 최소 한 페이지(512바이트)를 갖는다. 0바이트는 DB 가 아니라 자리만
126
+ // 잡힌 파일이므로 없는 것으로 취급해 정상 부트스트랩 경로를 태운다. 내용이 있는데
127
+ // 손상된 경우는 여기서 판단하지 않는다 — 그건 복구지 부트스트랩이 아니고, 멀쩡한
128
+ // DB 를 빈 것으로 오판해 덮어쓰는 위험이 훨씬 크다.
120
129
  if (exists(p)) {
121
- securePrivateMode(p, 0o600);
122
- return { created: false, path: p };
130
+ let empty = false;
131
+ try { empty = fs.statSync(p).size === 0; } catch { empty = false; }
132
+ if (!empty) {
133
+ securePrivateMode(p, 0o600);
134
+ return { created: false, path: p };
135
+ }
136
+ try { fs.rmSync(p, { force: true }); } catch { /* 지울 수 없으면 아래 link 가 EEXIST 로 알려준다 */ }
123
137
  }
124
138
  const schemaFile = path.join(PKG_ROOT, "engine", "bootstrap-schema.sql");
125
139
  if (!exists(schemaFile)) {
@@ -172,7 +186,7 @@ function main() {
172
186
  let error = null;
173
187
  if (!engineFound) {
174
188
  error = "Engine not found (engine/agentlas.cjs). Reinstall with: npm i -g agentlas";
175
- } else if (!sqliteDriver) {
189
+ } else if (!sqliteDriver && !metadataOnly) {
176
190
  error = `Node ${process.version} — no SQLite driver. Upgrade to Node 22.5+ or reinstall with 'npm i -g agentlas' to build better-sqlite3.`;
177
191
  }
178
192
 
@@ -100,28 +100,35 @@ function mergePrefs(base, patch) {
100
100
  return next;
101
101
  }
102
102
 
103
+ /*
104
+ * Throws on failure (lock timeout, read-only data dir, ENOSPC …) — it used to
105
+ * swallow every error and return null, so `agentlas setup` announced "All set."
106
+ * and exited 0 while the user's language/runtime/permission were never written.
107
+ * A caller that cannot persist must be able to say so; use savePrefs for the
108
+ * best-effort boolean contract.
109
+ */
103
110
  function updatePrefs(userDataDir, patch) {
104
- try {
105
- fs.mkdirSync(userDataDir, { recursive: true });
106
- return withPrefsLock(userDataDir, () => {
107
- const file = prefsPath(userDataDir);
108
- const currentWasValid = Boolean(readPrefsFile(file));
109
- const current = loadPrefs(userDataDir);
110
- if (fs.existsSync(file) && !currentWasValid) {
111
- try { fs.renameSync(file, `${file}.corrupt-${Date.now()}-${process.pid}`); } catch { /* recover from backup */ }
112
- }
113
- const next = mergePrefs(current, patch);
114
- if (currentWasValid) atomicWrite(backupPath(userDataDir), current);
115
- atomicWrite(file, next);
116
- return next;
117
- });
118
- } catch {
119
- return null;
120
- }
111
+ fs.mkdirSync(userDataDir, { recursive: true });
112
+ return withPrefsLock(userDataDir, () => {
113
+ const file = prefsPath(userDataDir);
114
+ const currentWasValid = Boolean(readPrefsFile(file));
115
+ const current = loadPrefs(userDataDir);
116
+ if (fs.existsSync(file) && !currentWasValid) {
117
+ try { fs.renameSync(file, `${file}.corrupt-${Date.now()}-${process.pid}`); } catch { /* recover from backup */ }
118
+ }
119
+ const next = mergePrefs(current, patch);
120
+ if (currentWasValid) atomicWrite(backupPath(userDataDir), current);
121
+ atomicWrite(file, next);
122
+ return next;
123
+ });
121
124
  }
122
125
 
123
126
  function savePrefs(userDataDir, prefs) {
124
- return Boolean(updatePrefs(userDataDir, prefs));
127
+ try {
128
+ return Boolean(updatePrefs(userDataDir, prefs));
129
+ } catch {
130
+ return false;
131
+ }
125
132
  }
126
133
 
127
134
  module.exports = { prefsPath, loadPrefs, savePrefs, updatePrefs, mergePrefs };
@@ -84,7 +84,20 @@ function resolveCoreRuntimeRootFromCandidates(candidateRoots, requiredMarkers =
84
84
  const version = readCoreRuntimeVersion(root);
85
85
  if (!version || compareSemVer(version, minVersion) < 0) continue;
86
86
  }
87
- return root;
87
+ // `~/.agentlas/runtime/current` 는 업데이터가 원자적으로 갈아 끼우는 심볼릭
88
+ // 링크다. 그 경로를 그대로 넘기면 Python 이 import 를 늦게 해석하는 특성상,
89
+ // 긴 실행 도중 업데이트가 일어나면 **옛 버전 모듈과 새 버전 모듈이 한 프로세스에
90
+ // 섞여 로드된다** — 어떤 버전에서도 시험된 적 없는 조합이고 버전 번호로는
91
+ // 재현조차 못 한다(감사 D3).
92
+ //
93
+ // 실경로로 고정해도 라이브 버전 선택은 그대로다: **다음** 호출이 다시 해석해
94
+ // 새 릴리스를 집는다. 없어지는 것은 실행 중 교체뿐이다.
95
+ try {
96
+ return fs.realpathSync(root);
97
+ } catch {
98
+ // 링크를 읽을 수 없다고 실행을 거부할 이유는 없다 — 이전 동작대로 경로를 쓴다.
99
+ return root;
100
+ }
88
101
  } catch {
89
102
  // Continue to the next installed/bundled root.
90
103
  }
@@ -260,6 +260,7 @@ const STRINGS = {
260
260
  "wiz.default": "(Enter default)",
261
261
  "wiz.invalid": "Choose 1–%s.",
262
262
  "wiz.saved": "All set. You can change any of this later with /runtime, /permission.",
263
+ "wiz.saveFailed": "Could not save your choices (%s) — they apply to this session only, and setup will ask again next launch.",
263
264
  "wiz.changeLang": "Tip: re-run setup anytime with agentlas setup",
264
265
  },
265
266
  ko: {
@@ -505,6 +506,7 @@ const STRINGS = {
505
506
  "wiz.default": "(Enter 기본값)",
506
507
  "wiz.invalid": "1–%s 중 고르세요.",
507
508
  "wiz.saved": "완료. 나중에 /runtime, /permission으로 언제든 바꿀 수 있어요.",
509
+ "wiz.saveFailed": "선택을 저장하지 못했습니다 (%s) — 이번 세션에만 적용되고, 다음 실행 때 다시 물어봅니다.",
508
510
  "wiz.changeLang": "팁: 언제든 agentlas setup 으로 다시 설정",
509
511
  },
510
512
  };
@@ -298,7 +298,7 @@ const SLASH_COMMAND_META = [
298
298
  { command: "/route", description: "Preview which agent/pipeline would take a request", category: "Engine", usage: "/route <request>", detail: "Runs the Hephaestus router without executing — shows the selected agent, candidates, and reasons." },
299
299
  { command: "/research", description: "Run the Hephaestus Research Engine", category: "Engine", usage: "/research search \"query\"", detail: "status|gather|search|read|plan — evidence-grade web research from the terminal." },
300
300
  { command: "/search", description: "Discover agents in the Hub", category: "Hub", usage: "/search <what you need>", detail: "Search the Agentlas Hub + local for an agent that fits the task (hep-search)." },
301
- { command: "/install", description: "Install an agent from the Hub by slug", category: "Hub", usage: "/install <slug>", detail: "Install a marketplace agent into this terminal (hep-cloud)." },
301
+ { command: "/install", description: "Install an agent from the Hub by slug", category: "Hub", usage: "/install <slug>", detail: "Install a marketplace agent into this terminal." },
302
302
  { command: "/network", description: "Staff and execute an ontology-grounded task force", category: "Engine", usage: "/network <request> [--benchmark]", detail: "The active top host LLM creates the work order, searches the Hub workforce ontology, selects exact releases, and executes a receipt-backed task force.", aliases: ["/taskforce", "/workforce"] },
303
303
  { command: "/legacy-network", description: "Run the compatibility Hephaestus network route", category: "Engine", usage: "/legacy-network <request>", detail: "Explicit compatibility escape hatch; never used as a fallback by /network." },
304
304
  { command: "/browser", description: "Real browser execution hardpoint", category: "Engine", usage: "/browser [sub]", detail: "Runs the Agentlas browser hardpoint (hep-browser)." },
@@ -582,6 +582,7 @@ function attachSlashPalette(rl, opts = {}) {
582
582
  selected: 0,
583
583
  selectedCommand: null,
584
584
  visible: false,
585
+ navigated: false,
585
586
  dismissedForLine: null,
586
587
  };
587
588
 
@@ -596,8 +597,23 @@ function attachSlashPalette(rl, opts = {}) {
596
597
  return rows().length > 0 && state.dismissedForLine !== (rl.line || "");
597
598
  }
598
599
  function replaceLine(value) {
600
+ const next = String(value || "");
601
+ /*
602
+ * keypress 리스너 안에서 rl.write(Ctrl-U) → rl.write(text)를 재진입시키면
603
+ * Node readline의 원래 Tab/Enter 핸들러가 아직 같은 키를 처리하는 중이라
604
+ * 기존 `/s` 뒤에 선택값을 붙였다(`/s/team`, `/s/skills` 실측). line/cursor는
605
+ * readline의 공개 관측 상태이고 `_refreshLine`은 그 상태를 그리는 유일한
606
+ * 부수효과라, 한 번에 교체해 재진입을 피한다. 구형 Node만 기존 키 시퀀스로
607
+ * 폴백한다.
608
+ */
609
+ if (typeof rl._refreshLine === "function") {
610
+ rl.line = next;
611
+ rl.cursor = next.length;
612
+ rl._refreshLine();
613
+ return;
614
+ }
599
615
  rl.write(null, { ctrl: true, name: "u" });
600
- rl.write(value);
616
+ rl.write(next);
601
617
  }
602
618
  /*
603
619
  * 커서 복원은 상대 이동으로만 한다.
@@ -644,8 +660,19 @@ function attachSlashPalette(rl, opts = {}) {
644
660
  });
645
661
  if (!body) { clear(); return; }
646
662
  const lines = body.split("\n");
647
- // 첫 줄바꿈은 프롬프트 아래로 내려가며, 자리가 없으면 여기서 화면이 한 번 밀린다.
648
- // 그린 만큼 그대로 되올라오므로 이후 갱신은 제자리에서 일어난다.
663
+ /*
664
+ * 줄바꿈은 프롬프트 아래로 내려가며, 자리가 없으면 여기서 화면이 한 번 밀린다.
665
+ * 그린 만큼 그대로 되올라오므로 이후 갱신은 제자리에서 일어난다.
666
+ *
667
+ * 복귀 후에는 커서 열만 맞춘다. readline 의 prompt(true) 로 프롬프트 줄을 다시
668
+ * 그리면 refreshLine 이 커서 아래를 지워 방금 그린 프레임까지 함께 날아간다
669
+ * (실측: 리사이즈 없이도 팔레트가 통째로 사라짐). 프롬프트 줄 자체는 지운 적이
670
+ * 없으므로 평상시에는 화면에 그대로 남아 있다.
671
+ *
672
+ * 남은 한계: 창 크기를 줄이면 터미널이 프롬프트 줄을 리플로우하며 지울 수 있고,
673
+ * 그때는 다음 입력 전까지 프롬프트가 보이지 않는다(40행→18행 축소에서 실측).
674
+ * 팔레트는 리사이즈를 구독하지 않는다.
675
+ */
649
676
  stream.write(`\r\n\x1b[0J${lines.join("\r\n")}\x1b[${lines.length}A\x1b[${promptColumn()}G`);
650
677
  state.visible = true;
651
678
  }
@@ -667,6 +694,7 @@ function attachSlashPalette(rl, opts = {}) {
667
694
  if (!list.length) return false;
668
695
  state.selected = (state.selected + delta + list.length) % list.length;
669
696
  state.selectedCommand = list[state.selected].command;
697
+ state.navigated = true;
670
698
  const query = rl.line || "";
671
699
  setImmediate(() => {
672
700
  if ((rl.line || "") !== query) replaceLine(query);
@@ -695,12 +723,38 @@ function attachSlashPalette(rl, opts = {}) {
695
723
  move(name === "down" ? 1 : -1);
696
724
  return;
697
725
  }
698
- // Shift-Tab 은 팔레트 확정 키가 아니다 호출자(REPL)의 권한 순환 단축키다.
699
- // 여기서 select() 하면 번의 Shift-Tab 내용까지 바꿔 버린다.
700
- if (active() && ((name === "tab" && !key.shift) || name === "return")) {
701
- select();
726
+ // Tab 은 완성이다강조된 항목으로 줄을 채운다.
727
+ // Shift-Tab 팔레트 확정 키가 아니다(호출자 REPL 권한 순환 단축키다).
728
+ if (active() && name === "tab" && !key.shift) {
729
+ if (select()) {
730
+ /*
731
+ * prependListener는 readline 자체 Tab 처리를 중단시키지 못한다. 선택을 먼저
732
+ * 반영한 뒤, 기본 완성기가 같은 키로 줄을 다시 바꿔도 다음 tick에 exact
733
+ * 선택을 한 번 재적용한다.
734
+ */
735
+ const selected = state.selectedCommand;
736
+ setImmediate(() => {
737
+ if (selected) replaceLine(selected);
738
+ });
739
+ }
740
+ return;
741
+ }
742
+ /*
743
+ * Enter 는 사용자가 실제로 목록을 훑었을 때만 강조 항목을 확정한다.
744
+ *
745
+ * 예전에는 팔레트가 떠 있기만 하면 Enter 가 무조건 select() 를 불렀고,
746
+ * 훑은 적이 없으면 state.selected 는 0이라 "목록 첫 줄"이 대신 실행됐다.
747
+ * `/s`(활성 세션 전환)를 치고 Enter 하면 `/sessions` 가 돌아간다 — 친 것과
748
+ * 다른 명령이 실행되는 것이다(pty 실측). 게다가 피해 대상은 팔레트 정렬의
749
+ * 함수라, 명령 목록을 손볼 때마다 어떤 명령이 가로채이는지가 조용히 바뀐다.
750
+ */
751
+ if (active() && name === "return") {
752
+ if (state.navigated) select();
753
+ else clear();
702
754
  return;
703
755
  }
756
+ // 타이핑이 이어지면 "훑었다"는 사실은 무효가 된다 — 질의가 달라졌기 때문.
757
+ state.navigated = false;
704
758
  state.dismissedForLine = null;
705
759
  setImmediate(render);
706
760
  }
@@ -163,7 +163,17 @@ function tableExists(db, name) {
163
163
  }
164
164
  }
165
165
 
166
+ // 커넥션당 1회. 이전에는 `beginTurn`/`listScopedTimeline` 이 부를 때마다, 즉 **매 턴**
167
+ // 공유 DB 에 테이블 4개 + 인덱스 5개 DDL 과 `UPDATE … stale` 을 날렸다. 전부
168
+ // IF NOT EXISTS 라 결과는 멱등이지만 쓰기 락을 잡는 것은 매번이었고, 데스크탑
169
+ // 마이그레이션과 겹치면 15초 busy_timeout 을 소진했다(2026-07-28).
170
+ const { ensureOnce } = require("./core/schema-ensure.cjs");
171
+
166
172
  function ensureGovernanceSchema(db) {
173
+ return ensureOnce(db, "terminal_memory.schema", (conn) => ensureGovernanceSchemaOnce(conn));
174
+ }
175
+
176
+ function ensureGovernanceSchemaOnce(db) {
167
177
  db.exec(`
168
178
  CREATE TABLE IF NOT EXISTS terminal_memory_turn_intents (
169
179
  turn_id TEXT PRIMARY KEY,
@@ -7,8 +7,13 @@ const i18n = require("./agentlas-i18n.cjs");
7
7
  const banner = require("./agentlas-banner.cjs");
8
8
  const { visWidth, wrapWidth } = require("./agentlas-composer.cjs");
9
9
 
10
- // req = { ui, rl, helpers } → Promise<{ onboarded, lang, runtime, permission }>
11
- async function runOnboard({ ui, rl, helpers }) {
10
+ /*
11
+ * req = { ui, rl, helpers, persist } → Promise<{ onboarded, saved, saveError, lang, runtime, permission }>
12
+ * `persist` writes the answers and must throw when it cannot. The wizard calls it
13
+ * itself, before announcing "All set." — the caller used to save afterwards, so a
14
+ * failed write (locked/read-only data dir) was announced as success and dropped.
15
+ */
16
+ async function runOnboard({ ui, rl, helpers, persist }) {
12
17
  const H = helpers;
13
18
  const c = ui.c;
14
19
  const contentWidth = () => Math.max(20, (ui.out.columns || 80) - 5);
@@ -149,10 +154,22 @@ async function runOnboard({ ui, rl, helpers }) {
149
154
  const pi = await pickNum(permOpts.length);
150
155
  const permission = permOpts[pi - 1].v;
151
156
 
157
+ // Save first, then report what actually happened — never the other way round.
158
+ let saveError = null;
159
+ try {
160
+ if (typeof persist !== "function") throw new Error("no persistence handler");
161
+ await persist({ lang, runtime, permission });
162
+ } catch (error) {
163
+ saveError = error;
164
+ }
152
165
  ui.line("");
153
- printSaved(ui.t("wiz.saved"));
154
- printIndented(ui.t("wiz.changeLang"), c.faint);
155
- return { onboarded: true, lang, runtime, permission };
166
+ if (saveError) {
167
+ ui.error(ui.t("wiz.saveFailed", String((saveError && saveError.message) || saveError)));
168
+ } else {
169
+ printSaved(ui.t("wiz.saved"));
170
+ printIndented(ui.t("wiz.changeLang"), c.faint);
171
+ }
172
+ return { onboarded: true, saved: !saveError, saveError, lang, runtime, permission };
156
173
  } finally {
157
174
  rl.removeListener("line", onLine);
158
175
  if (ownsSigint) rl.removeListener("SIGINT", onSigint);
@@ -6,15 +6,26 @@
6
6
  // fail immediately with SQLITE_BUSY even when busy_timeout is configured.
7
7
  const SQLITE_BUSY_TIMEOUT_MS = 15_000;
8
8
 
9
+ // `foreign_keys` 는 파일이 아니라 **커넥션** 속성이다. 데스크탑은
10
+ // `electron/store/db.ts` 에서 `foreign_keys = ON` 으로 열고, 터미널은 켜지 않아
11
+ // **같은 테이블인데 터미널이 쓰는 행만 참조 무결성 검사를 건너뛰고 있었다**
12
+ // (2026-07-28 확인). journal_mode 처럼 파일에 박히는 값이 아니라서 한쪽이 켠 것이
13
+ // 다른 쪽에 전파되지 않는다. 스키마 소유는 데스크탑이므로 그 규칙을 그대로 따른다.
14
+ const SQLITE_CONNECTION_PRAGMAS = [
15
+ `busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`,
16
+ "foreign_keys = ON",
17
+ ];
18
+
9
19
  function configureSqliteConnection(db) {
10
20
  if (!db) throw new TypeError("SQLite connection is required");
11
- if (typeof db.pragma === "function") {
12
- db.pragma(`busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
13
- } else if (typeof db.exec === "function") {
14
- db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
15
- } else {
16
- throw new TypeError("SQLite connection must expose pragma() or exec()");
17
- }
21
+ const applyPragma =
22
+ typeof db.pragma === "function"
23
+ ? (statement) => db.pragma(statement)
24
+ : typeof db.exec === "function"
25
+ ? (statement) => db.exec(`PRAGMA ${statement}`)
26
+ : null;
27
+ if (!applyPragma) throw new TypeError("SQLite connection must expose pragma() or exec()");
28
+ for (const statement of SQLITE_CONNECTION_PRAGMAS) applyPragma(statement);
18
29
  return db;
19
30
  }
20
31