agentlas 0.4.0
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.
- package/LICENSE +201 -0
- package/README.md +107 -0
- package/bin/agentlas.cjs +176 -0
- package/engine/ENGINE_META.json +8 -0
- package/engine/agentlas-api-agent.cjs +451 -0
- package/engine/agentlas-banner.cjs +108 -0
- package/engine/agentlas-capabilities.cjs +72 -0
- package/engine/agentlas-cloud-runtime.cjs +199 -0
- package/engine/agentlas-composer.cjs +256 -0
- package/engine/agentlas-config.cjs +29 -0
- package/engine/agentlas-doctor.cjs +173 -0
- package/engine/agentlas-i18n.cjs +317 -0
- package/engine/agentlas-input.cjs +437 -0
- package/engine/agentlas-native-host.cjs +612 -0
- package/engine/agentlas-onboard.cjs +71 -0
- package/engine/agentlas-parity.cjs +994 -0
- package/engine/agentlas-repl.cjs +1258 -0
- package/engine/agentlas-style.cjs +100 -0
- package/engine/agentlas-tools.cjs +196 -0
- package/engine/agentlas-ui.cjs +266 -0
- package/engine/agentlas.cjs +7503 -0
- package/engine/architecture.data.json +139 -0
- package/engine/bootstrap-schema.sql +568 -0
- package/install.ps1 +26 -0
- package/install.sh +53 -0
- package/package.json +49 -0
- package/scripts/gen-bootstrap-schema.sh +23 -0
- package/test/smoke.sh +56 -0
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* agentlas-parity: 데스크탑 앱 전용이던 기능의 터미널 패리티 구현.
|
|
4
|
+
*
|
|
5
|
+
* storm — Hephaestus Stormbreaker(route --auto-run) 파이프라인 실행 (+연구 증거)
|
|
6
|
+
* swarm — emergent 에이전트 스웜 (블랙보드 + `## Spawn` 그래프 성장 + 종합)
|
|
7
|
+
* automation — 앱 스케줄러가 실행하는 자동화의 등록/목록/토글 (같은 SQLite)
|
|
8
|
+
* usage — 로컬 실행/자동화/세션 집계
|
|
9
|
+
* telegram — 텔레그램 바인딩 현황 (읽기 전용; 페어링은 앱 Connect)
|
|
10
|
+
* cloud search — 마켓플레이스 검색 (MCP marketplace.search_agents)
|
|
11
|
+
*
|
|
12
|
+
* agentlas.cjs 가 create(deps)로 주입한 헬퍼(captureRuntime/runApi/resolveRuntime 등)만
|
|
13
|
+
* 사용한다 — 이 파일은 DB 스키마와 프로세스 스폰 외에 자체 상태를 갖지 않는다.
|
|
14
|
+
*/
|
|
15
|
+
const os = require("node:os");
|
|
16
|
+
const path = require("node:path");
|
|
17
|
+
const fs = require("node:fs");
|
|
18
|
+
const crypto = require("node:crypto");
|
|
19
|
+
const { spawn } = require("node:child_process");
|
|
20
|
+
const { Ui } = require("./agentlas-ui.cjs");
|
|
21
|
+
|
|
22
|
+
// ── 스웜 상수 (앱 mcp/swarm-run.ts 와 동일한 안전 상한) ──
|
|
23
|
+
const SWARM_MAX_TASKS = 24;
|
|
24
|
+
const SWARM_SPAWN_PER_TURN = 12;
|
|
25
|
+
|
|
26
|
+
function create(deps) {
|
|
27
|
+
const D = deps;
|
|
28
|
+
|
|
29
|
+
function newUi(lang) {
|
|
30
|
+
return new Ui({ lang: lang || D.prefsLang() });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Hephaestus 런타임 해석 (설치 런처 우선, 앱 번들 폴백) ──
|
|
34
|
+
function hephaestusBin() {
|
|
35
|
+
const candidates = [
|
|
36
|
+
process.env.HEPHAESTUS_BIN,
|
|
37
|
+
path.join(os.homedir(), ".agentlas", "runtime", "current", "bin", "hephaestus"),
|
|
38
|
+
];
|
|
39
|
+
for (const c of candidates) {
|
|
40
|
+
try {
|
|
41
|
+
if (c && fs.existsSync(c)) {
|
|
42
|
+
fs.accessSync(c, fs.constants.X_OK);
|
|
43
|
+
return { kind: "bin", exec: c };
|
|
44
|
+
}
|
|
45
|
+
} catch { /* 다음 후보 */ }
|
|
46
|
+
}
|
|
47
|
+
// 앱 번들 (Resources/Hephaestus) — python3 로 bin/hephaestus 와 동일한 부트스트랩 실행
|
|
48
|
+
const roots = [];
|
|
49
|
+
if (process.resourcesPath) roots.push(path.join(process.resourcesPath, "Hephaestus"));
|
|
50
|
+
if (process.platform === "darwin") roots.push("/Applications/Agentlas.app/Contents/Resources/Hephaestus");
|
|
51
|
+
for (const root of roots) {
|
|
52
|
+
try {
|
|
53
|
+
if (fs.existsSync(path.join(root, "agentlas_cloud", "__main__.py"))) return { kind: "python", root };
|
|
54
|
+
} catch { /* 다음 후보 */ }
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const PY_BOOTSTRAP =
|
|
60
|
+
"import os, runpy, sys; " +
|
|
61
|
+
'cwd=os.getcwd(); root=os.environ["HEPHAESTUS_RUNTIME_ROOT"]; ' +
|
|
62
|
+
'sys.path=[p for p in sys.path if p not in ("", cwd, root)]; ' +
|
|
63
|
+
"sys.path.insert(0, root); " +
|
|
64
|
+
"sys.argv=sys.argv[1:]; " +
|
|
65
|
+
'runpy.run_module(sys.argv[0], run_name="__main__", alter_sys=True)';
|
|
66
|
+
|
|
67
|
+
function spawnHephaestus(args, opts) {
|
|
68
|
+
const found = hephaestusBin();
|
|
69
|
+
if (!found) return null;
|
|
70
|
+
if (found.kind === "bin") return spawn(found.exec, args, opts);
|
|
71
|
+
return spawn("python3", ["-c", PY_BOOTSTRAP, "agentlas_cloud", ...args], {
|
|
72
|
+
...opts,
|
|
73
|
+
env: { ...(opts && opts.env ? opts.env : process.env), HEPHAESTUS_RUNTIME_ROOT: found.root },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── storm — 앱 stormbreakerRun()과 동일: route <goal> --auto-run ──
|
|
78
|
+
// ctx: { ui?, cwd?, research?, background? }
|
|
79
|
+
async function stormRun(db, goal, ctx = {}) {
|
|
80
|
+
const ui = ctx.ui || newUi();
|
|
81
|
+
goal = String(goal || "").trim();
|
|
82
|
+
if (!goal) {
|
|
83
|
+
ui.warn("usage: storm <goal> [--research]");
|
|
84
|
+
return { ok: false };
|
|
85
|
+
}
|
|
86
|
+
if (goal.startsWith("-")) {
|
|
87
|
+
ui.error("goal은 '-'로 시작할 수 없습니다.");
|
|
88
|
+
return { ok: false };
|
|
89
|
+
}
|
|
90
|
+
if (!hephaestusBin()) {
|
|
91
|
+
ui.error("Hephaestus 런타임이 없습니다 — 데스크탑 앱 설치 또는 Hephaestus 인스톨러 실행 후 다시 시도하세요.");
|
|
92
|
+
ui.info("설치: https://agentlas.cloud · 또는 HEPHAESTUS_BIN=<경로> 지정");
|
|
93
|
+
return { ok: false };
|
|
94
|
+
}
|
|
95
|
+
const cwd = ctx.cwd || D.runCwd();
|
|
96
|
+
const args = ["route", goal, "--project", cwd, "--runtime", "terminal", "--auto-run"];
|
|
97
|
+
if (ctx.research) args.push("--research-evidence");
|
|
98
|
+
if (ctx.background) args.push("--background");
|
|
99
|
+
|
|
100
|
+
ui.beginTurn();
|
|
101
|
+
ui.startSpinner(ui.lang === "ko" ? "Stormbreaker 라우팅/파이프라인 실행 중…" : "Stormbreaker routing/pipeline…");
|
|
102
|
+
const result = await new Promise((resolve) => {
|
|
103
|
+
const child = spawnHephaestus(args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
104
|
+
let stdout = "";
|
|
105
|
+
let stderrTail = [];
|
|
106
|
+
child.stdout.on("data", (c) => { stdout += c.toString(); });
|
|
107
|
+
child.stderr.on("data", (c) => {
|
|
108
|
+
for (const ln of c.toString().split("\n")) {
|
|
109
|
+
const line = ln.trim();
|
|
110
|
+
if (!line) continue;
|
|
111
|
+
stderrTail.push(line);
|
|
112
|
+
if (stderrTail.length > 30) stderrTail.shift();
|
|
113
|
+
ui.updateSpinner(line.slice(0, 100));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
child.on("error", (err) => resolve({ code: 1, stdout, stderr: String(err.message) }));
|
|
117
|
+
child.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr: stderrTail.join("\n") }));
|
|
118
|
+
});
|
|
119
|
+
ui.stopSpinner();
|
|
120
|
+
ui.endTurn();
|
|
121
|
+
|
|
122
|
+
let json = null;
|
|
123
|
+
try {
|
|
124
|
+
const s = result.stdout.indexOf("{");
|
|
125
|
+
const e = result.stdout.lastIndexOf("}");
|
|
126
|
+
if (s >= 0 && e > s) json = JSON.parse(result.stdout.slice(s, e + 1));
|
|
127
|
+
} catch { /* 비JSON 출력 */ }
|
|
128
|
+
|
|
129
|
+
if (json) {
|
|
130
|
+
const action = json.action || json.route_action || (json.route_decision && json.route_decision.action) || json.status || "?";
|
|
131
|
+
ui.line("");
|
|
132
|
+
ui.ok((ui.lang === "ko" ? "storm 결과: " : "storm result: ") + action);
|
|
133
|
+
const fields = {
|
|
134
|
+
receipt_id: json.receipt_id || (json.route_decision && json.route_decision.receipt_id),
|
|
135
|
+
pipeline_id: json.pipeline_id,
|
|
136
|
+
journal: json.journal,
|
|
137
|
+
status: json.status,
|
|
138
|
+
can_report_success: json.final_gate && json.final_gate.can_report_success,
|
|
139
|
+
};
|
|
140
|
+
if (json.auto_run) {
|
|
141
|
+
fields.auto_run = String(json.auto_run.status || "") + (json.auto_run.reason ? ` — ${json.auto_run.reason}` : "");
|
|
142
|
+
}
|
|
143
|
+
const sel = json.selected;
|
|
144
|
+
if (sel) fields.selected = typeof sel === "string" ? sel : sel.id || sel.slug || sel.name;
|
|
145
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
146
|
+
if (v !== undefined && v !== null && v !== "") ui.info(`${k}: ${v}`);
|
|
147
|
+
}
|
|
148
|
+
if (json.clarify_question) ui.warn(String(json.clarify_question));
|
|
149
|
+
if (json.reason) ui.info(String(json.reason));
|
|
150
|
+
// 파이프라인 패킷 요약
|
|
151
|
+
const packets = json.execution_fabric && json.execution_fabric.packets;
|
|
152
|
+
if (Array.isArray(packets)) {
|
|
153
|
+
for (const p of packets.slice(0, 12)) {
|
|
154
|
+
ui.line(" " + ui.c.emerald("▸ ") + ui.c.text(String(p.title || p.id || "packet")) + (p.card ? ui.c.dim(" " + p.card) : ""));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// 파이프라인이 아니면 추천 에이전트라도 보여준다 (hub_candidates 등)
|
|
158
|
+
const exec = json.execution || {};
|
|
159
|
+
const recos = []
|
|
160
|
+
.concat(exec.recommended_agents || [], exec.alternatives || [])
|
|
161
|
+
.map((a) => (typeof a === "string" ? a : a && (a.id || a.slug || a.name)))
|
|
162
|
+
.filter(Boolean);
|
|
163
|
+
if (recos.length) {
|
|
164
|
+
ui.line("");
|
|
165
|
+
ui.info(ui.lang === "ko" ? "추천 에이전트:" : "recommended agents:");
|
|
166
|
+
for (const r of recos.slice(0, 8)) ui.line(" " + ui.c.emerald("▸ ") + ui.c.text(r));
|
|
167
|
+
ui.info(ui.lang === "ko" ? '빌려 실행: agentlas cloud install <slug> 또는 "/storm"을 더 구체적 목표로.' : "borrow: agentlas cloud install <slug>, or re-run /storm with a more specific goal.");
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
const raw = (result.stdout || result.stderr || "").trim();
|
|
171
|
+
if (raw) ui.markdown(raw.slice(0, 4000));
|
|
172
|
+
}
|
|
173
|
+
if (result.code !== 0 && !json) ui.error(`hephaestus exited ${result.code}`);
|
|
174
|
+
return { ok: result.code === 0, json };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function cmdStorm(db, args, runtimeOverride) {
|
|
178
|
+
const rest = [];
|
|
179
|
+
const ctx = { cwd: D.runCwd() };
|
|
180
|
+
for (let i = 0; i < args.length; i++) {
|
|
181
|
+
if (args[i] === "--research" || args[i] === "--research-evidence") ctx.research = true;
|
|
182
|
+
else if (args[i] === "--background") ctx.background = true;
|
|
183
|
+
else rest.push(args[i]);
|
|
184
|
+
}
|
|
185
|
+
const r = await stormRun(db, rest.join(" "), ctx);
|
|
186
|
+
if (!r.ok) process.exitCode = 1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Hephaestus 네이티브 패스스루 — 엔진 전 기능을 터미널 1급으로 노출 ──
|
|
190
|
+
// stdio inherit 로 돌려 색/프롬프트/스트리밍이 네이티브 그대로 나온다.
|
|
191
|
+
function runHephaestusInteractive(args, opts = {}) {
|
|
192
|
+
const found = hephaestusBin();
|
|
193
|
+
if (!found) {
|
|
194
|
+
process.stderr.write("Hephaestus 런타임이 없습니다 — 데스크탑 앱 설치 또는 Hephaestus 인스톨러 실행 후 다시 시도하세요.\n");
|
|
195
|
+
process.stderr.write("설치: https://agentlas.cloud · 또는 HEPHAESTUS_BIN=<경로> 지정\n");
|
|
196
|
+
return Promise.resolve(1);
|
|
197
|
+
}
|
|
198
|
+
const cwd = opts.cwd || D.runCwd();
|
|
199
|
+
const child =
|
|
200
|
+
found.kind === "bin"
|
|
201
|
+
? spawn(found.exec, args, { cwd, stdio: "inherit" })
|
|
202
|
+
: spawn("python3", ["-c", PY_BOOTSTRAP, "agentlas_cloud", ...args], {
|
|
203
|
+
cwd,
|
|
204
|
+
stdio: "inherit",
|
|
205
|
+
env: { ...process.env, HEPHAESTUS_RUNTIME_ROOT: found.root },
|
|
206
|
+
});
|
|
207
|
+
return new Promise((resolve) => {
|
|
208
|
+
child.on("error", (e) => {
|
|
209
|
+
process.stderr.write(`Hephaestus 실행 실패: ${e.message}\n`);
|
|
210
|
+
resolve(1);
|
|
211
|
+
});
|
|
212
|
+
child.on("close", (code) => resolve(code == null ? 0 : code));
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const HEP_USAGE = [
|
|
217
|
+
"agentlas hep <hephaestus 서브커맨드…> — 엔진 전 기능 네이티브 패스스루",
|
|
218
|
+
"",
|
|
219
|
+
" 1급 별칭:",
|
|
220
|
+
" agentlas build \"<요청>\" 에이전트/팀 빌드·수리·패키징 (hep-build)",
|
|
221
|
+
" agentlas route \"<요청>\" 라우팅 미리보기 — 어떤 에이전트가 잡히는지",
|
|
222
|
+
" agentlas research <sub…> Research Engine (status|gather|search|read|plan…)",
|
|
223
|
+
" agentlas network <sub…> 로컬 에이전트 네트워크 (init|status|reindex|add-source…)",
|
|
224
|
+
" agentlas journal <sub…> Stormbreaker 런 저널 (status|verify|repair|gate)",
|
|
225
|
+
" agentlas call \"a,b\" \"<컨텍스트>\" 지정한 Hub/Cloud 에이전트 준비 (hep-call)",
|
|
226
|
+
"",
|
|
227
|
+
" 전체 서브커맨드: agentlas hep --help (wizard·security·package·publish·cards·ao·plugins·meta-agent…)",
|
|
228
|
+
].join("\n");
|
|
229
|
+
|
|
230
|
+
async function cmdHep(db, args) {
|
|
231
|
+
if (!args.length || args[0] === "help") {
|
|
232
|
+
D.out(HEP_USAGE);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const code = await runHephaestusInteractive(args);
|
|
236
|
+
if (code !== 0) process.exitCode = code;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── swarm — 앱 swarm-run.ts 프로토콜의 CLI 포트 ──
|
|
240
|
+
function swarmProtocol(goal, tasks, task) {
|
|
241
|
+
const doneList = tasks
|
|
242
|
+
.filter((t) => t.status === "done")
|
|
243
|
+
.slice(-8)
|
|
244
|
+
.map((t) => `- ${t.title}`)
|
|
245
|
+
.join("\n");
|
|
246
|
+
return [
|
|
247
|
+
"You are one worker in an EMERGENT AGENT SWARM collaborating on a shared goal.",
|
|
248
|
+
`SHARED GOAL: ${goal}`,
|
|
249
|
+
"",
|
|
250
|
+
"YOUR TASK RIGHT NOW:",
|
|
251
|
+
`- ${task.title}${task.role ? ` (role: ${task.role})` : ""}`,
|
|
252
|
+
task.brief ? `- Details: ${task.brief}` : "",
|
|
253
|
+
"",
|
|
254
|
+
doneList ? `Already completed by peers (recent):\n${doneList}` : "No peer results yet — you may be first.",
|
|
255
|
+
"",
|
|
256
|
+
"RULES:",
|
|
257
|
+
"1. Do your task concretely with available tools/files in the current working folder.",
|
|
258
|
+
"2. If the goal needs MORE work beyond your task — split into concrete next steps — end your",
|
|
259
|
+
" message with a `## Spawn` block, one task per line as `role? | brief`:",
|
|
260
|
+
" ## Spawn",
|
|
261
|
+
" - webmaster | build the landing page structure",
|
|
262
|
+
" - | run the tests and report failures",
|
|
263
|
+
" (role is optional; omit it for any-worker tasks. Do NOT spawn if the goal is already met.)",
|
|
264
|
+
"3. Do NOT restate the whole goal. Do NOT invent work that isn't needed — over-spawning wastes the user's money.",
|
|
265
|
+
"4. Everything above the `## Spawn` block is your result and is shared with peers on the blackboard.",
|
|
266
|
+
]
|
|
267
|
+
.filter(Boolean)
|
|
268
|
+
.join("\n");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function parseSwarmOutput(text) {
|
|
272
|
+
const m = String(text).match(/^[ \t]*##[ \t]*Spawn[ \t]*$/im);
|
|
273
|
+
if (!m || m.index === undefined) return { result: String(text).trim(), spawn: [] };
|
|
274
|
+
const result = String(text).slice(0, m.index).trim();
|
|
275
|
+
const block = String(text).slice(m.index + m[0].length).split("\n");
|
|
276
|
+
const spawn = [];
|
|
277
|
+
for (const raw of block) {
|
|
278
|
+
const line = raw.trim();
|
|
279
|
+
if (!line.startsWith("-")) {
|
|
280
|
+
if (line.startsWith("#")) break;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const body = line.replace(/^-\s*/, "");
|
|
284
|
+
const parts = body.split("|");
|
|
285
|
+
let role;
|
|
286
|
+
let brief;
|
|
287
|
+
if (parts.length >= 2) {
|
|
288
|
+
role = parts[0].trim() || undefined;
|
|
289
|
+
brief = parts.slice(1).join("|").trim();
|
|
290
|
+
} else {
|
|
291
|
+
brief = body.trim();
|
|
292
|
+
}
|
|
293
|
+
if (brief) spawn.push({ title: brief.slice(0, 80), brief, role });
|
|
294
|
+
if (spawn.length >= SWARM_SPAWN_PER_TURN) break;
|
|
295
|
+
}
|
|
296
|
+
return { result, spawn };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ctx: { ui?, cwd?, permission?, runtime?, runtimeOverride?, concurrency?, agent?, projectPath? }
|
|
300
|
+
async function swarmRun(db, goal, ctx = {}) {
|
|
301
|
+
const ui = ctx.ui || newUi();
|
|
302
|
+
goal = String(goal || "").trim();
|
|
303
|
+
if (!goal) {
|
|
304
|
+
ui.warn("usage: swarm <goal> [--parallel N]");
|
|
305
|
+
return { ok: false };
|
|
306
|
+
}
|
|
307
|
+
const runtime = ctx.runtime || D.resolveRuntime(db, ctx.runtimeOverride);
|
|
308
|
+
const permission = ctx.permission || "write";
|
|
309
|
+
const cwd = ctx.cwd || D.runCwd();
|
|
310
|
+
const concurrency = Math.max(1, Math.min(8, Number(ctx.concurrency) || 3));
|
|
311
|
+
const env = await D.buildChildEnvCli(db, {
|
|
312
|
+
projectPath: ctx.projectPath || null,
|
|
313
|
+
agentId: ctx.agent && ctx.agent.id,
|
|
314
|
+
permission,
|
|
315
|
+
cwd,
|
|
316
|
+
lang: ui.lang,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
async function runWorker(system, prompt) {
|
|
320
|
+
if (runtime.mode === "cli") {
|
|
321
|
+
return await D.captureRuntime(runtime.kind, system, prompt, { cwd, env, permission });
|
|
322
|
+
}
|
|
323
|
+
const text = await D.runApi(runtime.backend, runtime.model, system, prompt);
|
|
324
|
+
return typeof text === "string" ? text : (text && text.text) || "";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const label = runtime.mode === "cli" ? runtime.kind : runtime.backend;
|
|
328
|
+
ui.line("");
|
|
329
|
+
ui.line(ui.c.paw("◤ ") + ui.c.bold(ui.c.text("swarm")) + ui.c.dim(` ${label} · x${concurrency} · max ${SWARM_MAX_TASKS} tasks`));
|
|
330
|
+
ui.info(goal.slice(0, 120));
|
|
331
|
+
|
|
332
|
+
let seq = 0;
|
|
333
|
+
const tasks = [{ id: ++seq, title: goal.slice(0, 80), brief: goal, role: undefined, status: "pending", result: "" }];
|
|
334
|
+
const seen = new Set([goal.slice(0, 80).toLowerCase()]);
|
|
335
|
+
let active = 0;
|
|
336
|
+
let failed = 0;
|
|
337
|
+
|
|
338
|
+
await new Promise((resolveAll) => {
|
|
339
|
+
const pump = () => {
|
|
340
|
+
const pending = tasks.filter((t) => t.status === "pending");
|
|
341
|
+
if (!pending.length && active === 0) return resolveAll();
|
|
342
|
+
for (const task of pending) {
|
|
343
|
+
if (active >= concurrency) break;
|
|
344
|
+
task.status = "running";
|
|
345
|
+
active++;
|
|
346
|
+
ui.tool(`⚑ ${task.title}` + (task.role ? ` (${task.role})` : ""));
|
|
347
|
+
runWorker(swarmProtocol(goal, tasks, task), task.brief || task.title)
|
|
348
|
+
.then((text) => {
|
|
349
|
+
const parsed = parseSwarmOutput(text);
|
|
350
|
+
task.status = "done";
|
|
351
|
+
task.result = parsed.result;
|
|
352
|
+
ui.toolResult(parsed.result.split("\n").slice(0, 3).join("\n") || "(빈 결과)", true);
|
|
353
|
+
for (const s of parsed.spawn) {
|
|
354
|
+
const key = s.title.toLowerCase();
|
|
355
|
+
if (tasks.length >= SWARM_MAX_TASKS || seen.has(key)) continue;
|
|
356
|
+
seen.add(key);
|
|
357
|
+
tasks.push({ id: ++seq, title: s.title, brief: s.brief, role: s.role, status: "pending", result: "" });
|
|
358
|
+
ui.info(`+ spawn: ${s.title}`);
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
.catch((e) => {
|
|
362
|
+
task.status = "failed";
|
|
363
|
+
failed++;
|
|
364
|
+
ui.toolResult(String((e && e.message) || e).slice(0, 200), false);
|
|
365
|
+
})
|
|
366
|
+
.finally(() => {
|
|
367
|
+
active--;
|
|
368
|
+
setImmediate(pump);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
pump();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const done = tasks.filter((t) => t.status === "done" && t.result);
|
|
376
|
+
ui.line("");
|
|
377
|
+
ui.info(`tasks: ${tasks.length} · done: ${done.length} · failed: ${failed}`);
|
|
378
|
+
if (!done.length) {
|
|
379
|
+
ui.error(ui.lang === "ko" ? "스웜이 완료한 작업이 없습니다." : "The swarm completed no work.");
|
|
380
|
+
return { ok: false };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
ui.startSpinner(ui.lang === "ko" ? "스웜 결과 종합 중…" : "Synthesizing swarm results…");
|
|
384
|
+
const pieces = done.map((t, i) => `### ${i + 1}. ${t.title}\n${t.result}`).join("\n\n");
|
|
385
|
+
let finalText;
|
|
386
|
+
try {
|
|
387
|
+
finalText = await runWorker(
|
|
388
|
+
[
|
|
389
|
+
"You are the synthesizer of an agent swarm. Below are the results your peers produced for the shared goal.",
|
|
390
|
+
"Integrate them into ONE coherent final answer for the user. Reconcile overlaps, note anything incomplete.",
|
|
391
|
+
"Do not just concatenate. Do not include a `## Spawn` block.",
|
|
392
|
+
`SHARED GOAL: ${goal}`,
|
|
393
|
+
`Answer in the user's language (${ui.lang === "ko" ? "Korean" : "English"}).`,
|
|
394
|
+
].join("\n"),
|
|
395
|
+
pieces,
|
|
396
|
+
);
|
|
397
|
+
} catch (e) {
|
|
398
|
+
ui.stopSpinner();
|
|
399
|
+
ui.error("종합 실패: " + String((e && e.message) || e).slice(0, 200));
|
|
400
|
+
finalText = pieces;
|
|
401
|
+
}
|
|
402
|
+
ui.stopSpinner();
|
|
403
|
+
ui.line("");
|
|
404
|
+
ui.markdown(String(finalText).trim());
|
|
405
|
+
return { ok: true, finalText, taskCount: tasks.length, doneCount: done.length };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function cmdSwarm(db, args, runtimeOverride) {
|
|
409
|
+
const rest = [];
|
|
410
|
+
let concurrency;
|
|
411
|
+
for (let i = 0; i < args.length; i++) {
|
|
412
|
+
if (args[i] === "--parallel" || args[i] === "-n") concurrency = Number(args[++i]);
|
|
413
|
+
else rest.push(args[i]);
|
|
414
|
+
}
|
|
415
|
+
const r = await swarmRun(db, rest.join(" "), { concurrency, runtimeOverride });
|
|
416
|
+
if (!r.ok) process.exitCode = 1;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── 미니 cron (5필드: 분 시 일 월 요일) — next_run_at 계산용 ──
|
|
420
|
+
// 앱 스케줄러(croner)는 next_run_at IS NULL 을 "시계 없음"으로 취급하므로 CLI가 직접 채워야 한다.
|
|
421
|
+
function cronField(expr, min, max) {
|
|
422
|
+
const set = new Set();
|
|
423
|
+
for (const part of String(expr).split(",")) {
|
|
424
|
+
const m = part.match(/^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/);
|
|
425
|
+
if (!m) return null;
|
|
426
|
+
const step = m[2] ? Number(m[2]) : 1;
|
|
427
|
+
let lo = min;
|
|
428
|
+
let hi = max;
|
|
429
|
+
if (m[1] !== "*") {
|
|
430
|
+
const range = m[1].split("-").map(Number);
|
|
431
|
+
lo = range[0];
|
|
432
|
+
hi = range.length > 1 ? range[1] : m[2] ? max : range[0];
|
|
433
|
+
}
|
|
434
|
+
if (lo < min || hi > max || lo > hi || step < 1) return null;
|
|
435
|
+
for (let v = lo; v <= hi; v += step) set.add(v);
|
|
436
|
+
}
|
|
437
|
+
return set;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function nextCronRun(cron, from = new Date()) {
|
|
441
|
+
const parts = String(cron).trim().split(/\s+/);
|
|
442
|
+
if (parts.length !== 5) return null;
|
|
443
|
+
const [minS, hourS, domS, monS, dowS] = parts;
|
|
444
|
+
const mins = cronField(minS, 0, 59);
|
|
445
|
+
const hours = cronField(hourS, 0, 23);
|
|
446
|
+
const doms = cronField(domS, 1, 31);
|
|
447
|
+
const mons = cronField(monS, 1, 12);
|
|
448
|
+
const dows = cronField(dowS, 0, 7);
|
|
449
|
+
if (!mins || !hours || !doms || !mons || !dows) return null;
|
|
450
|
+
if (dows.has(7)) dows.add(0);
|
|
451
|
+
const t = new Date(from.getTime());
|
|
452
|
+
t.setSeconds(0, 0);
|
|
453
|
+
t.setMinutes(t.getMinutes() + 1);
|
|
454
|
+
for (let i = 0; i < 366 * 24 * 60; i++) {
|
|
455
|
+
const domOk = doms.has(t.getDate());
|
|
456
|
+
const dowOk = dows.has(t.getDay());
|
|
457
|
+
// 표준 cron: dom/dow 둘 다 제한이면 OR, 아니면 AND
|
|
458
|
+
const domRestricted = domS !== "*";
|
|
459
|
+
const dowRestricted = dowS !== "*";
|
|
460
|
+
const dayOk = domRestricted && dowRestricted ? domOk || dowOk : domOk && dowOk;
|
|
461
|
+
if (mons.has(t.getMonth() + 1) && dayOk && hours.has(t.getHours()) && mins.has(t.getMinutes())) return t;
|
|
462
|
+
t.setMinutes(t.getMinutes() + 1);
|
|
463
|
+
}
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── automation — 등록/목록/토글/실행 (run·daemon은 로컬 실행기) ──
|
|
468
|
+
async function cmdAutomation(db, args, runtimeOverride) {
|
|
469
|
+
const sub = args[0] || "list";
|
|
470
|
+
const now = new Date().toISOString();
|
|
471
|
+
|
|
472
|
+
if (sub === "list") {
|
|
473
|
+
const rows = db.prepare(
|
|
474
|
+
"SELECT id, name, schedule, target_type, target_id, enabled, next_run_at, last_run_at, run_count, trigger_type FROM automations ORDER BY created_at DESC",
|
|
475
|
+
).all();
|
|
476
|
+
if (!rows.length) return D.out("자동화가 없습니다. agentlas automation add --help");
|
|
477
|
+
for (const r of rows) {
|
|
478
|
+
const target = r.target_type + ":" + String(r.target_id).slice(0, 24);
|
|
479
|
+
D.out(
|
|
480
|
+
`${r.enabled ? "●" : "○"} ${String(r.id).slice(0, 8)} ${String(r.name).padEnd(28).slice(0, 28)} ` +
|
|
481
|
+
`${String(r.schedule || r.trigger_type).padEnd(14)} ${target.padEnd(32)} ` +
|
|
482
|
+
`next=${r.next_run_at ? r.next_run_at.slice(0, 16) : "-"} runs=${r.run_count}`,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
D.out("");
|
|
486
|
+
D.out("지금 실행: agentlas automation run <id> · 상주 실행기: agentlas automation daemon");
|
|
487
|
+
D.out("(데스크탑 앱이 켜져 있으면 앱 스케줄러도 실행합니다 — 리스로 중복 실행은 방지됩니다.)");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (sub === "add") {
|
|
492
|
+
// agentlas automation add --name "..." --agent <slug>|--firm <slug> --cron "0 9 * * *" --prompt "..."
|
|
493
|
+
const flags = {};
|
|
494
|
+
for (let i = 1; i < args.length; i++) {
|
|
495
|
+
const a = args[i];
|
|
496
|
+
if (a === "--name") flags.name = args[++i];
|
|
497
|
+
else if (a === "--agent") flags.agent = args[++i];
|
|
498
|
+
else if (a === "--firm") flags.firm = args[++i];
|
|
499
|
+
else if (a === "--cron") flags.cron = args[++i];
|
|
500
|
+
else if (a === "--prompt") flags.prompt = args[++i];
|
|
501
|
+
else if (a === "--tz") flags.tz = args[++i];
|
|
502
|
+
else if (a === "--disabled") flags.disabled = true;
|
|
503
|
+
}
|
|
504
|
+
if (!flags.cron || !flags.prompt || (!flags.agent && !flags.firm)) {
|
|
505
|
+
D.out('usage: agentlas automation add --name "이름" --agent <slug>|--firm <slug> --cron "0 9 * * *" --prompt "지시" [--tz Asia/Seoul] [--disabled]');
|
|
506
|
+
process.exitCode = 1;
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
let targetType;
|
|
510
|
+
let targetId;
|
|
511
|
+
let targetLabel;
|
|
512
|
+
if (flags.agent) {
|
|
513
|
+
const a = D.resolveAgent(db, flags.agent);
|
|
514
|
+
if (!a) return D.fail(`에이전트를 찾을 수 없습니다: ${flags.agent}`);
|
|
515
|
+
targetType = "agent";
|
|
516
|
+
targetId = a.id;
|
|
517
|
+
targetLabel = a.name;
|
|
518
|
+
} else {
|
|
519
|
+
const f = D.resolveFirm(db, flags.firm);
|
|
520
|
+
if (!f) return D.fail(`회사를 찾을 수 없습니다: ${flags.firm}`);
|
|
521
|
+
targetType = "firm";
|
|
522
|
+
targetId = f.id;
|
|
523
|
+
targetLabel = f.name;
|
|
524
|
+
}
|
|
525
|
+
const next = nextCronRun(flags.cron);
|
|
526
|
+
if (!next) return D.fail(`cron 표현식을 해석할 수 없습니다: "${flags.cron}" (5필드: 분 시 일 월 요일)`);
|
|
527
|
+
const id = crypto.randomUUID();
|
|
528
|
+
db.prepare(
|
|
529
|
+
`INSERT INTO automations (id, name, schedule, target_type, target_id, prompt_template, enabled, created_by,
|
|
530
|
+
next_run_at, created_at, timezone, trigger_type, tool_mode, hub_mode, run_count)
|
|
531
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,0)`,
|
|
532
|
+
).run(
|
|
533
|
+
id,
|
|
534
|
+
flags.name || `${targetLabel} 자동화`,
|
|
535
|
+
flags.cron,
|
|
536
|
+
targetType,
|
|
537
|
+
targetId,
|
|
538
|
+
flags.prompt,
|
|
539
|
+
flags.disabled ? 0 : 1,
|
|
540
|
+
"cli",
|
|
541
|
+
next.toISOString(),
|
|
542
|
+
now,
|
|
543
|
+
flags.tz || null,
|
|
544
|
+
"schedule",
|
|
545
|
+
"auto",
|
|
546
|
+
"hub-allowed",
|
|
547
|
+
);
|
|
548
|
+
D.out(`등록됨: ${id.slice(0, 8)} ${flags.name || targetLabel} next=${next.toISOString().slice(0, 16)}`);
|
|
549
|
+
D.out(`지금 실행: agentlas automation run ${id.slice(0, 8)} · 예약 실행: agentlas automation daemon (또는 데스크탑 앱)`);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (sub === "on" || sub === "off") {
|
|
554
|
+
const idPrefix = args[1];
|
|
555
|
+
if (!idPrefix) return D.fail(`usage: agentlas automation ${sub} <id>`);
|
|
556
|
+
const row = db.prepare("SELECT id, name, schedule FROM automations WHERE id LIKE ?").get(idPrefix + "%");
|
|
557
|
+
if (!row) return D.fail(`자동화를 찾을 수 없습니다: ${idPrefix}`);
|
|
558
|
+
if (sub === "on") {
|
|
559
|
+
const next = nextCronRun(row.schedule) || null;
|
|
560
|
+
db.prepare("UPDATE automations SET enabled=1, next_run_at=? WHERE id=?").run(next ? next.toISOString() : null, row.id);
|
|
561
|
+
} else {
|
|
562
|
+
db.prepare("UPDATE automations SET enabled=0 WHERE id=?").run(row.id);
|
|
563
|
+
}
|
|
564
|
+
D.out(`${sub === "on" ? "켜짐" : "꺼짐"}: ${row.id.slice(0, 8)} ${row.name}`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (sub === "remove" || sub === "rm") {
|
|
569
|
+
const idPrefix = args[1];
|
|
570
|
+
if (!idPrefix) return D.fail("usage: agentlas automation remove <id>");
|
|
571
|
+
const row = db.prepare("SELECT id, name FROM automations WHERE id LIKE ?").get(idPrefix + "%");
|
|
572
|
+
if (!row) return D.fail(`자동화를 찾을 수 없습니다: ${idPrefix}`);
|
|
573
|
+
db.prepare("DELETE FROM automations WHERE id=?").run(row.id);
|
|
574
|
+
D.out(`삭제됨: ${row.id.slice(0, 8)} ${row.name}`);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (sub === "runs") {
|
|
579
|
+
const rows = db.prepare(
|
|
580
|
+
`SELECT h.ran_at, h.status, h.error, a.name FROM run_history h
|
|
581
|
+
LEFT JOIN automations a ON a.id = h.automation_id
|
|
582
|
+
ORDER BY h.ran_at DESC LIMIT 15`,
|
|
583
|
+
).all();
|
|
584
|
+
if (!rows.length) return D.out("실행 이력이 없습니다.");
|
|
585
|
+
for (const r of rows) {
|
|
586
|
+
D.out(
|
|
587
|
+
`${(r.ran_at || "").slice(0, 16).padEnd(17)} ${(r.status || "?").padEnd(9)} ${(r.name || "(삭제됨)").slice(0, 30).padEnd(31)} ${r.error ? String(r.error).slice(0, 40) : ""}`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (sub === "run") {
|
|
594
|
+
const idPrefix = args[1];
|
|
595
|
+
if (!idPrefix) return D.fail("usage: agentlas automation run <id>");
|
|
596
|
+
const row = db.prepare("SELECT * FROM automations WHERE id LIKE ?").get(idPrefix + "%");
|
|
597
|
+
if (!row) return D.fail(`자동화를 찾을 수 없습니다: ${idPrefix}`);
|
|
598
|
+
const ui = newUi();
|
|
599
|
+
// run-now는 스케줄을 건드리지 않는다 (앱의 advanceSchedule=false와 동일).
|
|
600
|
+
const r = await runAutomationOnce(db, row, { ui, advanceSchedule: false, runtimeOverride });
|
|
601
|
+
if (!r.ok) process.exitCode = 1;
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (sub === "daemon") {
|
|
606
|
+
let interval = 30;
|
|
607
|
+
for (let i = 1; i < args.length; i++) {
|
|
608
|
+
if (args[i] === "--interval") interval = Math.max(10, Number(args[++i]) || 30);
|
|
609
|
+
}
|
|
610
|
+
return automationDaemon(db, { intervalSec: interval });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
D.out("usage: agentlas automation list|add|on <id>|off <id>|remove <id>|run <id>|runs|daemon");
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ── automation 실행기 — 앱 스케줄러와 같은 SQLite 리스(claimed_at TTL 15분)로 중복 실행 방지 ──
|
|
617
|
+
const AUTOMATION_LEASE_TTL_MS = 15 * 60 * 1000; // 앱 store/automations.ts LEASE_TTL_MS와 동일
|
|
618
|
+
const AUTOMATION_LEASE_OWNER = `cli:${os.hostname()}:${process.pid}`;
|
|
619
|
+
|
|
620
|
+
function claimAutomation(db, id, now = new Date()) {
|
|
621
|
+
const cutoff = new Date(now.getTime() - AUTOMATION_LEASE_TTL_MS).toISOString();
|
|
622
|
+
const result = db
|
|
623
|
+
.prepare(
|
|
624
|
+
"UPDATE automations SET claimed_at = ?, lease_owner = ? WHERE id = ? AND enabled = 1 AND (claimed_at IS NULL OR claimed_at < ?)",
|
|
625
|
+
)
|
|
626
|
+
.run(now.toISOString(), AUTOMATION_LEASE_OWNER, id, cutoff);
|
|
627
|
+
return (result.changes ?? result.rowsAffected ?? 0) > 0;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function releaseAutomation(db, id) {
|
|
631
|
+
try { db.prepare("UPDATE automations SET claimed_at = NULL, lease_owner = NULL WHERE id = ?").run(id); } catch { /* best-effort */ }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function recordAutomationRun(db, automationId, status, error, scheduledFor) {
|
|
635
|
+
try {
|
|
636
|
+
db.prepare(
|
|
637
|
+
"INSERT INTO run_history (id, automation_id, scheduled_for, ran_at, status, skipped_count, error) VALUES (?,?,?,?,?,0,?)",
|
|
638
|
+
).run(crypto.randomUUID(), automationId, scheduledFor || null, new Date().toISOString(), status, error || null);
|
|
639
|
+
} catch { /* best-effort */ }
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// 자동화 1건 실행: 타깃(agent/firm)의 시스템 프롬프트로 prompt_template을 활성 런타임에 태운다.
|
|
643
|
+
// ctx: { ui, advanceSchedule?, runtimeOverride?, scheduledFor? }
|
|
644
|
+
async function runAutomationOnce(db, row, ctx = {}) {
|
|
645
|
+
const ui = ctx.ui || newUi();
|
|
646
|
+
// run-now도 리스를 잡는다 — 앱 스케줄러가 같은 행을 동시에 돌리는 것을 방지.
|
|
647
|
+
if (!claimAutomation(db, row.id)) {
|
|
648
|
+
ui.warn(`다른 실행기가 이 자동화를 잡고 있습니다 (lease TTL 15분): ${row.name}`);
|
|
649
|
+
return { ok: false, skipped: true };
|
|
650
|
+
}
|
|
651
|
+
ui.line("");
|
|
652
|
+
ui.line(ui.c.paw("◤ ") + ui.c.bold(ui.c.text("automation")) + ui.c.dim(` ${row.name} (${String(row.id).slice(0, 8)})`));
|
|
653
|
+
|
|
654
|
+
try {
|
|
655
|
+
// 타깃 해석 (agent/firm) — background/비공개 포함 id 직접 조회.
|
|
656
|
+
let system;
|
|
657
|
+
let agentId = null;
|
|
658
|
+
if (row.target_type === "firm") {
|
|
659
|
+
const firm = db.prepare("SELECT * FROM firms WHERE id = ?").get(row.target_id);
|
|
660
|
+
if (!firm) throw new Error(`회사를 찾을 수 없습니다: ${row.target_id}`);
|
|
661
|
+
system = D.firmSystemPrompt(db, firm);
|
|
662
|
+
} else {
|
|
663
|
+
const agent = db.prepare("SELECT * FROM installed_agents WHERE id = ?").get(row.target_id);
|
|
664
|
+
if (!agent) throw new Error(`에이전트를 찾을 수 없습니다: ${row.target_id}`);
|
|
665
|
+
system = agent.system_prompt || `You are ${agent.name}.`;
|
|
666
|
+
agentId = agent.id;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const cwd = D.runCwd();
|
|
670
|
+
const env = await D.buildChildEnvCli(db, { projectPath: null, agentId, permission: "write", cwd, lang: ui.lang });
|
|
671
|
+
const runtime = D.resolveRuntime(db, ctx.runtimeOverride);
|
|
672
|
+
ui.info(`${runtime.mode === "cli" ? runtime.kind : runtime.backend} · write · ${cwd}`);
|
|
673
|
+
ui.startSpinner(ui.lang === "ko" ? "자동화 실행 중…" : "running automation…");
|
|
674
|
+
|
|
675
|
+
let text;
|
|
676
|
+
if (runtime.mode === "cli") {
|
|
677
|
+
text = await D.captureRuntime(runtime.kind, system, row.prompt_template, { cwd, env, permission: "write" });
|
|
678
|
+
} else {
|
|
679
|
+
const r = await D.runApi(runtime.backend, runtime.model, system, row.prompt_template);
|
|
680
|
+
text = typeof r === "string" ? r : (r && r.text) || "";
|
|
681
|
+
}
|
|
682
|
+
ui.stopSpinner();
|
|
683
|
+
ui.markdown(String(text).trim().slice(0, 4000));
|
|
684
|
+
|
|
685
|
+
recordAutomationRun(db, row.id, "ok", null, ctx.scheduledFor);
|
|
686
|
+
const advance = ctx.advanceSchedule && row.schedule ? nextCronRun(row.schedule) : null;
|
|
687
|
+
db.prepare(
|
|
688
|
+
"UPDATE automations SET last_run_at = ?, run_count = run_count + 1" + (advance ? ", next_run_at = ?" : "") + " WHERE id = ?",
|
|
689
|
+
).run(...(advance ? [new Date().toISOString(), advance.toISOString(), row.id] : [new Date().toISOString(), row.id]));
|
|
690
|
+
// max_runs 도달 시 비활성화 (앱과 동일한 종료 조건).
|
|
691
|
+
if (row.max_runs && row.run_count + 1 >= row.max_runs) {
|
|
692
|
+
db.prepare("UPDATE automations SET enabled = 0 WHERE id = ?").run(row.id);
|
|
693
|
+
ui.info(ui.lang === "ko" ? "max_runs 도달 — 자동화를 비활성화했습니다." : "max_runs reached — automation disabled.");
|
|
694
|
+
}
|
|
695
|
+
return { ok: true };
|
|
696
|
+
} catch (e) {
|
|
697
|
+
ui.stopSpinner();
|
|
698
|
+
const msg = String((e && e.message) || e).slice(0, 500);
|
|
699
|
+
ui.error(msg);
|
|
700
|
+
recordAutomationRun(db, row.id, "error", msg, ctx.scheduledFor);
|
|
701
|
+
db.prepare("UPDATE automations SET last_run_at = ? WHERE id = ?").run(new Date().toISOString(), row.id);
|
|
702
|
+
return { ok: false };
|
|
703
|
+
} finally {
|
|
704
|
+
releaseAutomation(db, row.id);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// 상주 실행기 — 앱 없이도 자동화가 돌게 하는 포그라운드 데몬 (Ctrl-C로 종료).
|
|
709
|
+
async function automationDaemon(db, opts = {}) {
|
|
710
|
+
const ui = newUi();
|
|
711
|
+
const intervalSec = Math.max(10, opts.intervalSec || 30);
|
|
712
|
+
let stopping = false;
|
|
713
|
+
process.on("SIGINT", () => { stopping = true; ui.line(""); ui.info(ui.lang === "ko" ? "종료 중…" : "stopping…"); });
|
|
714
|
+
process.on("SIGTERM", () => { stopping = true; });
|
|
715
|
+
|
|
716
|
+
ui.line("");
|
|
717
|
+
ui.ok(`automation daemon — ${intervalSec}s 폴링 · owner ${AUTOMATION_LEASE_OWNER}`);
|
|
718
|
+
ui.info(ui.lang === "ko" ? "Ctrl-C로 종료. (데스크탑 앱 스케줄러와 리스를 공유해 중복 실행되지 않습니다.)" : "Ctrl-C to stop.");
|
|
719
|
+
|
|
720
|
+
while (!stopping) {
|
|
721
|
+
const nowIso = new Date().toISOString();
|
|
722
|
+
let due = [];
|
|
723
|
+
try {
|
|
724
|
+
due = db.prepare(
|
|
725
|
+
"SELECT * FROM automations WHERE enabled = 1 AND trigger_type = 'schedule' AND next_run_at IS NOT NULL AND next_run_at <= ? ORDER BY next_run_at ASC LIMIT 5",
|
|
726
|
+
).all(nowIso);
|
|
727
|
+
} catch (e) {
|
|
728
|
+
ui.error("due 조회 실패: " + String((e && e.message) || e));
|
|
729
|
+
}
|
|
730
|
+
for (const row of due) {
|
|
731
|
+
if (stopping) break;
|
|
732
|
+
await runAutomationOnce(db, row, { ui, advanceSchedule: true, scheduledFor: row.next_run_at });
|
|
733
|
+
// 스케줄이 없는(1회성) 행이 남으면 재발화 방지.
|
|
734
|
+
if (!row.schedule || !nextCronRun(row.schedule)) {
|
|
735
|
+
db.prepare("UPDATE automations SET enabled = 0 WHERE id = ? AND (schedule IS NULL OR schedule = '')").run(row.id);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// interval 대기 (1초 단위로 stop 체크)
|
|
739
|
+
for (let i = 0; i < intervalSec && !stopping; i++) {
|
|
740
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
ui.info(ui.lang === "ko" ? "데몬 종료." : "daemon stopped.");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ── mcp / chats — 데스크탑 데이터 열람 ──
|
|
747
|
+
function cmdMcp(db) {
|
|
748
|
+
let rows = [];
|
|
749
|
+
try {
|
|
750
|
+
rows = db.prepare("SELECT id, name, name_en, transport, enabled FROM mcp_servers ORDER BY installed_at ASC").all();
|
|
751
|
+
} catch { /* 테이블 없음 */ }
|
|
752
|
+
if (!rows.length) return D.out("설치된 MCP 서버가 없습니다. (설치/설정은 데스크탑 앱 또는 에이전트 패키지가 관리)");
|
|
753
|
+
for (const r of rows) {
|
|
754
|
+
D.out(`${r.enabled ? "●" : "○"} ${String(r.name || r.name_en || r.id).padEnd(28).slice(0, 28)} ${String(r.transport || "stdio").padEnd(8)} ${String(r.id).slice(0, 12)}`);
|
|
755
|
+
}
|
|
756
|
+
D.out("");
|
|
757
|
+
D.out("write/full 턴에서 활성(●) stdio 서버가 런타임에 배선됩니다. REPL에서는 /mcp.");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function cmdChats(db, args) {
|
|
761
|
+
const limit = Math.max(1, Math.min(50, Number(args[0]) || 15));
|
|
762
|
+
let rows = [];
|
|
763
|
+
try {
|
|
764
|
+
rows = db.prepare(
|
|
765
|
+
`SELECT c.id, c.title, c.updated_at, a.name AS agent_name, a.name_en AS agent_name_en
|
|
766
|
+
FROM chats c LEFT JOIN installed_agents a ON a.id = c.agent_id
|
|
767
|
+
WHERE c.archived_at IS NULL AND c.kind = 'user'
|
|
768
|
+
ORDER BY c.updated_at DESC LIMIT ?`,
|
|
769
|
+
).all(limit);
|
|
770
|
+
} catch { /* 스키마 차이 */ }
|
|
771
|
+
if (!rows.length) return D.out("채팅이 없습니다.");
|
|
772
|
+
for (const r of rows) {
|
|
773
|
+
D.out(`${String(r.updated_at || "").slice(0, 16).padEnd(17)} ${String(r.agent_name || r.agent_name_en || "-").slice(0, 18).padEnd(19)} ${String(r.title || "(제목 없음)").slice(0, 60)}`);
|
|
774
|
+
}
|
|
775
|
+
D.out("");
|
|
776
|
+
D.out("데스크탑 앱과 같은 대화 목록입니다 — 터미널 세션 이어하기는 REPL의 /resume.");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ── usage — 로컬 집계 ──
|
|
780
|
+
async function cmdUsage(db) {
|
|
781
|
+
const day = new Date(Date.now() - 86400000).toISOString();
|
|
782
|
+
const week = new Date(Date.now() - 7 * 86400000).toISOString();
|
|
783
|
+
const q = (sql, ...p) => {
|
|
784
|
+
try { return db.prepare(sql).get(...p) || {}; } catch { return {}; }
|
|
785
|
+
};
|
|
786
|
+
const ar = q("SELECT kind FROM active_runtime WHERE id=1");
|
|
787
|
+
const agents = q("SELECT COUNT(*) AS n FROM installed_agents");
|
|
788
|
+
const chats = q("SELECT COUNT(*) AS n FROM chats WHERE archived_at IS NULL");
|
|
789
|
+
const msg24 = q("SELECT COUNT(*) AS n FROM chat_messages WHERE created_at > ?", day);
|
|
790
|
+
const msg7 = q("SELECT COUNT(*) AS n FROM chat_messages WHERE created_at > ?", week);
|
|
791
|
+
const auto = q("SELECT COUNT(*) AS n FROM automations WHERE enabled=1");
|
|
792
|
+
const runs7 = q("SELECT COUNT(*) AS n, SUM(CASE WHEN status='error' OR error IS NOT NULL THEN 1 ELSE 0 END) AS err FROM run_history WHERE ran_at > ?", week);
|
|
793
|
+
D.out(`활성 런타임 ${ar.kind || "(없음)"}`);
|
|
794
|
+
D.out(`설치 에이전트 ${agents.n ?? "?"}`);
|
|
795
|
+
D.out(`활성 채팅 ${chats.n ?? "?"}`);
|
|
796
|
+
D.out(`메시지 24h ${msg24.n ?? 0} · 7d ${msg7.n ?? 0}`);
|
|
797
|
+
D.out(`자동화(켜짐) ${auto.n ?? 0}`);
|
|
798
|
+
D.out(`자동화 실행(7d) ${runs7.n ?? 0}${runs7.err ? ` (실패 ${runs7.err})` : ""}`);
|
|
799
|
+
D.out("");
|
|
800
|
+
D.out("세션 단위 토큰/비용은 대화 안 /cost, 프로바이더 쿼터 대시보드는 데스크탑 앱에서.");
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ── telegram — 바인딩 현황 (읽기 전용) ──
|
|
804
|
+
function cmdTelegram(db, args) {
|
|
805
|
+
let rows;
|
|
806
|
+
try {
|
|
807
|
+
rows = db.prepare("SELECT * FROM telegram_bindings ORDER BY rowid DESC").all();
|
|
808
|
+
} catch {
|
|
809
|
+
rows = [];
|
|
810
|
+
}
|
|
811
|
+
if (!rows.length) {
|
|
812
|
+
D.out("텔레그램 바인딩이 없습니다 — 페어링은 데스크탑 앱 Connect에서 합니다.");
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
for (const r of rows) {
|
|
816
|
+
const bot = r.bot_username ? "@" + r.bot_username : "(봇 미지정)";
|
|
817
|
+
const chat = r.telegram_chat_title || r.telegram_chat_id || "(채팅 미연결)";
|
|
818
|
+
const status = r.status || (r.telegram_chat_id ? "paired" : "pending");
|
|
819
|
+
D.out(`${String(r.id).slice(0, 8)} ${r.target_kind}:${String(r.target_id).slice(0, 20).padEnd(21)} ${String(bot).padEnd(24)} ${String(chat).slice(0, 28).padEnd(29)} ${status}`);
|
|
820
|
+
}
|
|
821
|
+
D.out("");
|
|
822
|
+
D.out("페어링/봇 발급은 데스크탑 앱 Connect에서, 여기서는 현황만 봅니다.");
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ── login / logout / whoami — Agentlas Cloud 세션 (데스크탑과 동일한 loopback 브라우저 플로우) ──
|
|
826
|
+
function webBaseUrl() {
|
|
827
|
+
return (process.env.AGENTLAS_WEB_BASE_URL || "https://agentlas.cloud").replace(/\/$/, "");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function openInBrowser(url) {
|
|
831
|
+
const argv =
|
|
832
|
+
process.platform === "darwin" ? ["open", url]
|
|
833
|
+
: process.platform === "win32" ? ["cmd", "/c", "start", "", url]
|
|
834
|
+
: ["xdg-open", url];
|
|
835
|
+
try {
|
|
836
|
+
const child = spawn(argv[0], argv.slice(1), { stdio: "ignore", detached: true });
|
|
837
|
+
child.unref();
|
|
838
|
+
} catch { /* URL은 이미 출력됨 — 수동으로 열면 된다 */ }
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async function fetchSessionMeta(cookie) {
|
|
842
|
+
const resp = await fetch(`${webBaseUrl()}/api/auth/session`, { headers: { cookie }, signal: AbortSignal.timeout(8000) });
|
|
843
|
+
if (!resp.ok) throw new Error(`세션 확인 응답 ${resp.status}`);
|
|
844
|
+
return resp.json();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async function cmdWhoami() {
|
|
848
|
+
const cookie = await D.cloudSessionCookieCli();
|
|
849
|
+
if (!cookie) {
|
|
850
|
+
D.out("로그아웃 상태입니다. agentlas login 으로 로그인하세요.");
|
|
851
|
+
process.exitCode = 1;
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
try {
|
|
855
|
+
const j = await fetchSessionMeta(cookie);
|
|
856
|
+
if (j && j.authenticated) {
|
|
857
|
+
const email = (j.user && j.user.email) || "?";
|
|
858
|
+
const ws = j.workspace || {};
|
|
859
|
+
D.out(`로그인됨: ${email} · 워크스페이스: ${ws.name || "?"} (${ws.plan || "free"})`);
|
|
860
|
+
} else {
|
|
861
|
+
D.out("세션이 만료되었거나 유효하지 않습니다. agentlas login 으로 다시 로그인하세요.");
|
|
862
|
+
process.exitCode = 1;
|
|
863
|
+
}
|
|
864
|
+
} catch (e) {
|
|
865
|
+
D.fail("세션 확인 실패: " + String((e && e.message) || e));
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// 웹 /account?desktop=1&callback=<loopback> 이 유효 세션이면 <callback>?session=<value> 로 302 —
|
|
870
|
+
// 데스크탑 signInWithBrowser(electron/auth.ts)와 동일한 프로토콜을 순수 Node http로 구현.
|
|
871
|
+
async function cmdLogin(args = []) {
|
|
872
|
+
const force = args.includes("--force");
|
|
873
|
+
if (!force) {
|
|
874
|
+
const existing = await D.cloudSessionCookieCli();
|
|
875
|
+
if (existing) {
|
|
876
|
+
try {
|
|
877
|
+
const j = await fetchSessionMeta(existing);
|
|
878
|
+
if (j && j.authenticated) {
|
|
879
|
+
D.out(`이미 로그인돼 있습니다 (${(j.user && j.user.email) || "?"}). 재로그인: agentlas login --force`);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
} catch { /* 확인 실패 — 새로 로그인 진행 */ }
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const http = require("node:http");
|
|
887
|
+
let value;
|
|
888
|
+
try {
|
|
889
|
+
value = await new Promise((resolve, reject) => {
|
|
890
|
+
let settled = false;
|
|
891
|
+
const done = (fn, arg) => { if (!settled) { settled = true; fn(arg); } };
|
|
892
|
+
const server = http.createServer((req, res) => {
|
|
893
|
+
let u;
|
|
894
|
+
try { u = new URL(req.url, "http://127.0.0.1"); } catch { res.writeHead(400); res.end(); return; }
|
|
895
|
+
if (!u.pathname.startsWith("/callback")) { res.writeHead(404); res.end("not found"); return; }
|
|
896
|
+
const v = u.searchParams.get("session") || u.searchParams.get("token");
|
|
897
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
898
|
+
res.end("<html><body style=\"font-family:-apple-system,sans-serif;padding:40px\"><h3>Agentlas 로그인 완료</h3><p>터미널로 돌아가세요. 이 창은 닫아도 됩니다.</p></body></html>");
|
|
899
|
+
server.close();
|
|
900
|
+
if (v) done(resolve, v);
|
|
901
|
+
else done(reject, new Error("콜백에 session 값이 없습니다."));
|
|
902
|
+
});
|
|
903
|
+
server.on("error", (e) => done(reject, e));
|
|
904
|
+
server.listen(0, "127.0.0.1", () => {
|
|
905
|
+
const port = server.address().port;
|
|
906
|
+
const cb = encodeURIComponent(`http://127.0.0.1:${port}/callback`);
|
|
907
|
+
const url = `${webBaseUrl()}/account?desktop=1&callback=${cb}`;
|
|
908
|
+
D.out("브라우저에서 Agentlas에 로그인하세요 (자동으로 열립니다):");
|
|
909
|
+
D.out(" " + url);
|
|
910
|
+
openInBrowser(url);
|
|
911
|
+
});
|
|
912
|
+
const t = setTimeout(() => { try { server.close(); } catch { /* ignore */ } done(reject, new Error("로그인 대기 시간(180초)이 지났습니다. 다시 시도: agentlas login")); }, 180_000);
|
|
913
|
+
if (t.unref) t.unref();
|
|
914
|
+
});
|
|
915
|
+
} catch (e) {
|
|
916
|
+
return D.fail(String((e && e.message) || e));
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const p = D.cliSessionPath();
|
|
920
|
+
fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
|
|
921
|
+
fs.writeFileSync(p, JSON.stringify({ version: 1, value, updatedAt: new Date().toISOString() }, null, 2) + "\n", { mode: 0o600 });
|
|
922
|
+
try { fs.chmodSync(p, 0o600); } catch { /* win32 */ }
|
|
923
|
+
D.out(`세션 저장됨: ${p}`);
|
|
924
|
+
await cmdWhoami();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function cmdLogout() {
|
|
928
|
+
const p = D.cliSessionPath();
|
|
929
|
+
if (fs.existsSync(p)) {
|
|
930
|
+
try { fs.rmSync(p); D.out("로그아웃 완료 (CLI 세션 삭제)."); } catch (e) { return D.fail("세션 파일 삭제 실패: " + e.message); }
|
|
931
|
+
} else {
|
|
932
|
+
D.out("저장된 CLI 세션이 없습니다.");
|
|
933
|
+
}
|
|
934
|
+
if (process.env.AGENTLAS_SESSION) D.out("주의: AGENTLAS_SESSION 환경변수가 여전히 설정돼 있어 로그인 상태로 보일 수 있습니다.");
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// ── cloud search — 마켓플레이스 검색 ──
|
|
938
|
+
async function cloudSearch(db, args) {
|
|
939
|
+
let limit = 10;
|
|
940
|
+
const rest = [];
|
|
941
|
+
for (let i = 0; i < args.length; i++) {
|
|
942
|
+
if (args[i] === "--limit") limit = Math.max(1, Math.min(30, Number(args[++i]) || 10));
|
|
943
|
+
else rest.push(args[i]);
|
|
944
|
+
}
|
|
945
|
+
const query = rest.join(" ").trim();
|
|
946
|
+
if (!query) return D.fail('usage: agentlas cloud search "<찾는 일>" [--limit 10]');
|
|
947
|
+
if (typeof fetch !== "function") return D.fail("이 런타임에 fetch가 없습니다.");
|
|
948
|
+
const base = process.env.AGENTLAS_MCP_BASE_URL || "https://agentlas.cloud/api/mcp/v1";
|
|
949
|
+
const headers = { "content-type": "application/json" };
|
|
950
|
+
try {
|
|
951
|
+
const cookie = await D.cloudSessionCookieCli();
|
|
952
|
+
if (cookie) headers.cookie = cookie;
|
|
953
|
+
} catch { /* 로그인 없어도 검색은 가능 */ }
|
|
954
|
+
let resp;
|
|
955
|
+
try {
|
|
956
|
+
resp = await fetch(`${base.replace(/\/$/, "")}/tools/call`, {
|
|
957
|
+
method: "POST",
|
|
958
|
+
headers,
|
|
959
|
+
body: JSON.stringify({
|
|
960
|
+
method: "marketplace.search_agents",
|
|
961
|
+
params: { name: "marketplace.search_agents", arguments: { query, limit } },
|
|
962
|
+
}),
|
|
963
|
+
});
|
|
964
|
+
} catch (e) {
|
|
965
|
+
return D.fail(`마켓플레이스 연결 실패: ${(e && e.message) || e}`);
|
|
966
|
+
}
|
|
967
|
+
if (!resp.ok) return D.fail(`마켓플레이스 응답 ${resp.status}`);
|
|
968
|
+
const json = await resp.json();
|
|
969
|
+
if (json.error) return D.fail(json.error.message || "marketplace error");
|
|
970
|
+
const result = json.result || {};
|
|
971
|
+
const items = result.results || result.agents || result.items || (Array.isArray(result) ? result : null);
|
|
972
|
+
if (!Array.isArray(items) || !items.length) {
|
|
973
|
+
D.out(`검색 결과 없음: "${query}"`);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
for (const it of items.slice(0, limit)) {
|
|
977
|
+
const slug = it.slug || it.id || "?";
|
|
978
|
+
const name = it.name || it.title || slug;
|
|
979
|
+
const kind = it.kind || it.entity_kind || "";
|
|
980
|
+
const tagline = it.tagline || it.description || "";
|
|
981
|
+
D.out(`${String(slug).padEnd(34).slice(0, 34)} ${String(name).slice(0, 26).padEnd(27)} ${String(kind).padEnd(14)} ${String(tagline).slice(0, 60)}`);
|
|
982
|
+
}
|
|
983
|
+
D.out("");
|
|
984
|
+
D.out("설치: agentlas cloud install <slug>");
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
return {
|
|
988
|
+
cmdStorm, stormRun, cmdSwarm, swarmRun, cmdAutomation, cmdUsage, cmdTelegram, cloudSearch,
|
|
989
|
+
cmdLogin, cmdLogout, cmdWhoami, cmdHep, runHephaestusInteractive, cmdMcp, cmdChats,
|
|
990
|
+
nextCronRun, parseSwarmOutput,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
module.exports = { create };
|