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
@@ -341,15 +341,16 @@ function activeProjectPath(db, options = {}) {
341
341
  return result.activated ? root : null;
342
342
  }
343
343
 
344
- // v1 시그니처 보존: permission/reason은 받되 절대 초기화 권한으로 쓰지 않는다.
345
344
  function ensureTerminalProjectForExecutionCli(db, projectPath, permission = "write", reason = "terminal-first-contact") {
346
345
  const root = terminalProjectCandidateCli(projectPath);
347
346
  if (!root) return null;
348
- // Every ordinary execution mode is passive. Only `agentlas project init`
349
- // may create .agentlas state or edit .gitignore; write/full permission is
350
- // authority for the requested task, not consent to initialize the folder.
351
- void permission;
352
- void reason;
347
+ // A real write-capable execution is the product's first-contact boundary.
348
+ // Install the same private, merge-only Core infrastructure in whichever
349
+ // project the user opened; never key this behavior to the Agentlas_F repo.
350
+ if (permission === "write" || permission === "full") {
351
+ return initializeTerminalProjectCli(db, root, reason);
352
+ }
353
+ // Read-only inspection remains passive and never creates files.
353
354
  const active = activeProjectPath(db, { projectPath: root });
354
355
  if (!active) return null;
355
356
  return initializedAgentlasProjectPathCli(root);
@@ -13,7 +13,13 @@
13
13
  */
14
14
  const { tableExists, columnExists } = require("../core/db.cjs");
15
15
  const { RUNTIME_BIN, whichSync } = require("./detect.cjs");
16
- const { resolveRuntime, EXECUTABLE_KINDS } = require("./resolve.cjs");
16
+ const {
17
+ resolveRuntime,
18
+ EXECUTABLE_KINDS,
19
+ CLI_EXECUTABLE_KINDS,
20
+ API_EXECUTABLE_KINDS,
21
+ } = require("./resolve.cjs");
22
+ const { pickRoleFromPool } = require("./roles.cjs");
17
23
 
18
24
  const OVERRIDE_TABLE = "agent_runtime_overrides";
19
25
  // 데스크탑 VALID_SCOPES 동형. v2 터미널 호출자는 주로 'agent'지만 firm/division도 읽을 수 있다.
@@ -78,7 +84,7 @@ function findRuntimeOverride(db, targets) {
78
84
 
79
85
  /**
80
86
  * 오버라이드를 얹은 런타임 해석.
81
- * @param {object} p { db, prefs, explicit, agentId, targets?, deps? }
87
+ * @param {object} p { db, prefs, explicit, model?, effort?, role?, agentId, targets?, deps? }
82
88
  * targets: [{scope,targetId}] — 주면 agentId 대신 이 우선순위로 오버라이드를 찾는다
83
89
  * (firm 경로: agent > division > firm).
84
90
  * deps: 테스트 주입 { which, resolve } — 상용 호출자는 사용하지 않는다.
@@ -86,10 +92,48 @@ function findRuntimeOverride(db, targets) {
86
92
  * { ..., unavailableOverride } (오버라이드가 있으나 이 머신/v2에서 실행 불가 —
87
93
  * 조용히 무시하지 않고 사유를 실어 보내며, 호출자가 note를 출력해야 한다)
88
94
  */
89
- function resolveRuntimeForAgent({ db, prefs, explicit, agentId, targets, deps } = {}) {
95
+ function resolveRuntimeForAgent({
96
+ db,
97
+ prefs,
98
+ explicit,
99
+ model,
100
+ effort,
101
+ role = "orchestrator",
102
+ agentId,
103
+ targets,
104
+ deps,
105
+ } = {}) {
90
106
  const resolveImpl = (deps && deps.resolve) || resolveRuntime;
107
+ const withPins = (runtime) => ({
108
+ ...runtime,
109
+ ...(cleanText(model) ? { model: cleanText(model) } : {}),
110
+ ...(cleanText(effort) ? { effort: cleanText(effort) } : {}),
111
+ });
112
+ const selectedRuntime = (selection, source) => {
113
+ const kind = selection && selection.kind;
114
+ if (API_EXECUTABLE_KINDS.has(kind)) {
115
+ return {
116
+ kind,
117
+ backend: selection.backend || kind,
118
+ model: cleanText(model) || cleanText(selection.model) || undefined,
119
+ effort: cleanText(effort) || cleanText(selection.effort) || undefined,
120
+ source,
121
+ };
122
+ }
123
+ if (!CLI_EXECUTABLE_KINDS.has(kind)) return null;
124
+ const which = (deps && deps.which) || whichSync;
125
+ const bin = which(RUNTIME_BIN[kind]);
126
+ if (!bin) return null;
127
+ return {
128
+ kind,
129
+ bin,
130
+ model: cleanText(model) || cleanText(selection.model) || undefined,
131
+ effort: cleanText(effort) || cleanText(selection.effort) || undefined,
132
+ source,
133
+ };
134
+ };
91
135
  // 명시(--runtime)가 항상 이긴다 — 오버라이드는 "사용자가 고르지 않았을 때"의 기본값이다.
92
- if (explicit) return resolveImpl({ db, prefs, explicit });
136
+ if (explicit) return withPins(resolveImpl({ db, prefs, explicit }));
93
137
 
94
138
  const override = targets && targets.length
95
139
  ? findRuntimeOverride(db, targets)
@@ -98,27 +142,43 @@ function resolveRuntimeForAgent({ db, prefs, explicit, agentId, targets, deps }
98
142
  : null;
99
143
  if (override) {
100
144
  const kind = override.selection.kind;
101
- // v2 스트리밍 드라이버가 있는 CLI 런타임만 실제 실행 대상이다. byok/ollama/kimi
102
- // 오버라이드는 데스크탑에서는 유효하지만 터미널 v2 실행 사다리에는 아직 없다 —
103
- // 조용히 다른 런타임으로 둔갑시키지 않고 unavailableOverride로 정직하게 알린다.
145
+ // CLI 스트리밍과 로컬 API(Ollama)는 같은 Session 경로를 쓴다. 아직 연결되지
146
+ // 않은 BYOK/kimi 등은 조용히 다른 런타임으로 둔갑시키지 않는다.
104
147
  if (EXECUTABLE_KINDS.has(kind)) {
105
- const which = (deps && deps.which) || whichSync;
106
- const bin = which(RUNTIME_BIN[kind]);
107
- if (bin) {
108
- return {
109
- kind,
110
- bin,
111
- model: override.selection.model,
112
- effort: override.selection.effort,
113
- source: "agent-override",
114
- override,
115
- };
116
- }
148
+ const selected = selectedRuntime(override.selection, "agent-override");
149
+ if (selected) return { ...selected, override, role };
150
+ }
151
+ const resolved = withPins(resolveImpl({ db, prefs, explicit: null }));
152
+ return { ...resolved, unavailableOverride: override, role };
153
+ }
154
+
155
+ // Role defaults sit above active_runtime/detected but below exact per-call
156
+ // pins and agent/firm/division overrides. v80 풀(순서=우선순위)이 있으면
157
+ // 이 Terminal에서 실행 가능한 첫 멤버를 쓰고, 스킵 내역은 결과에 남긴다.
158
+ // 풀이 없으면 pickRoleFromPool이 단일 행/레거시 해석으로 내려간다.
159
+ const roleSelection = pickRoleFromPool(db, role, (member) => {
160
+ if (!EXECUTABLE_KINDS.has(member.kind)) return false;
161
+ return Boolean(selectedRuntime(member, member.sourceLayer));
162
+ });
163
+ if (roleSelection && EXECUTABLE_KINDS.has(roleSelection.kind)) {
164
+ const selected = selectedRuntime(roleSelection, roleSelection.sourceLayer);
165
+ if (selected) {
166
+ return {
167
+ ...selected,
168
+ role,
169
+ inheritedRole: roleSelection.inherit,
170
+ ...(roleSelection.skipped?.length
171
+ ? { rolePoolSkipped: roleSelection.skipped }
172
+ : {}),
173
+ };
117
174
  }
118
- const resolved = resolveImpl({ db, prefs, explicit: null });
119
- return { ...resolved, unavailableOverride: override };
120
175
  }
121
- return resolveImpl({ db, prefs, explicit: null });
176
+ const resolved = withPins(resolveImpl({ db, prefs, explicit: null }));
177
+ return {
178
+ ...resolved,
179
+ role,
180
+ ...(roleSelection ? { unavailableRoleSelection: roleSelection } : {}),
181
+ };
122
182
  }
123
183
 
124
184
  /** 오버라이드 실행 불가 시 사용자에게 출력할 한 줄 (데스크탑 문구 동형). */
@@ -130,10 +190,19 @@ function unavailableOverrideNote(runtime, lang) {
130
190
  : `Assigned runtime (${kind}) is unavailable here — using the default runtime (${runtime.kind}).`;
131
191
  }
132
192
 
193
+ function unavailableRoleNote(runtime, lang) {
194
+ if (!runtime || !runtime.unavailableRoleSelection) return "";
195
+ const selected = runtime.unavailableRoleSelection;
196
+ return lang === "ko"
197
+ ? `${selected.role} 기본 런타임(${selected.kind})을 이 Terminal에서 실행할 수 없어 ${runtime.kind}으로 실행합니다.`
198
+ : `${selected.role} default runtime (${selected.kind}) is unavailable in this Terminal — using ${runtime.kind}.`;
199
+ }
200
+
133
201
  module.exports = {
134
202
  readAgentRuntimeOverride,
135
203
  readRuntimeOverride,
136
204
  findRuntimeOverride,
137
205
  resolveRuntimeForAgent,
138
206
  unavailableOverrideNote,
207
+ unavailableRoleNote,
139
208
  };
@@ -7,9 +7,25 @@
7
7
  */
8
8
  const { RUNTIME_BIN, whichSync, listAvailableCliRuntimes, activeRuntimeRow } = require("./detect.cjs");
9
9
 
10
- // native-host가 스트리밍 드라이버를 갖춘 런타임만 실행 대상으로 삼는다.
11
- // kimi/grok/cursor 드라이버가 포팅되면 여기에 추가한다 (조용한 오폭 방지).
12
- const EXECUTABLE_KINDS = new Set(["claude-code", "codex", "gemini"]);
10
+ // Session이 실제 드라이버를 갖춘 런타임만 실행 대상으로 삼는다.
11
+ // CLI는 native-host, Ollama는 로컬 API loop를 쓴다. 다른 드라이버가 포팅되면
12
+ // 해당 집합에 추가한다(조용한 오폭 방지).
13
+ const CLI_EXECUTABLE_KINDS = new Set(["claude-code", "codex", "gemini"]);
14
+ const API_EXECUTABLE_KINDS = new Set(["ollama"]);
15
+ const EXECUTABLE_KINDS = new Set([
16
+ ...CLI_EXECUTABLE_KINDS,
17
+ ...API_EXECUTABLE_KINDS,
18
+ ]);
19
+
20
+ function apiRuntime(kind, model, source) {
21
+ if (!API_EXECUTABLE_KINDS.has(kind)) return null;
22
+ return {
23
+ kind,
24
+ backend: kind,
25
+ ...(model ? { model } : {}),
26
+ source,
27
+ };
28
+ }
13
29
 
14
30
  class NoRuntimeError extends Error {
15
31
  constructor(message) {
@@ -24,9 +40,11 @@ class NoRuntimeError extends Error {
24
40
  */
25
41
  function resolveRuntime({ db, prefs, explicit }) {
26
42
  if (explicit) {
43
+ const api = apiRuntime(explicit, null, "explicit");
44
+ if (api) return api;
27
45
  const bin = RUNTIME_BIN[explicit];
28
46
  if (!bin) throw new NoRuntimeError(`unknown runtime: ${explicit}`);
29
- if (!EXECUTABLE_KINDS.has(explicit)) {
47
+ if (!CLI_EXECUTABLE_KINDS.has(explicit)) {
30
48
  throw new NoRuntimeError(`runtime '${explicit}' has no v2 streaming driver yet (available: ${[...EXECUTABLE_KINDS].join(", ")})`);
31
49
  }
32
50
  const p = whichSync(bin);
@@ -34,18 +52,24 @@ function resolveRuntime({ db, prefs, explicit }) {
34
52
  return { kind: explicit, bin: p, source: "explicit" };
35
53
  }
36
54
  const pref = prefs && prefs.runtime;
37
- if (pref && EXECUTABLE_KINDS.has(pref)) {
55
+ if (pref && API_EXECUTABLE_KINDS.has(pref)) {
56
+ return apiRuntime(pref, null, "prefs");
57
+ }
58
+ if (pref && CLI_EXECUTABLE_KINDS.has(pref)) {
38
59
  const p = whichSync(RUNTIME_BIN[pref]);
39
60
  if (p) return { kind: pref, bin: p, source: "prefs" };
40
61
  }
41
62
  if (db) {
42
63
  const active = activeRuntimeRow(db);
43
- if (active && EXECUTABLE_KINDS.has(active.kind)) {
64
+ if (active && API_EXECUTABLE_KINDS.has(active.kind)) {
65
+ return apiRuntime(active.kind, active.model || undefined, "active");
66
+ }
67
+ if (active && CLI_EXECUTABLE_KINDS.has(active.kind)) {
44
68
  const p = whichSync(RUNTIME_BIN[active.kind]);
45
69
  if (p) return { kind: active.kind, bin: p, model: active.model || undefined, source: "active" };
46
70
  }
47
71
  }
48
- const found = listAvailableCliRuntimes().filter((r) => EXECUTABLE_KINDS.has(r.kind));
72
+ const found = listAvailableCliRuntimes().filter((r) => CLI_EXECUTABLE_KINDS.has(r.kind));
49
73
  if (found.length) return { kind: found[0].kind, bin: found[0].path, source: "detected" };
50
74
  // 신규 사용자의 최빈 막다른 길: "설치하라"만 있고 방법이 없으면 여기서 이탈한다.
51
75
  // 실제 설치 명령을 그대로 준다(데스크탑 온보딩의 "Claude Code 무료로 설치하기"와 동형).
@@ -61,4 +85,10 @@ function resolveRuntime({ db, prefs, explicit }) {
61
85
  ].join("\n"));
62
86
  }
63
87
 
64
- module.exports = { resolveRuntime, NoRuntimeError, EXECUTABLE_KINDS };
88
+ module.exports = {
89
+ resolveRuntime,
90
+ NoRuntimeError,
91
+ EXECUTABLE_KINDS,
92
+ CLI_EXECUTABLE_KINDS,
93
+ API_EXECUTABLE_KINDS,
94
+ };
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ /*
3
+ * Shared Desktop/Terminal model-role reader.
4
+ *
5
+ * Persistence is owned by Desktop migration v79. Terminal reads defensively:
6
+ * orchestrator row -> legacy active_runtime -> null
7
+ * worker direct row -> orchestrator row/legacy active_runtime
8
+ *
9
+ * The worker may inherit upward for quality; the orchestrator never falls
10
+ * downward to the worker row.
11
+ */
12
+ const { tableExists, columnExists } = require("../core/db.cjs");
13
+
14
+ const MODEL_ROLE_TABLE = "model_roles";
15
+ const VALID_ROLES = new Set(["orchestrator", "worker"]);
16
+
17
+ function cleanText(value) {
18
+ const trimmed = typeof value === "string" ? value.trim() : "";
19
+ return trimmed || null;
20
+ }
21
+
22
+ function roleRow(db, role) {
23
+ if (!db || !VALID_ROLES.has(role) || !tableExists(db, MODEL_ROLE_TABLE)) return null;
24
+ for (const column of ["role", "kind", "inherit"]) {
25
+ if (!columnExists(db, MODEL_ROLE_TABLE, column)) return null;
26
+ }
27
+ try {
28
+ return db.prepare("SELECT * FROM model_roles WHERE role=?").get(role) || null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function legacyOrchestrator(db) {
35
+ if (!db || !tableExists(db, "active_runtime")) return null;
36
+ try {
37
+ const row = db
38
+ .prepare(
39
+ "SELECT kind, backend, source, model, long_context FROM active_runtime WHERE id=1",
40
+ )
41
+ .get();
42
+ if (!row || !cleanText(row.kind)) return null;
43
+ let effort = null;
44
+ if (tableExists(db, "meta")) {
45
+ effort = cleanText(
46
+ db.prepare("SELECT value FROM meta WHERE key='claude_effort'").get()
47
+ ?.value,
48
+ );
49
+ }
50
+ return {
51
+ role: "orchestrator",
52
+ kind: cleanText(row.kind),
53
+ backend: cleanText(row.backend),
54
+ source: cleanText(row.source),
55
+ model: cleanText(row.model),
56
+ effort,
57
+ longContext: Boolean(row.long_context),
58
+ inherit: false,
59
+ updatedAt: null,
60
+ sourceLayer: "active-runtime",
61
+ };
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function normalizedRow(row, role) {
68
+ if (!row || !cleanText(row.kind)) return null;
69
+ return {
70
+ role,
71
+ kind: cleanText(row.kind),
72
+ backend: cleanText(row.backend),
73
+ source: cleanText(row.source),
74
+ model: cleanText(row.model),
75
+ effort: cleanText(row.effort),
76
+ longContext: Boolean(row.long_context),
77
+ inherit: role === "worker" && Boolean(row.inherit),
78
+ updatedAt: cleanText(row.updated_at),
79
+ sourceLayer: "model-role",
80
+ };
81
+ }
82
+
83
+ const MODEL_ROLE_MEMBER_TABLE = "model_role_members";
84
+
85
+ /** Desktop v80 역할 풀(순서=우선순위). 테이블이 없거나 비면 []. */
86
+ function roleMembers(db, role) {
87
+ if (!db || !VALID_ROLES.has(role) || !tableExists(db, MODEL_ROLE_MEMBER_TABLE)) return [];
88
+ for (const column of ["role", "position", "kind"]) {
89
+ if (!columnExists(db, MODEL_ROLE_MEMBER_TABLE, column)) return [];
90
+ }
91
+ try {
92
+ return db
93
+ .prepare("SELECT * FROM model_role_members WHERE role=? ORDER BY position ASC")
94
+ .all(role)
95
+ .map((row) => ({
96
+ ...normalizedRow({ ...row, inherit: 0 }, role),
97
+ position: row.position,
98
+ sourceLayer: "model-role-pool",
99
+ }))
100
+ .filter((row) => row && row.kind);
101
+ } catch {
102
+ return [];
103
+ }
104
+ }
105
+
106
+ /**
107
+ * 풀에서 첫 가용 멤버를 고른다. isAvailable(member)가 없으면 순서 1위.
108
+ * 전원 불가면 조용한 대체 없이 1위를 그대로 쓰고 skipped를 남긴다.
109
+ * worker 풀이 비면 오케스트레이터 풀을 상속하며, 풀 자체가 없으면
110
+ * 기존 단일 행 해석(resolvedModelRole)으로 내려간다.
111
+ */
112
+ function pickRoleFromPool(db, role = "orchestrator", isAvailable = null) {
113
+ if (!VALID_ROLES.has(role)) throw new TypeError(`unknown model role: ${role}`);
114
+ const own = roleMembers(db, role);
115
+ const inherited = role === "worker" && own.length === 0;
116
+ const members = inherited ? roleMembers(db, "orchestrator") : own;
117
+ const skipped = [];
118
+ for (const member of members) {
119
+ if (typeof isAvailable === "function" && !isAvailable(member)) {
120
+ skipped.push({ position: member.position, kind: member.kind, reason: "runtime-unavailable" });
121
+ continue;
122
+ }
123
+ return { ...member, role, inherit: inherited, skipped };
124
+ }
125
+ if (members.length > 0) {
126
+ return { ...members[0], role, inherit: inherited, skipped };
127
+ }
128
+ const single = resolvedModelRole(db, role);
129
+ return single ? { ...single, position: null, skipped } : null;
130
+ }
131
+
132
+ function resolvedModelRole(db, role = "orchestrator") {
133
+ if (!VALID_ROLES.has(role)) throw new TypeError(`unknown model role: ${role}`);
134
+ if (role === "orchestrator") {
135
+ return normalizedRow(roleRow(db, "orchestrator"), role) || legacyOrchestrator(db);
136
+ }
137
+ const worker = normalizedRow(roleRow(db, "worker"), "worker");
138
+ if (worker && !worker.inherit) return worker;
139
+ const orchestrator = resolvedModelRole(db, "orchestrator");
140
+ if (!orchestrator) return null;
141
+ return {
142
+ ...orchestrator,
143
+ role: "worker",
144
+ inherit: true,
145
+ updatedAt: worker?.updatedAt || orchestrator.updatedAt,
146
+ sourceLayer:
147
+ worker?.sourceLayer === "model-role"
148
+ ? "model-role-inherit"
149
+ : "active-runtime-inherit",
150
+ };
151
+ }
152
+
153
+ module.exports = {
154
+ MODEL_ROLE_TABLE,
155
+ MODEL_ROLE_MEMBER_TABLE,
156
+ VALID_ROLES,
157
+ roleRow,
158
+ roleMembers,
159
+ pickRoleFromPool,
160
+ legacyOrchestrator,
161
+ resolvedModelRole,
162
+ };
@@ -43,7 +43,7 @@ class Orchestrator extends EventEmitter {
43
43
  * 세션 생성(+선택적 즉시 실행). parentKey를 주면 그 세션의 서브에이전트
44
44
  * (division 챗)로 붙는다.
45
45
  */
46
- spawn({ agent, runtime, permission, cwd, title, parentKey, activate = true, spawnImpl, timeoutConfig, chatId }) {
46
+ spawn({ agent, runtime, permission, cwd, title, parentKey, activate = true, spawnImpl, apiTurnImpl, timeoutConfig, chatId }) {
47
47
  const parent = parentKey ? this.sessions.get(parentKey) || null : null;
48
48
  const session = new Session({
49
49
  db: this.db,
@@ -55,6 +55,7 @@ class Orchestrator extends EventEmitter {
55
55
  parent,
56
56
  title,
57
57
  spawnImpl,
58
+ apiTurnImpl,
58
59
  timeoutConfig,
59
60
  chatId,
60
61
  });
@@ -116,6 +117,27 @@ class Orchestrator extends EventEmitter {
116
117
  if (!session) return;
117
118
  if (session.isBusy()) session.kill();
118
119
  this.sessions.delete(key);
120
+
121
+ // 부모를 지워도 자식 세션은 살아 있다(실행 중이며 병렬 슬롯을 쥔 채일 수 있다).
122
+ // 자식의 parent 포인터가 지워진 세션을 계속 가리키면 list()의 루트 판정
123
+ // (!s.parent)에서 탈락하고, 어떤 루트에서도 닿지 않아 /sessions·/tree 에서
124
+ // 통째로 사라진다 — 실행 중인데 보이지도 끌 수도 없는 유령 세션이 된다.
125
+ // 그래서 세션이 맵을 떠날 때 트리를 함께 정리한다: 남은 자식은 살아 있는
126
+ // 조부모로 승계하고(없으면 루트로 승격), 지워진 키는 부모 목록에서 뗀다.
127
+ const grandparent = session.parent && this.sessions.has(session.parent.key) ? session.parent : null;
128
+ for (const childKey of session.children) {
129
+ const child = this.sessions.get(childKey);
130
+ if (!child) continue;
131
+ child.parent = grandparent;
132
+ if (grandparent && !grandparent.children.includes(childKey)) grandparent.children.push(childKey);
133
+ }
134
+ session.children = [];
135
+ if (session.parent) {
136
+ const siblings = session.parent.children;
137
+ const at = siblings.indexOf(key);
138
+ if (at >= 0) siblings.splice(at, 1);
139
+ }
140
+
119
141
  if (this.activeKey === key) {
120
142
  const rest = [...this.sessions.keys()];
121
143
  this.activeKey = rest.length ? rest[rest.length - 1] : null;
@@ -123,14 +145,31 @@ class Orchestrator extends EventEmitter {
123
145
  this.emit("sessions-changed");
124
146
  }
125
147
 
148
+ /**
149
+ * 전 세션 브로드캐스트 — {sent, skipped}를 반환한다(throw 하지 않는다).
150
+ *
151
+ * WHY: 예전엔 루프 안에서 sendTo()의 throw가 그대로 올라갔다. 동시 상한(기본 4)보다
152
+ * 유휴 세션이 많으면 앞의 몇 개는 이미 프롬프트를 받아 실행을 시작한 뒤 상한 세션에서
153
+ * 터졌고, 그때까지 모은 sent 배열은 스택과 함께 버려졌다. 호출자(REPL)는 상한 에러
154
+ * 한 줄만 찍어 "전부 실패"로 보고했지만 실제로는 4개 세션이 그 지시를 받아 토큰을
155
+ * 쓰고 쓰기 작업까지 할 수 있는 상태였다 — 부분 성공을 전면 실패로 오보하는 것은
156
+ * 브로드캐스트에서 가장 위험한 거짓말이다.
157
+ * 그래서 실패는 세션 단위로 모으고, 실제 전달된 목록은 무슨 일이 있어도 반환한다.
158
+ * (sendTo/spawn의 "상한 초과는 정직한 거부" 계약 자체는 그대로 둔다.)
159
+ */
126
160
  broadcast(prompt) {
127
161
  const sent = [];
162
+ const skipped = [];
128
163
  for (const [key, session] of this.sessions) {
129
164
  if (session.status === "killed") continue;
130
- this.sendTo(key, prompt);
131
- sent.push(key);
165
+ try {
166
+ this.sendTo(key, prompt);
167
+ sent.push(key);
168
+ } catch (e) {
169
+ skipped.push({ key, error: String((e && e.message) || e) });
170
+ }
132
171
  }
133
- return sent;
172
+ return { sent, skipped };
134
173
  }
135
174
 
136
175
  /** 세션 표: [{key, active, agent, status, elapsed, lastLine, parentKey, depth}] */
@@ -88,13 +88,10 @@ function contextLine(json) {
88
88
  }
89
89
  }
90
90
 
91
- function ensureMemoryContextColumn(db) {
92
- try {
93
- if (tableExists(db, "memory_entries") && !columnExists(db, "memory_entries", "context_json")) {
94
- db.exec("ALTER TABLE memory_entries ADD COLUMN context_json TEXT NOT NULL DEFAULT '{}'");
95
- }
96
- } catch { /* 앱 마이그레이션 중이면 다음 턴에 */ }
97
- }
91
+ // 사본 3벌을 하나로 합치고 커넥션당 1회로 줄였다. 이 파일의 호출부는 **매 턴** 도는
92
+ // 자리라(cliMemoryContext), 여기가 공유 DB 에 쓰기 락을 가장 자주 잡던 지점이었다.
93
+ // 실패는 여전히 다음 호출에서 재시도된다 — 실패를 "완료" 기억하지 않는다.
94
+ const { ensureMemoryContextColumn } = require("../core/schema-ensure.cjs");
98
95
 
99
96
  /** Context Map 슬라이스 — Core 부재 시 정직하게 빈 문자열 (조작된 지도 금지). */
100
97
  function cliProjectContextSlice(projectPath, task) {
@@ -21,7 +21,7 @@ const RING_LIMIT = 2000;
21
21
  class Session extends EventEmitter {
22
22
  /**
23
23
  * @param {object} opts
24
- * db, agent {id,slug,name,systemPrompt}, runtime {kind,bin,model?},
24
+ * db, agent {id,slug,name,systemPrompt}, runtime {kind,bin,model?,effort?},
25
25
  * permission, cwd, lang, parent (Session|null), title, chatId?(재개)
26
26
  */
27
27
  constructor(opts) {
@@ -46,7 +46,9 @@ class Session extends EventEmitter {
46
46
  this._turnPromise = null;
47
47
  // 계약 테스트용 spawn 주입(runNativeTurn의 req.spawn). 프로덕션 경로에선 null.
48
48
  this._spawnImpl = opts.spawnImpl || null;
49
+ this._apiTurnImpl = opts.apiTurnImpl || null;
49
50
  this._timeoutConfig = opts.timeoutConfig || null;
51
+ this._apiAbort = null;
50
52
 
51
53
  this.chatId = opts.chatId || store.createChat(this.db, {
52
54
  agentId: this.agent.id,
@@ -122,6 +124,25 @@ class Session extends EventEmitter {
122
124
  return result;
123
125
  }
124
126
 
127
+ /**
128
+ * 이미 동의된 시스템 MCP 서버 목록. `full` 권한이 아니면 빈 배열이다.
129
+ *
130
+ * 권한 확인을 여기서 한 번 더 하는 이유: native-host 도 `full` 게이트를 갖고
131
+ * 있지만, 권한이 낮은 턴에서 목록을 **읽는 것 자체를** 하지 않는 편이 낫다.
132
+ * 읽지 않은 값은 실수로 새 나갈 수 없다.
133
+ */
134
+ _consentedMcpServers() {
135
+ if (permissions.normalize(this.permission) !== "full") return [];
136
+ try {
137
+ const consent = require("../mcp/consent.cjs");
138
+ return consent.readConsentedSystemMcpServers(this.db, { env: process.env }) || [];
139
+ } catch {
140
+ // 동의 상태를 못 읽으면 MCP 없이 간다. 여기서 실패해 턴을 죽이면,
141
+ // MCP 는 부가 기능인데 대화 자체가 불가능해진다.
142
+ return [];
143
+ }
144
+ }
145
+
125
146
  async _runTurn(prompt) {
126
147
  this.status = "running";
127
148
  this.startedAt = Date.now();
@@ -154,26 +175,68 @@ class Session extends EventEmitter {
154
175
  }, true, prompt);
155
176
  } catch { /* 프롬프트 증강 실패는 턴을 막지 않는다 — 원 프롬프트로 진행 */ }
156
177
 
157
- const req = {
158
- kind: this.runtime.kind,
159
- bin: this.runtime.bin,
160
- ui: this._sink,
161
- cwd: this.cwd,
162
- prompt,
163
- systemPrompt,
164
- permission: this.permission,
165
- session: { ...this.runtimeSession },
166
- model: this.runtime.model,
167
- onSpawn: (child) => { this._child = child; },
168
- };
169
- if (this._spawnImpl) req.spawn = this._spawnImpl;
170
- if (this._timeoutConfig) req.timeoutConfig = this._timeoutConfig;
171
-
172
178
  let res;
173
- try {
174
- res = await nativeHost.runNativeTurn(req);
175
- } catch (e) {
176
- res = { text: "", session: req.session, error: (e && e.message) || String(e) };
179
+ if (this.runtime.kind === "ollama") {
180
+ const ctrl = new AbortController();
181
+ this._apiAbort = ctrl;
182
+ const apiTurn = this._apiTurnImpl || require("../agentlas-api-agent.cjs").runApiTurn;
183
+ try {
184
+ const history = store.chatHistory(this.db, this.chatId).map((row) => ({
185
+ role: row.role,
186
+ content: row.text,
187
+ }));
188
+ res = await apiTurn({
189
+ backend: "ollama",
190
+ model: this.runtime.model || "llama3.1",
191
+ system: systemPrompt,
192
+ messages: history,
193
+ ctx: {
194
+ cwd: this.cwd,
195
+ permission: this.permission,
196
+ env: process.env,
197
+ },
198
+ ui: this._sink,
199
+ signal: ctrl.signal,
200
+ });
201
+ } catch (e) {
202
+ res = { text: "", error: (e && e.message) || String(e) };
203
+ } finally {
204
+ this._apiAbort = null;
205
+ }
206
+ } else {
207
+ const req = {
208
+ kind: this.runtime.kind,
209
+ bin: this.runtime.bin,
210
+ ui: this._sink,
211
+ cwd: this.cwd,
212
+ prompt,
213
+ systemPrompt,
214
+ permission: this.permission,
215
+ session: { ...this.runtimeSession },
216
+ model: this.runtime.model,
217
+ effort: this.runtime.effort,
218
+ // 사용자가 이미 동의한 MCP 서버를 턴에 싣는다.
219
+ //
220
+ // 여기가 비어 있어서 챗 경로의 MCP 가 통째로 죽어 있었다(2026-07-28 확인:
221
+ // `mcpServers` 를 넘기는 호출자가 0곳). native-host 는 `full` 권한에서만
222
+ // 주입하도록 이미 게이팅돼 있었는데, 그 분기에 도달할 데이터를 아무도
223
+ // 채우지 않았다 — `agentlas mcp probe` 가 connected 를 찍어도 실제 턴에서는
224
+ // 그 서버를 쓸 수 없었다.
225
+ //
226
+ // 여기서 새로 묻지 않는다. `readConsentedSystemMcpServers` 는 이미 받아 둔
227
+ // 동의 영수증과 지문이 **지금도 일치하는** 서버만 돌려준다. 동의가 없으면
228
+ // 빈 배열이고, native-host 가 명시적 빈 격리로 간다. 턴 도중에 동의를 묻는
229
+ // 것은 사용자가 답할 수 없는 자리에서 묻는 것이라 하지 않는다.
230
+ mcpServers: this._consentedMcpServers(),
231
+ onSpawn: (child) => { this._child = child; },
232
+ };
233
+ if (this._spawnImpl) req.spawn = this._spawnImpl;
234
+ if (this._timeoutConfig) req.timeoutConfig = this._timeoutConfig;
235
+ try {
236
+ res = await nativeHost.runNativeTurn(req);
237
+ } catch (e) {
238
+ res = { text: "", session: req.session, error: (e && e.message) || String(e) };
239
+ }
177
240
  }
178
241
  this._child = null;
179
242
 
@@ -233,6 +296,11 @@ class Session extends EventEmitter {
233
296
  /** 실행 중 턴을 중단한다. 큐는 비운다. */
234
297
  kill() {
235
298
  this.queue.length = 0;
299
+ if (this._apiAbort) {
300
+ this.status = "killed";
301
+ try { this._apiAbort.abort(new Error("session killed")); } catch { /* already aborted */ }
302
+ return;
303
+ }
236
304
  if (this._child) {
237
305
  this.status = "killed";
238
306
  try { nativeHost.terminateNativeChild(this._child); } catch { /* already dead */ }